Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 57 additions & 54 deletions apps/site/components/Releases/PreviousReleasesTable.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -26,61 +27,63 @@ const PreviousReleasesTable: FC = () => {

const [currentModal, setCurrentModal] = useState<string | undefined>();

return (
<table id="tbVersions">
<thead>
<tr>
<th>{t('components.downloadReleasesTable.version')}</th>
<th>{t('components.downloadReleasesTable.codename')}</th>
<th>{t('components.downloadReleasesTable.firstReleased')}</th>
<th>{t('components.downloadReleasesTable.lastUpdated')}</th>
<th>{t('components.downloadReleasesTable.status')}</th>
<th></th>
</tr>
</thead>

<tbody>
{releaseData.map(release => (
<>
<tr key={release.major}>
<td data-label="Version">v{release.major}</td>

<td data-label="LTS">{release.codename || '-'}</td>

<td data-label="Date">
<FormattedTime date={release.currentStart} />
</td>
const columns = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's my spicy take on this:

  • I don't like this design of passing header/data as props
  • This responsive table component should work as the regular HTML table
  • Pretty much should just be import Table from #ui/common/Table
  • That common table could already come with a mapper to map the children into the proper props
  • Then you won't need a MDX version of this component. I believe you can also handle the type of the component as the same type of an actual HTML table.

What is really confusing here is the separate DOM structure for Mobile and Desktop tables and that you are pretty much rendering a much larger and complex DOM structure https://github.com/nodejs/nodejs.org/pull/8079/files#diff-e77faf52f4e475e7e36e8b4f5c935b9a8c2fb506f89b70ec130a6ed602007c0cR13-R25 for every table.

I know this might sound harder, but I do really believe you can achieve all you want with pure CSS.

I don't even think we need a custom component, it's like we're reinventing the wheel here. I do believe you can achieve all you want purely based on CSS. Might be harder? Hell yes, even more that heading separation. Below is an example query I gave to ChatGPT:

Check the example styles below:

/* Base table styles (desktop) */
table {
  width: 100%;
  border-collapse: collapse;
}
thead th {
  text-align: left;
  padding: 8px;
}
tbody td {
  padding: 8px;
  border-bottom: 1px solid #ddd;
}

/* Mobile layout: stack each row */
@media (max-width: 600px) {
  thead {
    /* Hide the table header visually but keep it for accessibility */
    position: absolute;
    clip: rect(0 0 0 0);
    width: 1px;
    height: 1px;
    overflow: hidden;
  }

  table, tbody, tr, td {
    display: block;
    width: 100%;
  }

  tr {
    margin-bottom: 1em;
    border: 1px solid #ccc;
    padding: 8px;
  }

  td {
    /* Indent data and make space for label */
    position: relative;
    padding-left: 50%;
    text-align: left;
  }

  td::before {
    content: attr(data-title);
    position: absolute;
    top: 50%;
    left: 8px;
    transform: translateY(-50%);
    font-weight: bold;
    white-space: nowrap;
  }
}

How It Works

  • Desktop View: The table appears normally with headers on top.
  • Mobile View (max-width: 600px):
    • The <thead> is visually hidden for space-saving, but remains accessible.
    • Each <tr> becomes a block, spaced out like its own mini-card.
    • Each <td> displays a label from its data-title attribute using ::before, mimicking column headers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note the above is just an example, but you can absolutely 100% achieve this with pure CSS.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually don't think you can achieve the card-based layout with CSS (since, it requires addition text to be displayed) but you definitely can with a custom component matching the syntax of a table.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You posted this twice. But yes, it is possible to do it with pure CSS.

Copy link
Member

@avivkeller avivkeller Aug 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You posted this twice.

Oh, GitHub Mobile 😭

attr(data-title)

This would still require a custom component / modifying the markdown to add this field.

Copy link
Member

@ovflowd ovflowd Aug 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really... You can do that directly on Remark.

Copy link
Member

@ovflowd ovflowd Aug 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But if not on Remark, yes, you can make a wrapper, like a WithResponseTable that just introspects the thead and tbody and adds the title tag to the tr's. But I do prefer the remark path, as it can be easily done as a plugin for specifcially table elements, and visits the Nodes and then just updates the attributes :)

Which adds a bit more of processing (CPU) but at least less JSX/interpretation for the client-side.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes sense, I'll give a try

{ 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: <FormattedTime date={release.currentStart} />,
lastUpdated: <FormattedTime date={release.releaseDate} />,
status: (
<Badge
kind={BADGE_KIND_MAP[release.status]}
size="small"
className="block"
>
{release.status}
{release.status === 'End-of-life' ? ' (EoL)' : ''}
</Badge>
),
details: (
<LinkWithArrow
className="cursor-pointer"
onClick={() => setCurrentModal(release.version)}
>
{t('components.downloadReleasesTable.details')}
</LinkWithArrow>
),
}));

<td data-label="Date">
<FormattedTime date={release.releaseDate} />
</td>

<td data-label="Status">
<Badge kind={BADGE_KIND_MAP[release.status]} size="small">
{release.status}
{release.status === 'End-of-life' ? ' (EoL)' : ''}
</Badge>
</td>

<td>
<LinkWithArrow
className="cursor-pointer"
onClick={() => setCurrentModal(release.version)}
>
{t('components.downloadReleasesTable.details')}
</LinkWithArrow>
</td>
</tr>

<ReleaseModal
release={release}
open={currentModal === release.version}
onOpenChange={open => open || setCurrentModal(undefined)}
/>
</>
))}
</tbody>
</table>
return (
<>
<ResponsiveTable
data={data}
columns={columns}
getRowId={data => data.version}
getRowLabel={data => data.version}
/>
{releaseData.map(release => (
<ReleaseModal
key={release.version}
release={release}
open={currentModal === release.version}
onOpenChange={open => open || setCurrentModal(undefined)}
/>
))}
</>
);
};

Expand Down
2 changes: 2 additions & 0 deletions apps/site/next.mdx.use.client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,4 +72,5 @@ export const htmlComponents = {
pre: MDXCodeBox,
// Renders an Image Component for `img` tags
img: MDXImage,
table: MDXTable,
};
18 changes: 18 additions & 0 deletions packages/ui-components/src/Common/Card/index.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
19 changes: 19 additions & 0 deletions packages/ui-components/src/Common/Card/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Meta as MetaObj, StoryObj } from '@storybook/react';

import { Card, CardHeader, CardBody } from './index';

type Story = StoryObj<typeof Card>;
type Meta = MetaObj<typeof Card>;

export const Default: Story = {
render: args => (
<Card {...args}>
<CardHeader>Card Header</CardHeader>
<CardBody>Card Body Content</CardBody>
</Card>
),
};

export default {
component: Card,
} as Meta;
18 changes: 18 additions & 0 deletions packages/ui-components/src/Common/Card/index.tsx
Original file line number Diff line number Diff line change
@@ -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<CardProps> = ({ className, ...props }) => {
return <div className={classNames(styles.card, className)} {...props} />;
};

export const CardHeader: FC<CardProps> = ({ className, ...props }) => {
return <div className={classNames(styles.header, className)} {...props} />;
};

export const CardBody: FC<CardProps> = props => {
return <div {...props} />;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { TableData } from '#ui/types';

import type { ResponsiveTableProps } from '..';

function DesktopTable<T extends TableData>({
data,
columns,
getRowId,
}: ResponsiveTableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map(column => (
<th key={column.key}>{column.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, index) => (
<tr key={getRowId(row, index)}>
{columns.map(column => (
<td key={column.key}>{row[column.key]}</td>
))}
</tr>
))}
</tbody>
</table>
);
}

export default DesktopTable;
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<T extends TableData>({
data,
columns,
getRowId,
getRowLabel,
}: ResponsiveTableProps<T>) {
return (
<div role="table" className="space-y-4">
{data.map((row, index) => (
<Card role="rowgroup" key={getRowId(row, index)}>
{getRowLabel && <CardHeader>{getRowLabel(row)}</CardHeader>}

<CardBody role="row">
{columns.map(column => (
<div key={column.key} className={styles.row}>
<div role="columnheader" className={styles.header}>
{column.header}
</div>

<div role="cell" className={styles.value}>
{row[column.key]}
</div>
</div>
))}
</CardBody>
</Card>
))}
</div>
);
}

export default MobileTable;
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Meta as MetaObj, StoryObj } from '@storybook/react';

import ResponsiveTable from './index';

type Story = StoryObj<typeof ResponsiveTable>;
type Meta = MetaObj<typeof ResponsiveTable>;

export const Default: Story = {
args: {
data: [
{ id: 1, name: 'John Doe', email: '[email protected]' },
{ id: 2, name: 'Jane Smith', email: '[email protected]' },
{ id: 3, name: 'Bob Johnson', email: '[email protected]' },
],
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;
27 changes: 27 additions & 0 deletions packages/ui-components/src/Common/ResponsiveTable/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { TableColumn, TableData } from '#ui/types';

import DesktopTable from './DesktopTable';
import MobileTable from './MobileTable';

export interface ResponsiveTableProps<T extends TableData> {
data: Array<T>;
columns: Array<TableColumn>;
getRowId: (row: T, index: number) => string;
getRowLabel?: (row: T) => string;
}

function ResponsiveTable<T extends TableData>(props: ResponsiveTableProps<T>) {
return (
<div className="w-full">
<div className="hidden overflow-x-auto lg:block">
<DesktopTable {...props} />
</div>

<div className="lg:hidden">
<MobileTable {...props} />
</div>
</div>
);
}

export default ResponsiveTable;
20 changes: 20 additions & 0 deletions packages/ui-components/src/MDX/Table/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ResponsiveTable
data={data}
columns={columns}
// We have to use index as row id fallback
getRowId={(row, index) => String(index)}
/>
);
};

export default Table;
7 changes: 7 additions & 0 deletions packages/ui-components/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,10 @@ export type SimpleLocaleConfig = {
localName: string;
name: string;
};

export type TableColumn = {
key: string;
header: string;
};

export type TableData = Record<string, React.ReactNode>;
Loading
Loading