From dda5b21ac496aa023bc6a1b7d11c225572fb9557 Mon Sep 17 00:00:00 2001 From: Eirik Backer Date: Wed, 11 Sep 2024 20:08:48 +0200 Subject: [PATCH] fix(Table): use css conventions (#2393) Part of #2295 Sortable button does not fill table header cell, but this is no requirement - the TH can have the onclick listener, while the button is mostly there to give screen readers a focusable element with button role. Clicking the button will bubble up and trigger TH event listener anyway :) --------- Co-authored-by: Tobias Barsnes --- .changeset/eleven-bags-shop.md | 6 + .../Previews/Components/Components.tsx | 4 +- packages/css/table.css | 251 ++++++++++-------- .../src/components/Table/Table.stories.tsx | 34 ++- packages/react/src/components/Table/Table.tsx | 22 +- .../react/src/components/Table/TableBody.tsx | 21 +- .../react/src/components/Table/TableCell.tsx | 17 +- .../react/src/components/Table/TableHead.tsx | 22 +- .../components/Table/TableHeaderCell.test.tsx | 9 +- .../src/components/Table/TableHeaderCell.tsx | 74 +----- .../react/src/components/Table/TableRow.tsx | 17 +- packages/react/stories/showcase.stories.tsx | 4 +- 12 files changed, 219 insertions(+), 262 deletions(-) create mode 100644 .changeset/eleven-bags-shop.md diff --git a/.changeset/eleven-bags-shop.md b/.changeset/eleven-bags-shop.md new file mode 100644 index 0000000000..4fc80ab3f0 --- /dev/null +++ b/.changeset/eleven-bags-shop.md @@ -0,0 +1,6 @@ +--- +"@digdir/designsystemet-css": patch +"@digdir/designsystemet-react": patch +--- + +TableHeaderCell: Remove `sortable` prop, `sort` now handles this diff --git a/apps/theme/components/Previews/Components/Components.tsx b/apps/theme/components/Previews/Components/Components.tsx index d1c0d678f1..b705fb5c44 100644 --- a/apps/theme/components/Previews/Components/Components.tsx +++ b/apps/theme/components/Previews/Components/Components.tsx @@ -107,11 +107,11 @@ export const Components = () => { - + Navn Epost - + Telefon diff --git a/packages/css/table.css b/packages/css/table.css index 198ee88494..de010ba6d9 100644 --- a/packages/css/table.css +++ b/packages/css/table.css @@ -1,143 +1,160 @@ .ds-table { - --dsc-table-padding: 0; - --dsc-table-border-radius: min(1rem, var(--ds-border-radius-md)); - --dsc-table-border-color: var(--ds-color-neutral-border-subtle); + --dsc-table-background--hover: var(--ds-color-neutral-surface-default); + --dsc-table-background--zebra: var(--ds-color-neutral-background-subtle); + --dsc-table-background: var(--ds-color-neutral-background-default); + --dsc-table-border-radius: 0; + --dsc-table-border: 1px solid var(--ds-color-neutral-border-subtle); --dsc-table-color: var(--ds-color-neutral-text-default); - --dsc-table-header-cell-background: var(--ds-color-neutral-background-default); - --dsc-table-header-sorted-background: var(--ds-color-neutral-background-subtle); - --dsc-table-header-sorted-hover: var(--ds-color-neutral-surface-default); - --dsc-table-cell-backround: var(--ds-color-neutral-background-default); - --dsc-table-cell-zebra-background: var(--ds-color-neutral-background-subtle); - --dsc-table-cell-hover-backround: var(--ds-color-neutral-surface-default); - - position: relative; - border-collapse: separate; + --dsc-table-header-background--hover: var(--ds-color-neutral-surface-default); + --dsc-table-header-background--sorted: var(--ds-color-neutral-background-subtle); + --dsc-table-header-background: var(--ds-color-neutral-background-default); + --dsc-table-header-divider: 2px solid var(--ds-color-neutral-border-subtle); + --dsc-table-padding: var(--ds-spacing-2) var(--ds-spacing-3); + --dsc-table-sort-size: var(--ds-spacing-6); + + border-collapse: separate; /* Using separate mode to enable border-radius */ + border-radius: var(--dsc-table-border-radius); border-spacing: 0; - text-align: left; color: var(--dsc-table-color); width: 100%; -} - -.ds-table--sticky-header { - overflow: auto; -} -.ds-table--border .ds-table__row:last-of-type td { - border-bottom: 0; -} - -.ds-table--sm { - --dsc-table-padding: var(--ds-spacing-1) var(--ds-spacing-3); -} - -.ds-table--md { - --dsc-table-padding: var(--ds-spacing-2) var(--ds-spacing-3); -} + & > :is(tbody, thead) > tr > :is(th, td) { + background: var(--dsc-table-background); + border-bottom: var(--dsc-table-border); + padding: var(--dsc-table-padding); + text-align: inherit; -.ds-table--lg { - --dsc-table-padding: var(--ds-spacing-3) var(--ds-spacing-3); -} - -.ds-table__head { - z-index: 0; - box-sizing: border-box; - font-family: inherit; - border-spacing: 0; - border-bottom: 2px solid var(--dsc-table-border-color); -} - -.ds-table__header__cell { - padding: var(--dsc-table-padding); - font-family: inherit; - background-color: var(--dsc-table-header-cell-background); - border-spacing: 0; - border-bottom: 2px solid var(--dsc-table-border-color); -} + &:is(th) { + font-weight: 500; + } + } -.ds-table--sticky-header .ds-table__head .ds-table__header__cell { - position: sticky; - top: 0; - z-index: 1; -} + & > thead > tr > :is(th, td) { + background: var(--dsc-table-header-background); + } -.ds-table__header__cell--sortable { - padding: 0; -} + & > thead > tr:last-child > :is(th, td) { + border-bottom: var(--dsc-table-header-divider); + } -.ds-table__header__cell--sortable button { - position: relative; - width: 100%; - border: none; - font-family: inherit; - display: flex; - cursor: pointer; - gap: var(--ds-spacing-1); - align-items: center; - padding: var(--dsc-table-padding); - background-color: transparent; - color: var(--dsc-table-color); - z-index: 2; -} + /* Add rounded border to first and last row */ + & > :is(thead, tbody):first-child > tr:first-child > :is(th, td) { + &:first-child { + border-top-left-radius: var(--dsc-table-border-radius); + } -.ds-table__header__cell--sorted button { - background-color: var(--dsc-table-header-sorted-background); -} + &:last-child { + border-top-right-radius: var(--dsc-table-border-radius); + } + } -.ds-table__header__cell--sortable button:focus-visible { - z-index: 3; - outline-offset: -3px; - box-shadow: unset; -} + /* Add rounded border to last row */ + & > :is(thead, tbody):last-child > tr:last-child > :is(th, td) { + &:first-child { + border-bottom-left-radius: var(--dsc-table-border-radius); + } -.ds-table__header__cell--sortable button svg { - font-size: 1.2em; -} + &:last-child { + border-bottom-right-radius: var(--dsc-table-border-radius); + } + } -.ds-table__cell { - padding: var(--dsc-table-padding); - border-bottom: 1px solid var(--dsc-table-border-color); - background-color: var(--dsc-table-cell-backround); -} + /** + * Sorting + */ + & > thead > tr > [aria-sort] { + cursor: pointer; + padding: 0; + + & > button { + background: none; + border: 0; + box-sizing: border-box; + color: inherit; + cursor: pointer; + display: block; + font: inherit; + padding: var(--dsc-table-padding); + text-align: inherit; + width: 100%; + + &:focus-visible { + position: relative; /* Place on top when painting focus border */ + } + + &::after { + background: currentcolor; + content: ''; + display: inline-block; + height: var(--dsc-table-sort-size); + mask: center/contain no-repeat + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12.53 4.47a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06L12 6.06l2.97 2.97a.75.75 0 1 0 1.06-1.06zm-3.5 10.5a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 1 0-1.06-1.06L12 17.94z'/%3E%3C/svg%3E"); + vertical-align: middle; + width: var(--dsc-table-sort-size); + } + } + + &[aria-sort='ascending'] > button::after { + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M11.47 7.97a.75.75 0 0 1 1.06 0l5.5 5.5a.75.75 0 1 1-1.06 1.06L12 9.56l-4.97 4.97a.75.75 0 0 1-1.06-1.06z'/%3E%3C/svg%3E"); + } + + &[aria-sort='descending'] > button::after { + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5.97 9.47a.75.75 0 0 1 1.06 0L12 14.44l4.97-4.97a.75.75 0 1 1 1.06 1.06l-5.5 5.5a.75.75 0 0 1-1.06 0l-5.5-5.5a.75.75 0 0 1 0-1.06'/%3E%3C/svg%3E"); + } + + &:not([aria-sort='none']) > button { + background: var(--dsc-table-header-background--sorted); + } + } -.ds-table--zebra .ds-table__row { - border-bottom: 0; -} + /** + * Sizes + */ + &[data-size='sm'] { + --dsc-table-padding: var(--ds-spacing-1) var(--ds-spacing-3); + } -.ds-table--zebra tr:nth-child(even) .ds-table__cell { - background-color: var(--dsc-table-cell-zebra-background); -} + &[data-size='lg'] { + --dsc-table-padding: var(--ds-spacing-3) var(--ds-spacing-3); + } -.ds-table--border { - border-radius: var(--dsc-table-border-radius); - border: 1px solid var(--dsc-table-border-color); -} + /** + * Configurations + */ + &[data-border] { + --dsc-table-border-radius: min(1rem, var(--ds-border-radius-md)); -.ds-table--border .ds-table__head .ds-table__header__cell:first-of-type { - border-top-left-radius: var(--dsc-table-border-radius); - overflow: hidden; -} + border: var(--dsc-table-border); -.ds-table--border .ds-table__head .ds-table__header__cell:last-of-type { - border-top-right-radius: var(--dsc-table-border-radius); - overflow: hidden; -} + & > :is(thead, tbody):last-child > tr:last-child > :is(th, td) { + border-bottom: 0; + } + } -.ds-table--border .ds-table__row:last-of-type .ds-table__cell:first-of-type { - border-bottom-left-radius: var(--dsc-table-border-radius); - overflow: hidden; -} + &[data-sticky-header] { + position: relative; + overflow: auto; -.ds-table--border .ds-table__row:last-of-type .ds-table__cell:last-of-type { - border-bottom-right-radius: var(--dsc-table-border-radius); - overflow: hidden; -} + & > thead > tr > :is(th, td) { + position: sticky; + top: 0; + } + } -@media (hover: hover) and (pointer: fine) { - .ds-table--hover .ds-table__row:hover .ds-table__cell { - background-color: var(--dsc-table-cell-hover-backround); + &[data-zebra] > :is(thead, tbody) > tr:nth-child(even) > :is(th, td) { + background: var(--dsc-table-background--zebra); } - .ds-table__header__cell--sortable button:hover { - background-color: var(--dsc-table-header-sorted-hover); + /** + * States + */ + @media (hover: hover) and (pointer: fine) { + &[data-hover] > tbody > tr:hover > :is(th, td) { + background: var(--dsc-table-background--hover); + } + + & > thead > tr > [aria-sort]:hover { + background: var(--dsc-table-header-background--hover); + } } } diff --git a/packages/react/src/components/Table/Table.stories.tsx b/packages/react/src/components/Table/Table.stories.tsx index 6f2c6fec3c..8ef601e6c4 100644 --- a/packages/react/src/components/Table/Table.stories.tsx +++ b/packages/react/src/components/Table/Table.stories.tsx @@ -119,16 +119,14 @@ export const Sortable: Story = (args) => { handleSort('navn')} > Navn Epost handleSort('telefon')} > Telefon @@ -292,3 +290,31 @@ export const FixedTable: Story = (args) => {
); }; + +export const MultipleHeaderRows: Story = (args) => { + const rows = Array.from({ length: 50 }, (_, i) => i + 1); + return ( + + + + Header 1 + Header 2 + + + Header 3 + Header 4 + Header 5 + + + + {rows.map((row) => ( + + {`Cell ${row}1`} + {`Cell ${row}2`} + {`Cell ${row}3`} + + ))} + +
+ ); +}; diff --git a/packages/react/src/components/Table/Table.tsx b/packages/react/src/components/Table/Table.tsx index 0f26bfaaa9..4f8d12eca3 100644 --- a/packages/react/src/components/Table/Table.tsx +++ b/packages/react/src/components/Table/Table.tsx @@ -32,7 +32,7 @@ export type TableProps = { } & Omit, 'border'>; export const Table = React.forwardRef( - ( + function Table( { zebra = false, stickyHeader = false, @@ -44,21 +44,17 @@ export const Table = React.forwardRef( ...rest }, ref, - ) => { + ) { return ( {children} @@ -67,5 +63,3 @@ export const Table = React.forwardRef( ); }, ); - -Table.displayName = 'Table'; diff --git a/packages/react/src/components/Table/TableBody.tsx b/packages/react/src/components/Table/TableBody.tsx index 8340ab5409..6709ea8f31 100644 --- a/packages/react/src/components/Table/TableBody.tsx +++ b/packages/react/src/components/Table/TableBody.tsx @@ -1,16 +1,9 @@ -import * as React from 'react'; +import { type HTMLAttributes, forwardRef } from 'react'; -export type TableBodyProps = React.HTMLAttributes; +export type TableBodyProps = HTMLAttributes; -export const TableBody = React.forwardRef< - HTMLTableSectionElement, - TableBodyProps ->(({ children, ...rest }, ref) => { - return ( - - {children} - - ); -}); - -TableBody.displayName = 'TableBody'; +export const TableBody = forwardRef( + function TableBody(rest, ref) { + return ; + }, +); diff --git a/packages/react/src/components/Table/TableCell.tsx b/packages/react/src/components/Table/TableCell.tsx index 44524ff31b..7bd50f10ef 100644 --- a/packages/react/src/components/Table/TableCell.tsx +++ b/packages/react/src/components/Table/TableCell.tsx @@ -1,16 +1,9 @@ -import cl from 'clsx/lite'; -import * as React from 'react'; +import { type TdHTMLAttributes, forwardRef } from 'react'; -export type TableCellProps = React.TdHTMLAttributes; +export type TableCellProps = TdHTMLAttributes; -export const TableCell = React.forwardRef( - ({ className, children, ...rest }, ref) => { - return ( - - ); +export const TableCell = forwardRef( + function TableCell(rest, ref) { + return - {children} - - ); -}); - -TableHead.displayName = 'TableHead'; +export const TableHead = forwardRef( + function TableHead(rest, ref) { + return ; + }, +); diff --git a/packages/react/src/components/Table/TableHeaderCell.test.tsx b/packages/react/src/components/Table/TableHeaderCell.test.tsx index ce6cc8dc15..1848717a8d 100644 --- a/packages/react/src/components/Table/TableHeaderCell.test.tsx +++ b/packages/react/src/components/Table/TableHeaderCell.test.tsx @@ -18,18 +18,13 @@ describe('table header cell', (): void => { expect(screen.getByRole('columnheader')).toBeInTheDocument(); }); - it('should render table header cell with sort icon', (): void => { - render({ sortable: true }); - expect(screen.getByRole('img')).toBeInTheDocument(); - }); - it('should render table header cell with sort button', (): void => { - render({ sortable: true }); + render({ sort: 'none' }); expect(screen.getByRole('button')).toBeInTheDocument(); }); it('should render table header cell with sort button with aria-sort', (): void => { - render({ sortable: true, sort: 'ascending' }); + render({ sort: 'ascending' }); expect(screen.getByRole('columnheader')).toHaveAttribute( 'aria-sort', 'ascending', diff --git a/packages/react/src/components/Table/TableHeaderCell.tsx b/packages/react/src/components/Table/TableHeaderCell.tsx index 544291daaa..62267f3003 100644 --- a/packages/react/src/components/Table/TableHeaderCell.tsx +++ b/packages/react/src/components/Table/TableHeaderCell.tsx @@ -1,73 +1,21 @@ -import { - ChevronDownIcon, - ChevronUpDownIcon, - ChevronUpIcon, -} from '@navikt/aksel-icons'; -import cl from 'clsx/lite'; import type { AriaAttributes } from 'react'; -import * as React from 'react'; - -const SORT_ICON = { - ascending: , - descending: , -}; +import { type MouseEvent, type ThHTMLAttributes, forwardRef } from 'react'; export type TableHeaderCellProps = { /** - * If true, will add a button to the header cell - * @default false - */ - sortable?: boolean; - /** - * If true, will change aria-sort and icon + * If 'none' | 'ascending' | 'descending' | 'other' will add a button to the header cell and change aria-sort and icon * @default undefined */ sort?: AriaAttributes['aria-sort']; - /** - * Callback for when the sort button is clicked - * @default undefined - */ - onSortClick?: (e: React.MouseEvent) => void; -} & React.ThHTMLAttributes; +} & ThHTMLAttributes; -export const TableHeaderCell = React.forwardRef< +export const TableHeaderCell = forwardRef< HTMLTableCellElement, TableHeaderCellProps ->( - ( - { sortable = false, sort, onSortClick, className, children, ...rest }, - ref, - ) => { - const sortIcon = - sort === 'ascending' || sort === 'descending' ? ( - SORT_ICON[sort] - ) : ( - - ); - - return ( - - ); - }, -); - -TableHeaderCell.displayName = 'TableHeaderCell'; +>(function TableHeaderCell({ sort, children, ...rest }, ref) { + return ( + + ); +}); diff --git a/packages/react/src/components/Table/TableRow.tsx b/packages/react/src/components/Table/TableRow.tsx index a1df3d90ef..99910e8089 100644 --- a/packages/react/src/components/Table/TableRow.tsx +++ b/packages/react/src/components/Table/TableRow.tsx @@ -1,16 +1,9 @@ -import cl from 'clsx/lite'; -import * as React from 'react'; +import { type HTMLAttributes, forwardRef } from 'react'; -export type TableRowProps = React.HTMLAttributes; +export type TableRowProps = HTMLAttributes; -export const TableRow = React.forwardRef( - ({ className, children, ...rest }, ref) => { - return ( - - {children} - - ); +export const TableRow = forwardRef( + function TableRow(rest, ref) { + return ; }, ); - -TableRow.displayName = 'TableRow'; diff --git a/packages/react/stories/showcase.stories.tsx b/packages/react/stories/showcase.stories.tsx index c477c899a5..8e656f812f 100644 --- a/packages/react/stories/showcase.stories.tsx +++ b/packages/react/stories/showcase.stories.tsx @@ -112,11 +112,11 @@ export const Showcase: StoryFn = () => { > - + Navn Epost - + Telefon
- {children} - ; }, ); - -TableCell.displayName = 'TableCell'; diff --git a/packages/react/src/components/Table/TableHead.tsx b/packages/react/src/components/Table/TableHead.tsx index ed1e24d16b..ed7368a3d3 100644 --- a/packages/react/src/components/Table/TableHead.tsx +++ b/packages/react/src/components/Table/TableHead.tsx @@ -1,17 +1,9 @@ -import cl from 'clsx/lite'; -import * as React from 'react'; +import { type HTMLAttributes, forwardRef } from 'react'; -export type TableHeadProps = React.HTMLAttributes; +export type TableHeadProps = HTMLAttributes; -export const TableHead = React.forwardRef< - HTMLTableSectionElement, - TableHeadProps ->(({ className, children, ...rest }, ref) => { - return ( -
- {sortable && ( - - )} - {!sortable && children} - + {sort ? : children} +