From c109f52e3f0f3c1aaa16a1cfd698a649aa962290 Mon Sep 17 00:00:00 2001 From: Andrew Holloway Date: Fri, 18 Oct 2024 18:42:25 -0500 Subject: [PATCH] feat(DataTable): add support for table row statuses (#2073) - component to support standard EDS statuses per row - additional exports to support typing of the data model - additional exports to support column header for status column - update tests and snapshots (new story for comparison) --- src/components/DataTable/DataTable.module.css | 45 + .../DataTable/DataTable.stories.tsx | 96 +- src/components/DataTable/DataTable.tsx | 350 ++++-- .../__snapshots__/DataTable.test.ts.snap | 1120 ++++++++++++++++- src/components/DataTable/index.ts | 6 +- src/index.ts | 5 + 6 files changed, 1454 insertions(+), 168 deletions(-) diff --git a/src/components/DataTable/DataTable.module.css b/src/components/DataTable/DataTable.module.css index 3efbab7bb..42bf017a8 100644 --- a/src/components/DataTable/DataTable.module.css +++ b/src/components/DataTable/DataTable.module.css @@ -96,6 +96,7 @@ align-items: flex-start; font: var(--eds-theme-typography-title-md); height: 100%; + overflow: hidden; border-right: calc(var(--eds-border-width-sm) * 1px) solid transparent; @@ -127,12 +128,28 @@ } } +.data-table__status-cell { + height: 100%; + + display: flex; + align-items: center; + justify-content: center; +} + +.data-table__status-header-cell { + height: 1px; + width: 1px; + overflow: hidden; + text-indent: 9999; +} + .data-table__cell { display: flex; gap: calc(var(--eds-size-1) / 16 * 1rem); align-items: flex-start; font: var(--eds-theme-typography-body-md); height: 100%; + overflow: hidden; border-right: calc(var(--eds-border-width-sm) * 1px) solid transparent; @@ -288,3 +305,31 @@ background-color: var(--eds-theme-color-background-utility-interactive-low-emphasis); } } + +.data-table .data-table__status-cell { + &.data-table--status-critical { + color: var(--eds-theme-color-icon-utility-critical) + } + + &.data-table--status-favorable { + color: var(--eds-theme-color-icon-utility-favorable); + } + + &.data-table--status-warning { + color: var(--eds-theme-color-icon-utility-warning); + } +} + +.data-table .data-table__row { + &.data-table--status-critical { + background-color: var(--eds-theme-color-background-utility-critical-low-emphasis); + } + + &.data-table--status-favorable { + background-color: var(--eds-theme-color-background-utility-favorable-low-emphasis); + } + + &.data-table--status-warning { + background-color: var(--eds-theme-color-background-utility-warning-low-emphasis); + } +} \ No newline at end of file diff --git a/src/components/DataTable/DataTable.stories.tsx b/src/components/DataTable/DataTable.stories.tsx index 314a2cbe5..4c6d40767 100644 --- a/src/components/DataTable/DataTable.stories.tsx +++ b/src/components/DataTable/DataTable.stories.tsx @@ -11,6 +11,7 @@ import { DataTableRow, DataTableHeader, type DataTableProps, + type DataTableWithStatus, } from './DataTable'; // We import all of the utilities from tanstack here, and this can contain other custom utilities @@ -53,13 +54,14 @@ export default { type Args = DataTableProps; // Specifying an example data type for the rows of a table -type Person = { +// This column is used for tables that are eligible for status +type Person = DataTableWithStatus<{ firstName: string; lastName: string; age: number; visits: number; progress: number; -}; +}>; // Specifying the example (static) data for the table to use with tanstack primitives const defaultData: Person[] = [ @@ -76,6 +78,7 @@ const defaultData: Person[] = [ age: 40, visits: 40, progress: 80, + status: 'warning', }, { firstName: 'Tanner', @@ -90,6 +93,7 @@ const defaultData: Person[] = [ age: 45, visits: 20, progress: 10, + status: 'critical', }, { firstName: 'Tandy', @@ -111,6 +115,7 @@ const defaultData: Person[] = [ age: 45, visits: 20, progress: 10, + status: 'favorable', }, { firstName: 'Tandy', @@ -700,6 +705,93 @@ export const Grouping: StoryObj = { }, }; +/** + * You can specify detailed statuses for each row in a table, matching a few common options. + * Extend the data type to include `status` which maps to the internal type + * + * Use `DataTableWithStatus` on your data type model to add in the column handler. this + * will add add a `status` item, to be used with a column with header name `DataTable.__StatusColumnId__`. + */ +export const StatusRows: StoryObj = { + args: { + caption: 'Test table', + subcaption: 'Additional Subcaption', + isStatusEligible: true, + tableStyle: 'border', + rowStyle: 'lined', + size: 'sm', + }, + render: (args) => { + const columns = [ + columnHelper.accessor(DataTable.__StatusColumnId__, { + header: () => , + cell: (info) => , + size: 32, + }), + columnHelper.accessor('firstName', { + header: () => ( + + First Name + + ), + cell: (info) => ( + {info.getValue()} + ), + }), + columnHelper.accessor((row) => row.lastName, { + id: 'lastName', + header: () => Last Name, + cell: (info) => ( + {info.getValue()} + ), + }), + columnHelper.accessor('age', { + header: () => ( + Age + ), + cell: (info) => ( + + {info.renderValue()} + + ), + }), + columnHelper.accessor('visits', { + header: () => ( + + Visits + + ), + cell: (info) => ( + + {info.renderValue()} + + ), + }), + columnHelper.accessor('progress', { + header: () => ( + + Profile Progress + + ), + cell: (info) => ( + + {info.renderValue()} + + ), + }), + ]; + + // eslint-disable-next-line react-hooks/rules-of-hooks + const table = DataTableUtils.useReactTable({ + data: defaultData, + columns, + getCoreRowModel: DataTableUtils.getCoreRowModel(), + }); + + return ; + }, +}; + // TODO: Story for sticky column pinning (https://tanstack.com/table/latest/docs/framework/react/examples/column-pinning-sticky) export const DefaultWithCustomTable: StoryObj = { diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index 77a8e5ad5..56eb216d2 100644 --- a/src/components/DataTable/DataTable.tsx +++ b/src/components/DataTable/DataTable.tsx @@ -1,7 +1,8 @@ import { flexRender, type Table } from '@tanstack/react-table'; import clsx from 'clsx'; -import React, { useEffect } from 'react'; +import React, { useEffect, createContext, useContext } from 'react'; +import getIconNameFromStatus from '../../util/getIconNameFromStatus'; import type { EDSBase, Size, Status, Align } from '../../util/variant-types'; import Button, { type ButtonProps } from '../Button'; @@ -46,7 +47,7 @@ export type DataTableProps = EDSBase & { */ caption?: string; /** - * Controls whether the rows allow for a status color/icon treatment. + * Controls whether the table allows rows for a status color/icon treatment. */ isStatusEligible?: boolean; /** @@ -77,12 +78,12 @@ export type DataTableProps = EDSBase & { export type DataTableTableProps = EDSBase & Pick; -// TODO: Implement as followup -export type DataTableRowProps = Pick & { - isInteractive?: boolean; - isSelected?: boolean; - status?: Extract; -}; +export type DataTableRowProps = Pick & + StatusColumn & { + 'aria-label'?: string; + isInteractive?: boolean; + isSelected?: boolean; + }; export type DataTableHeaderCellProps = EDSBase & { // Component API @@ -95,6 +96,9 @@ export type DataTableHeaderCellProps = EDSBase & { * Determines the edge alignment of content within the cell */ alignment?: Extract; + /** + * Whether the cell has a divider between adjacent cells + */ hasHorizontalDivider?: boolean; /** * Marks the header cell as sortable (used in conjunction with `sortDirection`) @@ -120,6 +124,11 @@ export type DataTableHeaderCellProps = EDSBase & { sortDirection?: SortDirectionsType; }; +// Used to augment the data model shown in the table with a provided status column type +type StatusColumn = { + status?: Extract; +}; + const SORT_DIRECTIONS = ['ascending', 'descending', 'default'] as const; export type SortDirectionsType = (typeof SORT_DIRECTIONS)[number]; @@ -129,6 +138,16 @@ export type DataTableDataCellProps = DataTableHeaderCellProps & { children: React.ReactNode; }; +export type DataTableStatusCellProps = StatusColumn & { + 'aria-label'?: string; +}; + +export type DataTableWithStatus = S & StatusColumn; + +const DataTableContext = createContext>({ + size: 'md', +}); + /** * `import {DataTable} from "@chanzuckerberg/eds";` * @@ -142,6 +161,7 @@ export function DataTable({ className, caption, isInteractive = false, + isStatusEligible, onSearchChange, rowStyle = 'striped', size = 'md', @@ -149,7 +169,7 @@ export function DataTable({ table, tableClassName, tableStyle = 'basic', - ...other + ...rest }: DataTableProps) { const componentClassName = clsx(styles['data-table'], className); @@ -159,129 +179,140 @@ export function DataTable({ * header, search field, and actions, and preserve accessibility. */ return ( -
- {(caption || subcaption || onSearchChange || actions) && ( -
- {(caption || subcaption) && ( -
- {caption && ( - - )} - {subcaption && ( - // TODO: Warn when only using subcaption - - )} -
- )} - {onSearchChange && ( -
- -
- )} - {actions && ( -
- {actions} -
- )} -
- )} - {/* Provide an escape hatch for specifying all aspects of a table using `children` and Sub-components directly */} - {children ?? ( - - {(caption || subcaption) && ( - - {`${caption}${subcaption ? ': ' + subcaption : ''}`} - - )} - - {table?.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - +
+ {(caption || subcaption || onSearchChange || actions) && ( +
+ {(caption || subcaption) && ( +
+ {caption && ( + + {caption} +
+ )} + {subcaption && ( + // TODO: Warn when only using subcaption + + )} +
+ )} + {onSearchChange && ( +
+ +
+ )} + {actions && ( +
+ {actions} +
+ )} +
+ )} + {/* Provide an escape hatch for specifying all aspects of a table using `children` and Sub-components directly */} + {children ?? ( + + {(caption || subcaption) && ( + + {`${caption}${subcaption ? ': ' + subcaption : ''}`} + + )} + + {table?.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const columnWidth = + header.getSize() !== 150 + ? `${header.getSize()}px` + : undefined; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table?.getRowModel().rows.map((row) => { + return ( + + {row.getCanExpand() ? ( + <> + + {row.getLeafRows().map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ))} + + ) : ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )} + + ); + })} + + + )} +
+ ); } @@ -403,6 +434,43 @@ export const DataTableDataCell = ({ ); }; +export const DataTableStatusHeaderCell = ({ + 'aria-label': ariaLabel, + status, + ...rest +}: DataTableStatusCellProps) => { + return ( +
+ Status Column +
+ ); +}; + +export const DataTableStatusCell = ({ + 'aria-label': ariaLabel, + status, + ...rest +}: DataTableStatusCellProps) => { + const statusCellClassName = clsx( + styles['data-table__status-cell'], + status && styles[`data-table--status-${status}`], + ); + + const { size } = useContext(DataTableContext); + + return ( +
+ {status && ( + + )} +
+ ); +}; + export const DataTableTable = ({ children, tableClassName, @@ -471,10 +539,12 @@ export const DataTableHeader = ({ }; export const DataTableRow = ({ + 'aria-label': ariaLabel, children, className, isInteractive, isSelected, + status, ...rest }: DataTableRowProps) => { const componnentClassName = clsx( @@ -482,9 +552,20 @@ export const DataTableRow = ({ styles['data-table__row'], isInteractive && styles['data-table__row--is-interactive'], isSelected && styles['data-table__row--is-selected'], + status && styles[`data-table--status-${status}`], ); + + const rowA11yDesc = + ariaLabel || + (status && + { + favorable: 'This table row has a favorable status', + critical: 'This table row has a critical status', + warning: 'This table row has a warning status', + }[status]); + return ( - + {children} ); @@ -535,3 +616,8 @@ DataTable.Row = DataTableRow; DataTable.GroupRow = DataTableGroupRow; DataTable.HeaderCell = DataTableHeaderCell; DataTable.DataCell = DataTableDataCell; + +// Special Cell Sub-types and data +DataTable.StatusCell = DataTableStatusCell; +DataTable.StatusHeaderCell = DataTableStatusHeaderCell; +DataTable.__StatusColumnId__ = 'status' as const; diff --git a/src/components/DataTable/__snapshots__/DataTable.test.ts.snap b/src/components/DataTable/__snapshots__/DataTable.test.ts.snap index 0501270b9..a43e5152b 100644 --- a/src/components/DataTable/__snapshots__/DataTable.test.ts.snap +++ b/src/components/DataTable/__snapshots__/DataTable.test.ts.snap @@ -41,7 +41,6 @@ exports[` Default story renders snapshot 1`] = `
Default story renders snapshot 1`] = `
Default story renders snapshot 1`] = `
Default story renders snapshot 1`] = `
Default story renders snapshot 1`] = `
Grouping story renders snapshot 1`] = `
Grouping story renders snapshot 1`] = `
Grouping story renders snapshot 1`] = `
RowStyleLined story renders snapshot 1`] = `
RowStyleLined story renders snapshot 1`] = `
RowStyleLined story renders snapshot 1`] = `
RowStyleLined story renders snapshot 1`] = `
RowStyleLined story renders snapshot 1`] = `
Selectable story renders snapshot 1`] = `
Selectable story renders snapshot 1`] = `
Selectable story renders snapshot 1`] = `
Selectable story renders snapshot 1`] = `
Selectable story renders snapshot 1`] = `
Selectable story renders snapshot 1`] = `
`; +exports[` StatusRows story renders snapshot 1`] = ` +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Test table: Additional Subcaption +
+
+ Status Column +
+
+
+
+ First Name +
+
+
+
+
+ Last Name +
+
+
+
+
+ Age +
+
+
+
+
+ Visits +
+
+
+
+
+ Profile Progress +
+
+
+
+
+
+
+ Joe +
+
+
+
+
+ Dirte +
+
+
+
+
+ 45 +
+
+
+
+
+ 20 +
+
+
+
+
+ 10 +
+
+
+
+ +
+
+
+
+ Tandy +
+
+
+
+
+ Miller +
+
+
+
+
+ 40 +
+
+
+
+
+ 40 +
+
+
+
+
+ 80 +
+
+
+
+
+
+
+ Tanner +
+
+
+
+
+ Lindsey +
+
+
+
+
+ 24 +
+
+
+
+
+ 100 +
+
+
+
+
+ 50 +
+
+
+
+ +
+
+
+
+ Joe +
+
+
+
+
+ Dirte +
+
+
+
+
+ 45 +
+
+
+
+
+ 20 +
+
+
+
+
+ 10 +
+
+
+
+
+
+
+ Tandy +
+
+
+
+
+ Miller +
+
+
+
+
+ 40 +
+
+
+
+
+ 40 +
+
+
+
+
+ 80 +
+
+
+
+
+
+
+ Tanner +
+
+
+
+
+ Lindsey +
+
+
+
+
+ 24 +
+
+
+
+
+ 100 +
+
+
+
+
+ 50 +
+
+
+
+ +
+
+
+
+ Joe +
+
+
+
+
+ Dirte +
+
+
+
+
+ 45 +
+
+
+
+
+ 20 +
+
+
+
+
+ 10 +
+
+
+
+
+
+
+ Tandy +
+
+
+
+
+ Miller +
+
+
+
+
+ 40 +
+
+
+
+
+ 40 +
+
+
+
+
+ 80 +
+
+
+
+
+
+
+ Tanner +
+
+
+
+
+ Lindsey +
+
+
+
+
+ 24 +
+
+
+
+
+ 100 +
+
+
+
+
+ 50 +
+
+
+
+
+
+
+ Joe +
+
+
+
+
+ Dirte +
+
+
+
+
+ 45 +
+
+
+
+
+ 20 +
+
+
+
+
+ 10 +
+
+
+
+
+
+
+ Tandy +
+
+
+
+
+ Miller +
+
+
+
+
+ 40 +
+
+
+
+
+ 40 +
+
+
+
+
+ 80 +
+
+
+
+
+
+
+ Tanner +
+
+
+
+
+ Lindsey +
+
+
+
+
+ 24 +
+
+
+
+
+ 100 +
+
+
+
+
+ 50 +
+
+
+
+`; + exports[` TableSizeSm story renders snapshot 1`] = `
TableSizeSm story renders snapshot 1`] = `
TableSizeSm story renders snapshot 1`] = `
TableSizeSm story renders snapshot 1`] = `
TableSizeSm story renders snapshot 1`] = `
TableSizeSm story renders snapshot 1`] = `
TableStyleBorder story renders snapshot 1`] = `
TableStyleBorder story renders snapshot 1`] = `
TableStyleBorder story renders snapshot 1`] = `
TableStyleBorder story renders snapshot 1`] = `
TableStyleBorder story renders snapshot 1`] = `
VerticalDivider story renders snapshot 1`] = `
VerticalDivider story renders snapshot 1`] = `
VerticalDivider story renders snapshot 1`] = `
VerticalDivider story renders snapshot 1`] = `
VerticalDivider story renders snapshot 1`] = `