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(' '),
}
}