diff --git a/.changeset/spotty-beers-cheer.md b/.changeset/spotty-beers-cheer.md new file mode 100644 index 00000000000..3302cc5a1ce --- /dev/null +++ b/.changeset/spotty-beers-cheer.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Add DataTable, Table to drafts entrypoint diff --git a/src/DataTable/DataTable.features.stories.tsx b/src/DataTable/DataTable.features.stories.tsx index 133f0633451..d2db5cf30f6 100644 --- a/src/DataTable/DataTable.features.stories.tsx +++ b/src/DataTable/DataTable.features.stories.tsx @@ -1,17 +1,6 @@ import {Meta} from '@storybook/react' import React from 'react' -import { - DataTable, - Table, - TableHead, - TableBody, - TableRow, - TableHeader, - TableCell, - TableContainer, - TableTitle, - TableSubtitle, -} from '../DataTable' +import {DataTable, Table} from '../DataTable' import Label from '../Label' import LabelGroup from '../LabelGroup' import RelativeTime from '../RelativeTime' @@ -19,17 +8,6 @@ import RelativeTime from '../RelativeTime' export default { title: 'Drafts/Components/DataTable/Features', component: DataTable, - subcomponents: { - Table, - TableHead, - TableBody, - TableRow, - TableHeader, - TableCell, - TableContainer, - TableTitle, - TableSubtitle, - }, } as Meta const now = Date.now() @@ -139,13 +117,13 @@ function uppercase(input: string): string { } export const Default = () => ( - - + + Repositories - - + + A subtitle could appear here to give extra context to the data. - + ( }, ]} /> - + ) export const WithTitle = () => ( - - + + Repositories - + ( }, ]} /> - + ) export const WithTitleAndSubtitle = () => ( - - + + Repositories - - + + A subtitle could appear here to give extra context to the data. - + ( }, ]} /> - + ) export const WithSorting = () => { @@ -329,13 +307,13 @@ export const WithSorting = () => { return b.updatedAt - a.updatedAt }) return ( - - + + Repositories - - + + A subtitle could appear here to give extra context to the data. - + { initialSortColumn="updatedAt" initialSortDirection="DESC" /> - + ) } diff --git a/src/DataTable/DataTable.stories.tsx b/src/DataTable/DataTable.stories.tsx index 2a2aa4aa0b7..a66e790f57b 100644 --- a/src/DataTable/DataTable.stories.tsx +++ b/src/DataTable/DataTable.stories.tsx @@ -1,6 +1,6 @@ import {Meta, ComponentStory} from '@storybook/react' import React from 'react' -import {DataTable, TableContainer, TableTitle, TableSubtitle} from '../DataTable' +import {DataTable, Table} from '../DataTable' import Label from '../Label' import LabelGroup from '../LabelGroup' import RelativeTime from '../RelativeTime' @@ -118,13 +118,13 @@ function uppercase(input: string): string { export const Playground: ComponentStory = args => { return ( - - + + Repositories - - + + A subtitle could appear here to give extra context to the data. - + = args => { }, ]} /> - + ) } diff --git a/src/DataTable/DataTable.tsx b/src/DataTable/DataTable.tsx new file mode 100644 index 00000000000..0ae159cc9a7 --- /dev/null +++ b/src/DataTable/DataTable.tsx @@ -0,0 +1,113 @@ +import React from 'react' +import {Column} from './column' +import {useTable} from './useTable' +import {SortDirection} from './sorting' +import {UniqueRow} from './row' +import {ObjectPaths} from './utils' +import {Table, TableHead, TableBody, TableRow, TableHeader, TableSortHeader, TableCell} from './Table' + +// ---------------------------------------------------------------------------- +// DataTable +// ---------------------------------------------------------------------------- + +export type DataTableProps = { + /** + * Provide an id to an element which uniquely describes this table + */ + 'aria-describedby'?: string | undefined + + /** + * Provide an id to an element which uniquely labels this table + */ + 'aria-labelledby'?: string | undefined + + /** + * Specify the amount of space that should be available around the contents of + * a cell + */ + cellPadding?: 'condensed' | 'normal' | 'spacious' | undefined + + /** + * Provide a collection of the rows which will be rendered inside of the table + */ + data: Array + + /** + * Provide the columns for the table and the fields in `data` to which they + * correspond + */ + columns: Array> + + /** + * Provide the id or field of the column by which the table is sorted. When + * using this `prop`, the input data must be sorted by this column in + * ascending order + */ + initialSortColumn?: ObjectPaths | string | undefined + + /** + * Provide the sort direction that the table should be sorted by on the + * currently sorted column + */ + initialSortDirection?: Exclude | undefined +} + +function DataTable({ + 'aria-labelledby': labelledby, + 'aria-describedby': describedby, + cellPadding, + columns, + data, + initialSortColumn, + initialSortDirection, +}: DataTableProps) { + const {headers, rows, actions} = useTable({ + data, + columns, + initialSortColumn, + initialSortDirection, + }) + return ( + + + + {headers.map(header => { + if (header.isSortable()) { + return ( + { + actions.sortBy(header) + }} + > + {header.column.header} + + ) + } + return {header.column.header} + })} + + + + {rows.map(row => { + return ( + + {row.getCells().map(cell => { + return ( + + {cell.column.renderCell + ? cell.column.renderCell(row.getValue()) + : (cell.getValue() as React.ReactNode)} + + ) + })} + + ) + })} + +
+ ) +} + +export {DataTable} diff --git a/src/DataTable/index.tsx b/src/DataTable/Table.tsx similarity index 68% rename from src/DataTable/index.tsx rename to src/DataTable/Table.tsx index 09c592ef0ce..6fce8aa0806 100644 --- a/src/DataTable/index.tsx +++ b/src/DataTable/Table.tsx @@ -3,115 +3,8 @@ import React from 'react' import styled from 'styled-components' import Box from '../Box' import {get} from '../constants' -import {Column} from './column' -import {useTable} from './useTable' +import {SxProp} from '../sx' import {SortDirection} from './sorting' -import {UniqueRow} from './row' -import {ObjectPaths} from './utils' - -// ---------------------------------------------------------------------------- -// DataTable -// ---------------------------------------------------------------------------- - -export interface DataTableProps { - /** - * Provide an id to an element which uniquely describes this table - */ - 'aria-describedby'?: string | undefined - - /** - * Provide an id to an element which uniquely labels this table - */ - 'aria-labelledby'?: string | undefined - - /** - * Specify the amount of space that should be available around the contents of - * a cell - */ - cellPadding?: 'condensed' | 'normal' | 'spacious' | undefined - - /** - * Provide a collection of the rows which will be rendered inside of the table - */ - data: Array - - /** - * Provide the columns for the table and the fields in `data` to which they - * correspond - */ - columns: Array> - - /** - * Provide the id or field of the column by which the table is sorted. When - * using this `prop`, the input data must be sorted by this column in - * ascending order - */ - initialSortColumn?: ObjectPaths | string | undefined - - /** - * Provide the sort direction that the table should be sorted by on the - * currently sorted column - */ - initialSortDirection?: Exclude | undefined -} - -function DataTable({ - 'aria-labelledby': labelledby, - 'aria-describedby': describedby, - cellPadding, - columns, - data, - initialSortColumn, - initialSortDirection, -}: DataTableProps) { - const {headers, rows, actions} = useTable({ - data, - columns, - initialSortColumn, - initialSortDirection, - }) - return ( - - - - {headers.map(header => { - if (header.isSortable()) { - return ( - { - actions.sortBy(header) - }} - > - {header.column.header} - - ) - } - return {header.column.header} - })} - - - - {rows.map(row => { - return ( - - {row.getCells().map(cell => { - return ( - - {cell.column.renderCell - ? cell.column.renderCell(row.getValue()) - : (cell.getValue() as React.ReactNode)} - - ) - })} - - ) - })} - -
- ) -} // ---------------------------------------------------------------------------- // Table @@ -253,7 +146,7 @@ const StyledTable = styled.table>` } ` -interface TableProps extends React.ComponentPropsWithoutRef<'table'> { +export type TableProps = React.ComponentPropsWithoutRef<'table'> & { /** * Provide an id to an element which uniquely describes this table */ @@ -264,8 +157,6 @@ interface TableProps extends React.ComponentPropsWithoutRef<'table'> { */ 'aria-labelledby'?: string | undefined - children?: React.ReactNode - /** * Specify the amount of space that should be available around the contents of * a cell @@ -281,9 +172,7 @@ const Table = React.forwardRef(function Table({cel // TableHead // ---------------------------------------------------------------------------- -interface TableHeadProps extends React.ComponentPropsWithoutRef<'thead'> { - children?: React.ReactNode -} +export type TableHeadProps = React.ComponentPropsWithoutRef<'thead'> function TableHead({children}: TableHeadProps) { return {children} @@ -293,9 +182,7 @@ function TableHead({children}: TableHeadProps) { // TableBody // ---------------------------------------------------------------------------- -interface TableBodyProps extends React.ComponentPropsWithoutRef<'tbody'> { - children?: React.ReactNode -} +export type TableBodyProps = React.ComponentPropsWithoutRef<'tbody'> function TableBody({children}: TableBodyProps) { return {children} @@ -305,19 +192,17 @@ function TableBody({children}: TableBodyProps) { // TableHeader // ---------------------------------------------------------------------------- -interface TableHeaderProps extends React.ComponentPropsWithoutRef<'th'> { - children?: React.ReactNode -} +export type TableHeaderProps = React.ComponentPropsWithoutRef<'th'> function TableHeader({children, ...rest}: TableHeaderProps) { return ( - + {children} ) } -interface TableSortHeaderProps extends TableHeaderProps { +type TableSortHeaderProps = TableHeaderProps & { /** * Specify the sort direction for the TableHeader */ @@ -354,13 +239,11 @@ function TableSortHeader({children, direction, onToggleSort, ...rest}: TableSort // TableRow // ---------------------------------------------------------------------------- -interface TableRowProps extends React.ComponentPropsWithoutRef<'tr'> { - children?: React.ReactNode -} +export type TableRowProps = React.ComponentPropsWithoutRef<'tr'> function TableRow({children, ...rest}: TableRowProps) { return ( - + {children} ) @@ -370,21 +253,20 @@ function TableRow({children, ...rest}: TableRowProps) { // TableCell // ---------------------------------------------------------------------------- -interface TableCellProps extends React.ComponentPropsWithoutRef<'td'> { - children?: React.ReactNode - +export type TableCellProps = React.ComponentPropsWithoutRef<'td'> & { /** * Provide the scope for a table cell, useful for defining a row header using * `scope="row"` */ - scope?: string | undefined + scope?: 'row' | undefined } function TableCell({children, scope, ...rest}: TableCellProps) { const BaseComponent = scope ? 'th' : 'td' + const role = scope ? 'rowheader' : 'cell' return ( - + {children} ) @@ -393,15 +275,13 @@ function TableCell({children, scope, ...rest}: TableCellProps) { // ---------------------------------------------------------------------------- // TableContainer // ---------------------------------------------------------------------------- -interface TableContainerProps { - children?: React.ReactNode | undefined -} +export type TableContainerProps = React.PropsWithChildren -function TableContainer({children}: TableContainerProps) { - return {children} +function TableContainer({children, sx}: TableContainerProps) { + return {children} } -interface TableTitleProps { +export type TableTitleProps = React.PropsWithChildren<{ /** * Provide an alternate element or component to use as the container for * `TableSubtitle`. This is useful when specifying markup that is more @@ -409,16 +289,14 @@ interface TableTitleProps { */ as?: keyof JSX.IntrinsicElements | React.ComponentType - children?: React.ReactNode | undefined - /** * Provide a unique id for the table subtitle. This should be used along with * `aria-labelledby` on `DataTable` */ id: string -} +}> -function TableTitle({as, children, id}: TableTitleProps) { +function TableTitle({as = 'h2', children, id}: TableTitleProps) { return ( function TableSubtitle({as, children, id}: TableSubtitleProps) { return ( @@ -500,7 +376,6 @@ const Button = styled.button` ` export { - DataTable, Table, TableHead, TableBody, @@ -510,4 +385,5 @@ export { TableContainer, TableTitle, TableSubtitle, + TableSortHeader, } diff --git a/src/DataTable/__tests__/DataTable.test.tsx b/src/DataTable/__tests__/DataTable.test.tsx index bf3d3bed1a5..cc26d83fe0d 100644 --- a/src/DataTable/__tests__/DataTable.test.tsx +++ b/src/DataTable/__tests__/DataTable.test.tsx @@ -1,7 +1,7 @@ import userEvent from '@testing-library/user-event' import {render, screen, getByRole, queryByRole, queryAllByRole} from '@testing-library/react' import React from 'react' -import {DataTable, TableContainer, TableTitle, TableSubtitle} from '..' +import {DataTable, Table} from '../../DataTable' import {createColumnHelper} from '../column' describe('DataTable', () => { @@ -114,7 +114,7 @@ describe('DataTable', () => { expect(screen.getByRole('table', {name: 'custom-title'})).toBeInTheDocument() }) - it('should support custom labeling through `aria-labelledby` and `TableTitle`', () => { + it('should support custom labeling through `aria-labelledby` and `Table.Title`', () => { const columnHelper = createColumnHelper<{id: number; name: string}>() const columns = [ columnHelper.column({ @@ -137,12 +137,12 @@ describe('DataTable', () => { }, ] render( - - + + custom-title - + - , + , ) expect(screen.getByRole('table', {name: 'custom-title'})).toBeInTheDocument() }) @@ -178,7 +178,7 @@ describe('DataTable', () => { expect(screen.getByRole('table', {description: 'custom-description'})).toBeInTheDocument() }) - it('should support custom descriptions through `aria-describedby` and `TableSubtitle`', () => { + it('should support custom descriptions through `aria-describedby` and `Table.Subtitle`', () => { const columnHelper = createColumnHelper<{id: number; name: string}>() const columns = [ columnHelper.column({ @@ -201,12 +201,12 @@ describe('DataTable', () => { }, ] render( - - + + custom-description - + - , + , ) expect(screen.getByRole('table', {description: 'custom-description'})).toBeInTheDocument() }) diff --git a/src/DataTable/__tests__/Table.test.tsx b/src/DataTable/__tests__/Table.test.tsx new file mode 100644 index 00000000000..d82cefa4ffa --- /dev/null +++ b/src/DataTable/__tests__/Table.test.tsx @@ -0,0 +1,233 @@ +import {render, screen} from '@testing-library/react' +import React from 'react' +import {Table} from '../../DataTable' +import {TableProps} from '../Table' + +function createTable({columns, rows}: {columns: Array; rows: Array>}) { + return ( + + + + {columns.map(column => { + return {column} + })} + + + + {rows.map((row, index) => { + return ( + + {row.map(cell => ( + {cell} + ))} + + ) + })} + +
+ ) +} + +describe('Table', () => { + it('should render an element with role="table" semantics', () => { + const columns = ['Column A', 'Column B', 'Column C'] + const rows = [ + ['Cell A1', 'Cell B1', 'Cell C1'], + ['Cell A2', 'Cell B2', 'Cell C2'], + ['Cell A3', 'Cell B3', 'Cell C3'], + ] + render(createTable({columns, rows})) + + expect(screen.getByRole('table')).toBeInTheDocument() + // The within is considered a row so we add it to the list of + // rows + expect(screen.getAllByRole('row')).toHaveLength(rows.length + 1) + + for (const column of columns) { + expect(screen.getByRole('columnheader', {name: column})).toBeInTheDocument() + } + + for (const row of rows) { + for (const cell of row) { + expect(screen.getByRole('cell', {name: cell})).toBeInTheDocument() + } + } + }) + + it('should use "normal" cellPadding by default', () => { + render( + + + + Column + + + + + Cell + + +
, + ) + expect(screen.getByRole('table')).toHaveAttribute('data-cell-padding', 'normal') + }) + + it('should support different padding options through `cellPadding`', () => { + const options: Array> = ['condensed', 'normal', 'spacious'] + + for (const option of options) { + render( + <> + {option} + + + + Column + + + + + Cell + + +
+ , + ) + + const table = screen.getByRole('table', {name: option}) + // Note: `data-cell-padding` is an implementation detail for styling and + // may change in the future. This test is used to confirm that we are + // setting that option as it is what triggers the different appearances of + // the + expect(table).toHaveAttribute('data-cell-padding', option) + } + }) + + it('should support labeling through `aria-labelledby`', () => { + render( + <> + test +
+ + + Column + + + + + Cell + + +
+ , + ) + expect(screen.getByRole('table', {name: 'test'})).toBeInTheDocument() + }) + + it('should support describing through `aria-describedby`', () => { + render( + <> + test + + + + Column + + + + + Cell + + +
+ , + ) + expect(screen.getByRole('table', {description: 'test'})).toBeInTheDocument() + }) + + describe('Table.Container', () => { + it('should support custom styles through the `sx` prop', () => { + const {container} = render() + expect(container.firstElementChild).toHaveStyle('margin:0') + }) + }) + + describe('Table.Title', () => { + it('should default to rendering a level 2 heading', () => { + render(test) + expect(screen.getByRole('heading', {name: 'test', level: 2})).toBeInTheDocument() + }) + + it('should place the `id` prop on the outermost element', () => { + const {container} = render(test) + expect(container.firstChild).toHaveAttribute('id', 'test') + }) + + it('should support rendering a custom heading tag', () => { + render( + + test + , + ) + expect(screen.getByRole('heading', {name: 'test', level: 3})).toBeInTheDocument() + }) + }) + + describe('Table.Subtitle', () => { + it('should support changing the outermost element through the `as` prop', () => { + const {container} = render( + + test + , + ) + expect(container.firstElementChild?.tagName).toBe('P') + }) + + it('should place the `id` prop on the outermost element', () => { + const {container} = render(test) + expect(container.firstChild).toHaveAttribute('id', 'test') + }) + }) + + describe('Table.Header', () => { + it('should set scope="col" on the column header', () => { + render( + + + + Column + + + + + Cell + + +
, + ) + + expect(screen.getByRole('columnheader', {name: 'Column'})).toBeInTheDocument() + expect(screen.getByRole('columnheader', {name: 'Column'})).toHaveAttribute('scope', 'col') + }) + }) + + describe('Table.Cell', () => { + it('should set the element to a when `scope` is defined', () => { + render( + + + + Column + + + + + Cell + + +
, + ) + expect(screen.getByRole('rowheader', {name: 'Cell'})).toBeInTheDocument() + }) + }) +}) diff --git a/src/DataTable/index.ts b/src/DataTable/index.ts new file mode 100644 index 00000000000..3d82da7abb1 --- /dev/null +++ b/src/DataTable/index.ts @@ -0,0 +1,37 @@ +import {DataTable} from './DataTable' +import { + Table as TableImpl, + TableHead, + TableBody, + TableRow, + TableHeader, + TableCell, + TableContainer, + TableTitle, + TableSubtitle, +} from './Table' + +const Table = Object.assign(TableImpl, { + Container: TableContainer, + Title: TableTitle, + Subtitle: TableSubtitle, + Head: TableHead, + Body: TableBody, + Header: TableHeader, + Row: TableRow, + Cell: TableCell, +}) + +export {DataTable, Table} +export type {DataTableProps} from './DataTable' +export type { + TableProps, + TableHeadProps, + TableBodyProps, + TableRowProps, + TableHeaderProps, + TableCellProps, + TableContainerProps, + TableTitleProps, + TableSubtitleProps, +} from './Table' diff --git a/src/__tests__/__snapshots__/exports.test.ts.snap b/src/__tests__/__snapshots__/exports.test.ts.snap index 8afcfb8cee4..3bb1aa96540 100644 --- a/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/src/__tests__/__snapshots__/exports.test.ts.snap @@ -130,6 +130,7 @@ exports[`@primer/react/decprecated should not update exports without a semver ch exports[`@primer/react/drafts should not update exports without a semver change 1`] = ` [ "Content", + "DataTable", "Dialog", "Footer", "Header", @@ -143,6 +144,7 @@ exports[`@primer/react/drafts should not update exports without a semver change "Root", "SegmentedControl", "SplitPageLayout", + "Table", "TreeView", "UnderlineNav", "callbackCancelledResult", diff --git a/src/drafts/index.ts b/src/drafts/index.ts index 44c36e90f32..8e3be6dc207 100644 --- a/src/drafts/index.ts +++ b/src/drafts/index.ts @@ -1,9 +1,25 @@ -/** This is the place where we keep components that are not part of the public - * api yet (not in main bundle). We don't recommend using it in production. +/** + * This is the place where we keep components that are not part of the public + * api yet (not in main bundle). We don't recommend using it in production. * - * But, they are published on npm and you can import them for experimentation/feedback. - * example: import {ActionList} from '@primer/react/drafts + * But, they are published on npm and you can import them for experimentation/feedback. + * example: import {ActionList} from '@primer/react/drafts */ + +export {DataTable, Table} from '../DataTable' +export type { + DataTableProps, + TableProps, + TableHeadProps, + TableBodyProps, + TableRowProps, + TableHeaderProps, + TableCellProps, + TableContainerProps, + TableTitleProps, + TableSubtitleProps, +} from '../DataTable' + export * from '../Dialog/Dialog' export * from '../Hidden'