diff --git a/.changeset/flat-owls-grin.md b/.changeset/flat-owls-grin.md new file mode 100644 index 00000000000..3bfa3bced66 --- /dev/null +++ b/.changeset/flat-owls-grin.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +Implements column width features for the DataTable diff --git a/src/DataTable/DataTable.features.stories.tsx b/src/DataTable/DataTable.features.stories.tsx index 75e95c254da..b7d8f615d3d 100644 --- a/src/DataTable/DataTable.features.stories.tsx +++ b/src/DataTable/DataTable.features.stories.tsx @@ -930,6 +930,72 @@ export const WithRowActionMenu = () => ( ) +export const MixedColumnWidths = () => ( + + + Repositories + + { + return + }, + width: 'shrink', + minWidth: '100px', + }, + { + header: 'auto', + field: 'updatedAt', + renderCell: row => { + return + }, + width: 'auto', + }, + { + header: '200px', + field: 'securityFeatures.dependabot', + renderCell: row => { + return row.securityFeatures.dependabot.length > 0 ? ( + + {row.securityFeatures.dependabot.map(feature => { + return + })} + + ) : null + }, + width: '200px', + }, + { + header: 'undefined (defaults to grow)', + field: 'securityFeatures.codeScanning', + renderCell: row => { + return row.securityFeatures.codeScanning.length > 0 ? ( + + {row.securityFeatures.codeScanning.map(feature => { + return + })} + + ) : null + }, + }, + ]} + /> + +) + export const WithCustomHeading = () => ( <> diff --git a/src/DataTable/DataTable.stories.tsx b/src/DataTable/DataTable.stories.tsx index 1b50a6b6b67..a7b1d29fec1 100644 --- a/src/DataTable/DataTable.stories.tsx +++ b/src/DataTable/DataTable.stories.tsx @@ -1,13 +1,16 @@ -import {Meta, ComponentStory} from '@storybook/react' +import {Meta} from '@storybook/react' import React from 'react' -import {DataTable, Table} from '../DataTable' +import {DataTable, DataTableProps, Table} from '../DataTable' import Label from '../Label' import LabelGroup from '../LabelGroup' import RelativeTime from '../RelativeTime' +import {UniqueRow} from './row' +import {getColumnWidthArgTypes, ColWidthArgTypes} from './storyHelpers' export default { title: 'Components/DataTable', component: DataTable, + argTypes: getColumnWidthArgTypes(5), } as Meta const now = Date.now() @@ -179,7 +182,14 @@ export const Default = () => ( ) -export const Playground: ComponentStory = args => { +export const Playground = (args: DataTableProps & ColWidthArgTypes) => { + const getColWidth = (colIndex: number) => { + return args[`colWidth${colIndex}`] !== 'explicit width' + ? args[`colWidth${colIndex}`] + : args[`explicitColWidth${colIndex}`] + ? args[`explicitColWidth${colIndex}`] + : 'grow' + } return ( @@ -198,6 +208,9 @@ export const Playground: ComponentStory = args => { header: 'Repository', field: 'name', rowHeader: true, + width: getColWidth(0), + minWidth: args.minColWidth0, + maxWidth: args.maxColWidth0, }, { header: 'Type', @@ -205,6 +218,9 @@ export const Playground: ComponentStory = args => { renderCell: row => { return }, + width: getColWidth(1), + minWidth: args.minColWidth1, + maxWidth: args.maxColWidth1, }, { header: 'Updated', @@ -212,6 +228,9 @@ export const Playground: ComponentStory = args => { renderCell: row => { return }, + width: getColWidth(2), + minWidth: args.minColWidth2, + maxWidth: args.maxColWidth2, }, { header: 'Dependabot', @@ -225,6 +244,9 @@ export const Playground: ComponentStory = args => { ) : null }, + width: getColWidth(3), + minWidth: args.minColWidth3, + maxWidth: args.maxColWidth3, }, { header: 'Code scanning', @@ -238,6 +260,9 @@ export const Playground: ComponentStory = args => { ) : null }, + width: getColWidth(4), + minWidth: args.minColWidth4, + maxWidth: args.maxColWidth4, }, ]} /> diff --git a/src/DataTable/DataTable.tsx b/src/DataTable/DataTable.tsx index 7b6ccb20de3..9fc9f7c419e 100644 --- a/src/DataTable/DataTable.tsx +++ b/src/DataTable/DataTable.tsx @@ -61,14 +61,19 @@ function DataTable({ initialSortColumn, initialSortDirection, }: DataTableProps) { - const {headers, rows, actions} = useTable({ + const {headers, rows, actions, gridTemplateColumns} = useTable({ data, columns, initialSortColumn, initialSortDirection, }) return ( - +
{headers.map(header => { diff --git a/src/DataTable/Table.tsx b/src/DataTable/Table.tsx index 9b9765438ca..7f197d0983f 100644 --- a/src/DataTable/Table.tsx +++ b/src/DataTable/Table.tsx @@ -19,7 +19,9 @@ const StyledTable = styled.table>` background-color: ${get('colors.canvas.default')}; border-spacing: 0; border-collapse: separate; + display: grid; font-size: var(--table-font-size); + grid-template-columns: var(--grid-template-columns); line-height: calc(20 / var(--table-font-size)); width: 100%; overflow-x: auto; @@ -138,6 +140,22 @@ const StyledTable = styled.table>` font-weight: 600; text-align: start; } + + /* Grid layout */ + .TableHead, + .TableBody, + .TableRow { + display: contents; + } + + @supports (grid-template-columns: subgrid) { + .TableHead, + .TableBody, + .TableRow { + display: grid; + grid-template-columns: subgrid; + grid-column: -1 /1; + } ` export type TableProps = React.ComponentPropsWithoutRef<'table'> & { @@ -151,6 +169,11 @@ export type TableProps = React.ComponentPropsWithoutRef<'table'> & { */ 'aria-labelledby'?: string + /** + * Column width definitions + */ + gridTemplateColumns?: React.CSSProperties['gridTemplateColumns'] + /** * Specify the amount of space that should be available around the contents of * a cell @@ -158,8 +181,20 @@ export type TableProps = React.ComponentPropsWithoutRef<'table'> & { cellPadding?: 'condensed' | 'normal' | 'spacious' } -const Table = React.forwardRef(function Table({cellPadding = 'normal', ...rest}, ref) { - return +const Table = React.forwardRef(function Table( + {cellPadding = 'normal', gridTemplateColumns, ...rest}, + ref, +) { + return ( + + ) }) // ---------------------------------------------------------------------------- @@ -169,7 +204,14 @@ const Table = React.forwardRef(function Table({cel export type TableHeadProps = React.ComponentPropsWithoutRef<'thead'> function TableHead({children}: TableHeadProps) { - return {children} + return ( + // We need to explicitly pass this role because some ATs and browsers drop table semantics + // when we use `display: contents` or `display: grid` in the table + // eslint-disable-next-line jsx-a11y/no-redundant-roles + + {children} + + ) } // ---------------------------------------------------------------------------- @@ -179,7 +221,14 @@ function TableHead({children}: TableHeadProps) { export type TableBodyProps = React.ComponentPropsWithoutRef<'tbody'> function TableBody({children}: TableBodyProps) { - return {children} + return ( + // We need to explicitly pass this role because some ATs and browsers drop table semantics + // when we use `display: contents` or `display: grid` in the table + // eslint-disable-next-line jsx-a11y/no-redundant-roles + + {children} + + ) } // ---------------------------------------------------------------------------- diff --git a/src/DataTable/__tests__/DataTable.test.tsx b/src/DataTable/__tests__/DataTable.test.tsx index 865f28f53e3..b977d3876c0 100644 --- a/src/DataTable/__tests__/DataTable.test.tsx +++ b/src/DataTable/__tests__/DataTable.test.tsx @@ -2,7 +2,8 @@ import userEvent from '@testing-library/user-event' import {render, screen, getByRole, queryByRole, queryAllByRole} from '@testing-library/react' import React from 'react' import {DataTable, Table} from '../../DataTable' -import {createColumnHelper} from '../column' +import {Column, createColumnHelper} from '../column' +import {getGridTemplateFromColumns} from '../useTable' describe('DataTable', () => { it('should render a semantic
through `data` and `columns`', () => { @@ -809,4 +810,174 @@ describe('DataTable', () => { expect(getRowOrder()).toEqual(['3', '2', '1']) }) }) + + describe('column widths', () => { + it('correctly sets the column width to "grow" when width is undefined', () => { + const columnHelper = createColumnHelper<{id: number; name: string}>() + const columns = [ + columnHelper.column({ + header: 'Name', + field: 'name', + }), + ] + + expect(getGridTemplateFromColumns(columns)).toEqual(['minmax(max-content, 1fr)']) + }) + it('correctly sets the column width when width === "grow"', () => { + const columnHelper = createColumnHelper<{id: number; name: string}>() + const columns = [ + columnHelper.column({ + header: 'Name', + field: 'name', + width: 'grow', + }), + ] + + expect(getGridTemplateFromColumns(columns)).toEqual(['minmax(max-content, 1fr)']) + }) + it('correctly sets the column width when width === "shrink"', () => { + const columnHelper = createColumnHelper<{id: number; name: string}>() + const columns = [ + columnHelper.column({ + header: 'Name', + field: 'name', + width: 'shrink', + }), + ] + + expect(getGridTemplateFromColumns(columns)).toEqual(['minmax(0, 1fr)']) + }) + it('correctly sets the column width when width === "auto"', () => { + const columnHelper = createColumnHelper<{id: number; name: string}>() + const columns = [ + columnHelper.column({ + header: 'Name', + field: 'name', + width: 'auto', + }), + ] + + expect(getGridTemplateFromColumns(columns)).toEqual(['auto']) + }) + it('correctly sets the column width when width is a CSS width string', () => { + const columnHelper = createColumnHelper<{id: number; name: string}>() + const columns = [ + columnHelper.column({ + header: 'Name', + field: 'name', + width: '42ch', + }), + ] + + expect(getGridTemplateFromColumns(columns)).toEqual(['42ch']) + }) + it('correctly sets the column width when width is a number', () => { + const columnHelper = createColumnHelper<{id: number; name: string}>() + const columns = [ + columnHelper.column({ + header: 'Name', + field: 'name', + width: 200, + }), + ] + + expect(getGridTemplateFromColumns(columns)).toEqual(['200px']) + }) + it('correctly sets min-widths for the column', () => { + const columnHelper = createColumnHelper<{id: number; name: string}>() + const columns: Record[]> = { + grow: [ + columnHelper.column({ + header: 'Name', + field: 'name', + width: 'grow', + minWidth: '42ch', + }), + ], + shrink: [ + columnHelper.column({ + header: 'Name', + field: 'name', + width: 'shrink', + minWidth: '42ch', + }), + ], + auto: [ + columnHelper.column({ + header: 'Name', + field: 'name', + width: 'auto', + minWidth: '42ch', + }), + ], + } + const expectedWidths: Record = { + grow: 'minmax(42ch, 1fr)', + shrink: 'minmax(42ch, 1fr)', + auto: 'minmax(42ch, auto)', + } + + for (const widthOpt in columns) { + expect(getGridTemplateFromColumns(columns[widthOpt])).toEqual([expectedWidths[widthOpt]]) + } + }) + it('correctly sets max-widths for the column', () => { + const columnHelper = createColumnHelper<{id: number; name: string}>() + const columns: Record[]> = { + grow: [ + columnHelper.column({ + header: 'Name', + field: 'name', + width: 'grow', + maxWidth: '42ch', + }), + ], + shrink: [ + columnHelper.column({ + header: 'Name', + field: 'name', + width: 'shrink', + maxWidth: '42ch', + }), + ], + auto: [ + columnHelper.column({ + header: 'Name', + field: 'name', + width: 'auto', + maxWidth: '42ch', + }), + ], + } + const expectedWidths: Record = { + grow: 'minmax(auto, 42ch)', + shrink: 'minmax(0, 42ch)', + auto: 'minmax(auto, 42ch)', + } + + for (const widthOpt in columns) { + expect(getGridTemplateFromColumns(columns[widthOpt])).toEqual([expectedWidths[widthOpt]]) + } + }) + it('sets a custom property style to define the column grid template', () => { + const columnHelper = createColumnHelper<{id: number; name: string}>() + const columns = [ + columnHelper.column({ + header: 'Name', + field: 'name', + }), + ] + const data = [ + { + id: 1, + name: 'one', + }, + ] + render() + + expect(screen.getByRole('table')).toHaveStyle({ + '--grid-template-columns': 'minmax(max-content, 1fr)', + }) + }) + }) }) diff --git a/src/DataTable/column.ts b/src/DataTable/column.ts index 6a01fdeec18..7294177bcc0 100644 --- a/src/DataTable/column.ts +++ b/src/DataTable/column.ts @@ -2,6 +2,7 @@ import {ObjectPaths} from './utils' import {UniqueRow} from './row' import {SortStrategy, CustomSortStrategy} from './sorting' +export type ColumnWidth = 'grow' | 'shrink' | 'auto' | React.CSSProperties['width'] export interface Column { id?: string @@ -21,6 +22,18 @@ export interface Column { */ field?: ObjectPaths + /** + * The minimum width the column can shrink to + */ + // TODO: uncomment ResponsiveValue when I'm ready to implement the responsive part + maxWidth?: React.CSSProperties['maxWidth'] /*| ResponsiveValue*/ + + /** + * The maximum width the column can grow to + */ + // TODO: uncomment ResponsiveValue when I'm ready to implement the responsive part + minWidth?: React.CSSProperties['minWidth'] /*| ResponsiveValue*/ + /** * Provide a custom component or render prop to render the data for this * column in a row @@ -38,6 +51,16 @@ export interface Column { * specific sort strategy or custom sort strategy */ sortBy?: boolean | SortStrategy | CustomSortStrategy + + /** + * Controls the width of the column. + * - 'grow': Stretch to fill available space, and min width is the width of the widest cell in the column + * - 'shrink': Stretch to fill available space or shrink to fit in the available space. Allows the column to shrink smaller than the cell content's width. + * - 'auto': The column is the width of it’s widest cell. Not intended for use with columns who’s content length varies a lot because a layout shift will occur when the content changes + * - explicit width: Will be exactly that width and will not grow or shrink to fill the parent + * @default 'grow' + */ + width?: ColumnWidth } export function createColumnHelper() { diff --git a/src/DataTable/storyHelpers.ts b/src/DataTable/storyHelpers.ts new file mode 100644 index 00000000000..0781b255b0e --- /dev/null +++ b/src/DataTable/storyHelpers.ts @@ -0,0 +1,52 @@ +import {InputType} from '@storybook/csf' + +// Keeping this generic because we can't know how many columns there will be, +// so we can't know all the possible width keys (for example. 'colWidth1') +export type ColWidthArgTypes = Record + +export const getColumnWidthArgTypes = (colCount: number) => { + const argTypes: InputType = {} + for (let i = 0; i <= colCount - 1; i++) { + argTypes[`colWidth${i}`] = { + name: `column ${i + 1} width`, + control: { + type: 'radio', + }, + defaultValue: 'grow', + options: ['grow', 'shrink', 'auto', 'explicit width'], + table: { + category: 'Column widths', + }, + } + argTypes[`explicitColWidth${i}`] = { + name: `column ${i + 1} explicit width`, + control: { + type: 'text', + }, + defaultValue: '200px', + if: {arg: `colWidth${i}`, eq: 'explicit width'}, + table: { + category: 'Column widths', + }, + } + argTypes[`minColWidth${i}`] = { + name: `column ${i + 1} min width`, + control: { + type: 'text', + }, + table: { + category: 'Column widths', + }, + } + argTypes[`maxColWidth${i}`] = { + name: `column ${i + 1} max width`, + control: { + type: 'text', + }, + table: { + category: 'Column widths', + }, + } + } + return argTypes +} diff --git a/src/DataTable/useTable.ts b/src/DataTable/useTable.ts index 43c71fc2c17..149b2c3eaa2 100644 --- a/src/DataTable/useTable.ts +++ b/src/DataTable/useTable.ts @@ -17,6 +17,7 @@ interface Table { actions: { sortBy: (header: Header) => void } + gridTemplateColumns: React.CSSProperties['gridTemplateColumns'] } interface Header { @@ -41,6 +42,48 @@ interface Cell { type ColumnSortState = {id: string | number; direction: Exclude} | null +export function getGridTemplateFromColumns(columns: Array>): string[] { + return columns.map(column => { + const columnWidth = column.width ?? 'grow' + let minWidth = 'auto' + let maxWidth = '1fr' + + if (columnWidth === 'auto') { + maxWidth = 'auto' + } + + // Setting a min-width of 'max-content' ensures that the column will grow to fit the widest cell's content. + // However, If the column has a max width, we can't set the min width to `max-content` because + // the widest cell's content might overflow the container. + if (columnWidth === 'grow' && !column.maxWidth) { + minWidth = 'max-content' + } + + // Column widths set to "shrink" don't need a min width unless one is explicitly provided. + if (columnWidth === 'shrink') { + minWidth = '0' + } + + // If a consumer passes `minWidth` or `maxWidth`, we need to override whatever we set above. + if (column.minWidth) { + minWidth = typeof column.minWidth === 'number' ? `${column.minWidth}px` : column.minWidth + } + + if (column.maxWidth) { + maxWidth = typeof column.maxWidth === 'number' ? `${column.maxWidth}px` : column.maxWidth + } + + // If a consumer is passing one of the shorthand widths or doesn't pass a width at all, we use the + // min and max width calculated above to create a minmax() column template value. + if (typeof columnWidth !== 'number' && ['grow', 'shrink', 'auto'].includes(columnWidth)) { + return minWidth === maxWidth ? minWidth : `minmax(${minWidth}, ${maxWidth})` + } + + // If we reach this point, the consumer is passing an explicit width value. + return typeof columnWidth === 'number' ? `${columnWidth}px` : columnWidth + }) +} + export function useTable({ columns, data, @@ -192,6 +235,7 @@ export function useTable({ actions: { sortBy, }, + gridTemplateColumns: getGridTemplateFromColumns(columns).join(' '), } }