diff --git a/apps/site/components/Releases/PreviousReleasesTable.tsx b/apps/site/components/Releases/PreviousReleasesTable.tsx index 19705cf0958aa..b8148713ccc97 100644 --- a/apps/site/components/Releases/PreviousReleasesTable.tsx +++ b/apps/site/components/Releases/PreviousReleasesTable.tsx @@ -1,6 +1,7 @@ 'use client'; import Badge from '@node-core/ui-components/Common/Badge'; +import ResponsiveTable from '@node-core/ui-components/Common/ResponsiveTable'; import { useTranslations } from 'next-intl'; import type { FC } from 'react'; import { useState } from 'react'; @@ -26,61 +27,63 @@ const PreviousReleasesTable: FC = () => { const [currentModal, setCurrentModal] = useState(); - return ( - - - - - - - - - - - - - - {releaseData.map(release => ( - <> - - - - - - + const columns = [ + { key: 'version', header: t('components.downloadReleasesTable.version') }, + { key: 'codename', header: t('components.downloadReleasesTable.codename') }, + { + key: 'firstReleased', + header: t('components.downloadReleasesTable.firstReleased'), + }, + { + key: 'lastUpdated', + header: t('components.downloadReleasesTable.lastUpdated'), + }, + { key: 'status', header: t('components.downloadReleasesTable.status') }, + { key: 'details', header: '' }, + ]; + + const data = releaseData.map(release => ({ + version: `v${release.major}`, + codename: release.codename || '-', + firstReleased: , + lastUpdated: , + status: ( + + {release.status} + {release.status === 'End-of-life' ? ' (EoL)' : ''} + + ), + details: ( + setCurrentModal(release.version)} + > + {t('components.downloadReleasesTable.details')} + + ), + })); - - - - - - - - open || setCurrentModal(undefined)} - /> - - ))} - -
{t('components.downloadReleasesTable.version')}{t('components.downloadReleasesTable.codename')}{t('components.downloadReleasesTable.firstReleased')}{t('components.downloadReleasesTable.lastUpdated')}{t('components.downloadReleasesTable.status')}
v{release.major}{release.codename || '-'} - - - - - - {release.status} - {release.status === 'End-of-life' ? ' (EoL)' : ''} - - - setCurrentModal(release.version)} - > - {t('components.downloadReleasesTable.details')} - -
+ return ( + <> + data.version} + getRowLabel={data => data.version} + /> + {releaseData.map(release => ( + open || setCurrentModal(undefined)} + /> + ))} + ); }; diff --git a/apps/site/next.mdx.use.client.mjs b/apps/site/next.mdx.use.client.mjs index 67bdc3b4b16d4..bd253be8d4496 100644 --- a/apps/site/next.mdx.use.client.mjs +++ b/apps/site/next.mdx.use.client.mjs @@ -2,6 +2,7 @@ import Blockquote from '@node-core/ui-components/Common/Blockquote'; import MDXCodeTabs from '@node-core/ui-components/MDX/CodeTabs'; +import MDXTable from '@node-core/ui-components/MDX/Table'; import DownloadButton from './components/Downloads/DownloadButton'; import BlogPostLink from './components/Downloads/Release/BlogPostLink'; @@ -71,4 +72,5 @@ export const htmlComponents = { pre: MDXCodeBox, // Renders an Image Component for `img` tags img: MDXImage, + table: MDXTable, }; diff --git a/packages/ui-components/src/Common/Card/index.module.css b/packages/ui-components/src/Common/Card/index.module.css new file mode 100644 index 0000000000000..3803a8f8e3043 --- /dev/null +++ b/packages/ui-components/src/Common/Card/index.module.css @@ -0,0 +1,18 @@ +@reference "../../styles/index.css"; + +.card { + @apply rounded-xs + border + border-neutral-200 + bg-transparent + p-4 + dark:border-neutral-800; + + .header { + @apply mb-2 + text-sm + font-medium + text-gray-500 + dark:text-gray-400; + } +} diff --git a/packages/ui-components/src/Common/Card/index.stories.tsx b/packages/ui-components/src/Common/Card/index.stories.tsx new file mode 100644 index 0000000000000..95d5c328ce594 --- /dev/null +++ b/packages/ui-components/src/Common/Card/index.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +import { Card, CardHeader, CardBody } from './index'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = { + render: args => ( + + Card Header + Card Body Content + + ), +}; + +export default { + component: Card, +} as Meta; diff --git a/packages/ui-components/src/Common/Card/index.tsx b/packages/ui-components/src/Common/Card/index.tsx new file mode 100644 index 0000000000000..bad15e07f5048 --- /dev/null +++ b/packages/ui-components/src/Common/Card/index.tsx @@ -0,0 +1,18 @@ +import classNames from 'classnames'; +import type { ComponentPropsWithRef, FC } from 'react'; + +import styles from './index.module.css'; + +type CardProps = ComponentPropsWithRef<'div'>; + +export const Card: FC = ({ className, ...props }) => { + return
; +}; + +export const CardHeader: FC = ({ className, ...props }) => { + return
; +}; + +export const CardBody: FC = props => { + return
; +}; diff --git a/packages/ui-components/src/Common/ResponsiveTable/DesktopTable/index.tsx b/packages/ui-components/src/Common/ResponsiveTable/DesktopTable/index.tsx new file mode 100644 index 0000000000000..234edd935f36a --- /dev/null +++ b/packages/ui-components/src/Common/ResponsiveTable/DesktopTable/index.tsx @@ -0,0 +1,32 @@ +import type { TableData } from '#ui/types'; + +import type { ResponsiveTableProps } from '..'; + +function DesktopTable({ + data, + columns, + getRowId, +}: ResponsiveTableProps) { + return ( + + + + {columns.map(column => ( + + ))} + + + + {data.map((row, index) => ( + + {columns.map(column => ( + + ))} + + ))} + +
{column.header}
{row[column.key]}
+ ); +} + +export default DesktopTable; diff --git a/packages/ui-components/src/Common/ResponsiveTable/MobileTable/index.module.css b/packages/ui-components/src/Common/ResponsiveTable/MobileTable/index.module.css new file mode 100644 index 0000000000000..577050bf6b758 --- /dev/null +++ b/packages/ui-components/src/Common/ResponsiveTable/MobileTable/index.module.css @@ -0,0 +1,25 @@ +@reference "../../../styles/index.css"; + +.row { + @apply flex + items-center + justify-between + gap-2 + border-b + border-gray-100 + py-2 + last:border-b-0 + dark:border-neutral-800; + + .header { + @apply font-medium + text-gray-700 + dark:text-gray-200; + } + + .value { + @apply text-right + text-gray-600 + dark:text-gray-300; + } +} diff --git a/packages/ui-components/src/Common/ResponsiveTable/MobileTable/index.tsx b/packages/ui-components/src/Common/ResponsiveTable/MobileTable/index.tsx new file mode 100644 index 0000000000000..c6440a30010b7 --- /dev/null +++ b/packages/ui-components/src/Common/ResponsiveTable/MobileTable/index.tsx @@ -0,0 +1,38 @@ +import type { TableData } from '#ui/types'; + +import type { ResponsiveTableProps } from '..'; +import styles from './index.module.css'; +import { Card, CardBody, CardHeader } from '../../Card'; + +function MobileTable({ + data, + columns, + getRowId, + getRowLabel, +}: ResponsiveTableProps) { + return ( +
+ {data.map((row, index) => ( + + {getRowLabel && {getRowLabel(row)}} + + + {columns.map(column => ( +
+
+ {column.header} +
+ +
+ {row[column.key]} +
+
+ ))} +
+
+ ))} +
+ ); +} + +export default MobileTable; diff --git a/packages/ui-components/src/Common/ResponsiveTable/index.stories.tsx b/packages/ui-components/src/Common/ResponsiveTable/index.stories.tsx new file mode 100644 index 0000000000000..5a32d47fe3765 --- /dev/null +++ b/packages/ui-components/src/Common/ResponsiveTable/index.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +import ResponsiveTable from './index'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = { + args: { + data: [ + { id: 1, name: 'John Doe', email: 'john@example.com' }, + { id: 2, name: 'Jane Smith', email: 'jane@example.com' }, + { id: 3, name: 'Bob Johnson', email: 'bob@example.com' }, + ], + columns: [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + { key: 'email', header: 'Email' }, + ], + getRowId: row => String(row.id), + getRowLabel: row => String(row.name), + }, +}; + +export default { component: ResponsiveTable } as Meta; diff --git a/packages/ui-components/src/Common/ResponsiveTable/index.tsx b/packages/ui-components/src/Common/ResponsiveTable/index.tsx new file mode 100644 index 0000000000000..999d08d9ece07 --- /dev/null +++ b/packages/ui-components/src/Common/ResponsiveTable/index.tsx @@ -0,0 +1,27 @@ +import type { TableColumn, TableData } from '#ui/types'; + +import DesktopTable from './DesktopTable'; +import MobileTable from './MobileTable'; + +export interface ResponsiveTableProps { + data: Array; + columns: Array; + getRowId: (row: T, index: number) => string; + getRowLabel?: (row: T) => string; +} + +function ResponsiveTable(props: ResponsiveTableProps) { + return ( +
+
+ +
+ +
+ +
+
+ ); +} + +export default ResponsiveTable; diff --git a/packages/ui-components/src/MDX/Table/index.tsx b/packages/ui-components/src/MDX/Table/index.tsx new file mode 100644 index 0000000000000..a8966e6ed4744 --- /dev/null +++ b/packages/ui-components/src/MDX/Table/index.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; + +import { parseTableStructure } from '#ui/util/table'; + +import ResponsiveTable from '../../Common/ResponsiveTable'; + +const Table = ({ children }: { children: ReactNode }) => { + const { data, columns } = parseTableStructure(children); + + return ( + String(index)} + /> + ); +}; + +export default Table; diff --git a/packages/ui-components/src/types.ts b/packages/ui-components/src/types.ts index 86088827f76e8..314861ef2c961 100644 --- a/packages/ui-components/src/types.ts +++ b/packages/ui-components/src/types.ts @@ -23,3 +23,10 @@ export type SimpleLocaleConfig = { localName: string; name: string; }; + +export type TableColumn = { + key: string; + header: string; +}; + +export type TableData = Record; diff --git a/packages/ui-components/src/util/table.ts b/packages/ui-components/src/util/table.ts new file mode 100644 index 0000000000000..2344e0af4d970 --- /dev/null +++ b/packages/ui-components/src/util/table.ts @@ -0,0 +1,98 @@ +import { + Children, + isValidElement, + type ReactElement, + type ReactNode, +} from 'react'; + +import type { TableColumn, TableData } from '#ui/types'; + +const hasChildren = (props: unknown): props is { children: ReactNode } => + typeof props === 'object' && props !== null && 'children' in props; + +const isTableElement = ( + child: ReactNode, + tagName: string +): child is ReactElement<{ children?: ReactNode }> => + isValidElement(child) && child.type === tagName; + +const getTextContent = (node: ReactNode): string => { + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(getTextContent).join(''); + if (isValidElement(node) && hasChildren(node.props)) + return getTextContent(node.props.children); + return ''; +}; + +const getColumnKey = (headerText: string, index: number): string => + headerText + ? headerText.toLowerCase().trim().replace(/\s+/g, '_') + : `col_${index}`; + +export const extractColumns = (thead: ReactElement): Array => { + if (!hasChildren(thead.props) || !thead.props.children) return []; + + const headerRows = Children.toArray(thead.props.children); + const firstRow = headerRows[0]; + if (!isValidElement(firstRow) || !hasChildren(firstRow.props)) return []; + + const headerCells = Children.toArray(firstRow.props.children); + + return headerCells + .map((cell, index) => { + if (!isValidElement(cell) || !hasChildren(cell.props)) return null; + + const headerText = getTextContent(cell.props.children); + const key = getColumnKey(headerText, index); + + return { + key: key, + header: headerText, + }; + }) + .filter((col): col is TableColumn => col !== null); +}; + +export const extractData = ( + tbody: ReactElement, + columns: Array +): Array => { + if (!hasChildren(tbody.props) || !tbody.props.children) return []; + + const bodyRows = Children.toArray(tbody.props.children); + + return bodyRows + .map(row => { + if (!isValidElement(row) || !hasChildren(row.props)) return null; + + const cells = Children.toArray(row.props.children); + const rowData: TableData = {}; + + cells.forEach((cell, i) => { + if (isValidElement(cell) && hasChildren(cell.props) && columns[i]) { + rowData[columns[i].key] = cell.props.children; + } + }); + + return rowData; + }) + .filter((row): row is TableData => row !== null); +}; + +export const parseTableStructure = ( + children: ReactNode +): { columns: Array; data: Array } => { + if (!children) return { columns: [], data: [] }; + + const nodes = Children.toArray(children); + const thead = nodes.find(node => isTableElement(node, 'thead')); + const tbody = nodes.find(node => isTableElement(node, 'tbody')); + + if (!thead) throw new Error('Thead element not found'); + if (!tbody) throw new Error('Tbody element not found'); + + const columns = extractColumns(thead); + const data = extractData(tbody, columns); + + return { columns, data }; +};