) => {
+ e.preventDefault();
+ updateSort(column);
+ };
+
+ return (
+ |
+
+ {label}
+
+
+ |
+ );
+}
+
+export default SortableHeader;
diff --git a/source/03-components/Table/Table.stories.tsx b/source/03-components/Table/Table.stories.tsx
new file mode 100644
index 00000000..9727a079
--- /dev/null
+++ b/source/03-components/Table/Table.stories.tsx
@@ -0,0 +1,189 @@
+import { Meta, StoryObj } from '@storybook/react';
+import clsx from 'clsx';
+import { useMemo, useState } from 'react';
+import { withGlobalWrapper } from '../../../.storybook/decorators';
+import SiteContainer from '../../02-layouts/SiteContainer/SiteContainer';
+import SortableHeader from './SortableHeader';
+import tableData from './table.data.yml';
+import styles from './table.module.css';
+
+type ExampleData = {
+ city: string;
+ country: string;
+ population: number;
+};
+
+const settings: Meta<{
+ isScrollable?: boolean;
+ caption?: string;
+ data: ExampleData[];
+}> = {
+ title: 'Components/Table',
+ decorators: [
+ Story => (
+
+
+
+ ),
+ withGlobalWrapper,
+ ],
+ args: {
+ isScrollable: false,
+ caption: 'Table caption',
+ },
+ argTypes: {
+ isScrollable: {
+ type: 'boolean',
+ },
+ caption: {
+ type: 'string',
+ },
+ },
+ parameters: {
+ controls: {
+ include: ['isScrollable', 'caption'],
+ },
+ },
+ tags: ['autodocs'],
+};
+
+type Story = StoryObj<{
+ isScrollable?: boolean;
+ caption?: string;
+ data: ExampleData[];
+}>;
+
+const Default: Story = {
+ render: ({ isScrollable, caption, data }) => {
+ return (
+
+ {caption && {caption}}
+
+
+ | City |
+ Country |
+ Population |
+
+
+
+ {data.map(row => (
+
+ | {row.city} |
+ {row.country} |
+ {Number(row.population).toLocaleString('en-US')} |
+
+ ))}
+
+
+ );
+ },
+ args: {
+ ...tableData,
+ },
+};
+
+const Sortable: Story = {
+ render: function SortableTable({ isScrollable, caption, data }) {
+ const [sortColumn, setSortColumn] = useState<
+ 'city' | 'country' | 'population' | undefined
+ >(undefined);
+ const [sortDirection, setSortDirection] = useState<
+ 'ascending' | 'descending'
+ >('ascending');
+ const sortedData = useMemo(
+ () =>
+ sortColumn
+ ? [...data].sort((thisRow, nextRow) => {
+ const cellA =
+ sortDirection === 'ascending'
+ ? thisRow[sortColumn]
+ : nextRow[sortColumn];
+ const cellB =
+ sortDirection === 'ascending'
+ ? nextRow[sortColumn]
+ : thisRow[sortColumn];
+ if (typeof cellA === 'number' && typeof cellB === 'number') {
+ return cellA - cellB;
+ }
+ return cellA.toString().localeCompare(cellB.toString());
+ })
+ : data,
+ [data, sortColumn, sortDirection],
+ );
+
+ const updateSort = (newSortColumn: string) => {
+ if (sortColumn === newSortColumn) {
+ setSortDirection(prevState =>
+ prevState === 'ascending' ? 'descending' : 'ascending',
+ );
+ } else {
+ setSortColumn(newSortColumn as 'city' | 'country' | 'population');
+ setSortDirection('ascending');
+ }
+ };
+
+ return (
+ <>
+
+ {caption && {caption}}
+
+
+
+
+
+
+
+
+ {sortedData.map(row => (
+
+ | {row.city} |
+ {row.country} |
+ {Number(row.population).toLocaleString('en-US')} |
+
+ ))}
+
+
+
+ {sortColumn
+ ? `The table ${caption && `named "${caption}"`} is now sorted by
+ ${sortColumn} in ${sortDirection} order.`
+ : ''}
+
+ >
+ );
+ },
+ args: {
+ ...tableData,
+ },
+};
+
+export default settings;
+export { Default, Sortable };
diff --git a/source/03-components/Table/table.data.yml b/source/03-components/Table/table.data.yml
new file mode 100644
index 00000000..45cc1788
--- /dev/null
+++ b/source/03-components/Table/table.data.yml
@@ -0,0 +1,61 @@
+data:
+ - city: 'Tokyo'
+ country: 'Japan'
+ population: '37468000'
+ - city: 'Delhi'
+ country: 'India'
+ population: '28514000'
+ - city: 'Shanghai'
+ country: 'China'
+ population: '25582000'
+ - city: 'São Paulo'
+ country: 'Brazil'
+ population: '21650000'
+ - city: 'Mexico City'
+ country: 'Mexico'
+ population: '21581000'
+ - city: 'Cairo'
+ country: 'Egypt'
+ population: '20076000'
+ - city: 'Mumbai'
+ country: 'India'
+ population: '19980000'
+ - city: 'Beijing'
+ country: 'China'
+ population: '19618000'
+ - city: 'Dhaka'
+ country: 'Bangladesh'
+ population: '19578000'
+ - city: 'Osaka'
+ country: 'Japan'
+ population: '19281000'
+ - city: 'New York'
+ country: 'United States'
+ population: '18819000'
+ - city: 'Karachi'
+ country: 'Pakistan'
+ population: '15400000'
+ - city: 'Buenos Aires'
+ country: 'Argentina'
+ population: '14967000'
+ - city: 'Chongqing'
+ country: 'China'
+ population: '14838000'
+ - city: 'Istanbul'
+ country: 'Turkey'
+ population: '14751000'
+ - city: 'Kolkata'
+ country: 'India'
+ population: '14681000'
+ - city: 'Manila'
+ country: 'Philippines'
+ population: '13482000'
+ - city: 'Lagos'
+ country: 'Nigeria'
+ population: '13463000'
+ - city: 'Rio de Janeiro'
+ country: 'Brazil'
+ population: '13293000'
+ - city: 'Tianjin'
+ country: 'China'
+ population: '13215000'
diff --git a/source/03-components/Table/table.module.css b/source/03-components/Table/table.module.css
new file mode 100644
index 00000000..9fe84413
--- /dev/null
+++ b/source/03-components/Table/table.module.css
@@ -0,0 +1,60 @@
+@import 'mixins';
+
+@layer components {
+ .is-scrollable {
+ thead {
+ display: table;
+ inline-size: calc(100% - var(--scrollbar-width));
+ table-layout: fixed;
+ }
+
+ tbody {
+ display: block;
+ max-block-size: rem-convert(365px);
+ overflow: hidden auto;
+
+ tr {
+ display: table;
+ inline-size: 100%;
+ table-layout: fixed;
+ }
+ }
+ }
+
+ .is-sortable {
+ th[data-sortable] {
+ padding-inline-end: var(--spacing-5);
+ position: relative;
+
+ &[aria-sort] {
+ background-color: var(--table-background-sorted);
+ }
+
+ &[aria-sort='descending'] {
+ :global(.icon) {
+ rotate: 180deg;
+ }
+ }
+ }
+ }
+
+ .header-button {
+ @include text-button;
+
+ color: inherit;
+ display: block;
+ inline-size: var(--spacing-4);
+ inset-block: var(--spacing-0-5);
+ inset-inline-end: var(--spacing-0-5);
+ outline-offset: 0;
+ position: absolute;
+
+ &:focus-visible {
+ outline-color: var(--grayscale-white);
+ }
+ }
+
+ .announcement-region {
+ @include visually-hidden;
+ }
+}
diff --git a/source/06-utility/setScrollbarProperty.ts b/source/06-utility/setScrollbarProperty.ts
new file mode 100644
index 00000000..bcb71bd6
--- /dev/null
+++ b/source/06-utility/setScrollbarProperty.ts
@@ -0,0 +1,30 @@
+/**
+ * Calculate the width of the vertical scrollbar.
+ * via https://codepen.io/Mamboleoo/post/scrollbars-and-css-custom-properties
+ */
+function calculateScrollbarSize(): number {
+ const containerWithScroll = document.createElement('div');
+ containerWithScroll.style.visibility = 'hidden';
+ containerWithScroll.style.overflow = 'scroll';
+ const innerContainer = document.createElement('div');
+ containerWithScroll.appendChild(innerContainer);
+ document.body.appendChild(containerWithScroll);
+ const width = containerWithScroll.offsetWidth - innerContainer.offsetWidth;
+ document.body.removeChild(containerWithScroll);
+ return width;
+}
+
+/**
+ * Set a CSS variable with the width of the scrollbar.
+ */
+function setScrollbarProperty(): void {
+ const scrollbarWidth = calculateScrollbarSize();
+ if (!Number.isNaN(scrollbarWidth)) {
+ document.documentElement.style.setProperty(
+ '--scrollbar-width',
+ `${scrollbarWidth}px`,
+ );
+ }
+}
+
+export default setScrollbarProperty;