diff --git a/.storybook/decorators.jsx b/.storybook/decorators.jsx new file mode 100644 index 00000000..835adba4 --- /dev/null +++ b/.storybook/decorators.jsx @@ -0,0 +1,9 @@ +import Constrain from '../source/02-layouts/Constrain/Constrain'; + +const withGlobalWrapper = Story => ( + + + +); + +export { withGlobalWrapper }; diff --git a/source/00-config/index.css b/source/00-config/index.css index bfa6cb8f..23b92162 100644 --- a/source/00-config/index.css +++ b/source/00-config/index.css @@ -12,5 +12,6 @@ @layer config.usage { @import 'vars/colors.css'; + @import 'vars/dynamic.css'; @import 'vars/form.css'; } diff --git a/source/00-config/vars/colors.css b/source/00-config/vars/colors.css index 3910ab9c..686b87d8 100644 --- a/source/00-config/vars/colors.css +++ b/source/00-config/vars/colors.css @@ -21,8 +21,9 @@ --selection-text: var(--grayscale-white); --table-background: var(--grayscale-white); - --tablebackground-foot: var(--grayscale-gray-1); + --table-background-foot: var(--grayscale-gray-1); --table-background-head: var(--grayscale-gray-1); + --table-background-sorted: var(--brand-blue-light-2); --table-border: var(--grayscale-gray-5); --text-link: var(--brand-blue-base); diff --git a/source/00-config/vars/dynamic.css b/source/00-config/vars/dynamic.css new file mode 100644 index 00000000..7003f2c8 --- /dev/null +++ b/source/00-config/vars/dynamic.css @@ -0,0 +1,3 @@ +:root { + --scrollbar-width: 0px; +} diff --git a/source/01-global/icon/icons/Sort.tsx b/source/01-global/icon/icons/Sort.tsx new file mode 100644 index 00000000..29e2d930 --- /dev/null +++ b/source/01-global/icon/icons/Sort.tsx @@ -0,0 +1,37 @@ +// organize-imports-ignore +// This component is automatically generated. +// SVGs should be added to icon/svgs. +// See the project documentation for more information. +// tslint:disable:ordered-imports +import clsx from 'clsx'; +import * as React from 'react'; +import type { SVGProps } from 'react'; +interface SVGRProps { + title?: string; + titleId?: string; + isHidden?: boolean; + modifierClasses?: string | string[]; +} +const SvgSort = ({ + modifierClasses, + isHidden, + title, + titleId, + ...props +}: SVGProps & SVGRProps) => { + return ( + + {title ? {title} : null} + + + ); +}; +export default SvgSort; diff --git a/source/01-global/icon/icons/Sorted.tsx b/source/01-global/icon/icons/Sorted.tsx new file mode 100644 index 00000000..aa0cf55f --- /dev/null +++ b/source/01-global/icon/icons/Sorted.tsx @@ -0,0 +1,37 @@ +// organize-imports-ignore +// This component is automatically generated. +// SVGs should be added to icon/svgs. +// See the project documentation for more information. +// tslint:disable:ordered-imports +import clsx from 'clsx'; +import * as React from 'react'; +import type { SVGProps } from 'react'; +interface SVGRProps { + title?: string; + titleId?: string; + isHidden?: boolean; + modifierClasses?: string | string[]; +} +const SvgSorted = ({ + modifierClasses, + isHidden, + title, + titleId, + ...props +}: SVGProps & SVGRProps) => { + return ( + + {title ? {title} : null} + + + ); +}; +export default SvgSorted; diff --git a/source/01-global/icon/icons/index.tsx b/source/01-global/icon/icons/index.tsx index cf40248b..808f4159 100644 --- a/source/01-global/icon/icons/index.tsx +++ b/source/01-global/icon/icons/index.tsx @@ -31,6 +31,12 @@ const Icons = { Rss: dynamic(() => import('./Rss'), { loading: () => , }), + Sort: dynamic(() => import('./Sort'), { + loading: () => , + }), + Sorted: dynamic(() => import('./Sorted'), { + loading: () => , + }), Twitter: dynamic(() => import('./Twitter'), { loading: () => , }), diff --git a/source/01-global/icon/svgs/sort.svg b/source/01-global/icon/svgs/sort.svg new file mode 100644 index 00000000..dbbb5615 --- /dev/null +++ b/source/01-global/icon/svgs/sort.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/source/01-global/icon/svgs/sorted.svg b/source/01-global/icon/svgs/sorted.svg new file mode 100644 index 00000000..fe045879 --- /dev/null +++ b/source/01-global/icon/svgs/sorted.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/source/02-layouts/SiteContainer/SiteContainer.tsx b/source/02-layouts/SiteContainer/SiteContainer.tsx index 820c73a3..92453285 100644 --- a/source/02-layouts/SiteContainer/SiteContainer.tsx +++ b/source/02-layouts/SiteContainer/SiteContainer.tsx @@ -1,5 +1,8 @@ +'use client'; + import { GessoComponent } from 'gesso'; -import { ReactNode } from 'react'; +import { JSX, ReactNode, useEffect } from 'react'; +import setScrollbarProperty from '../../06-utility/setScrollbarProperty'; import styles from './site-container.module.css'; interface SiteContainerProps extends GessoComponent { @@ -7,6 +10,10 @@ interface SiteContainerProps extends GessoComponent { } function SiteContainer({ children }: SiteContainerProps): JSX.Element { + useEffect(() => { + setScrollbarProperty(); + }, []); + return
{children}
; } diff --git a/source/03-components/Table/SortableHeader.tsx b/source/03-components/Table/SortableHeader.tsx new file mode 100644 index 00000000..c0b1d8bb --- /dev/null +++ b/source/03-components/Table/SortableHeader.tsx @@ -0,0 +1,48 @@ +import { JSX, MouseEvent } from 'react'; +import Sort from '../../01-global/icon/icons/Sort'; +import Sorted from '../../01-global/icon/icons/Sorted'; +import styles from './table.module.css'; + +interface SortableHeaderProps { + column: string; + label: string; + direction?: 'ascending' | 'descending'; + updateSort: (newSortColumn: string) => void; +} + +function SortableHeader({ + column, + label, + direction, + updateSort, +}: SortableHeaderProps): JSX.Element { + const Icon = direction ? Sorted : Sort; + + const handleClick = (e: MouseEvent) => { + 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 && } + + + + + + + + + {data.map(row => ( + + + + + + ))} + +
{caption}
CityCountryPopulation
{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 && } + + + + + + + + + {sortedData.map(row => ( + + + + + + ))} + +
{caption}
{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;