diff --git a/.changeset/strange-donkeys-worry.md b/.changeset/strange-donkeys-worry.md new file mode 100644 index 00000000000..c27e58b4a94 --- /dev/null +++ b/.changeset/strange-donkeys-worry.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Add Table.Skeleton component diff --git a/generated/components.json b/generated/components.json index 5840bfae98e..a05e339b0dd 100644 --- a/generated/components.json +++ b/generated/components.json @@ -1807,6 +1807,10 @@ { "id": "components-datatable-features--with-custom-heading", "code": "() => (\n <>\n \n Security coverage\n \n

\n Organization members can only see data for the most recently-updated\n repositories. To see all repositories, talk to your organization\n administrator about becoming a security manager.\n

\n \n {\n return \n },\n },\n {\n header: 'Updated',\n field: 'updatedAt',\n renderCell: (row) => {\n return \n },\n },\n {\n header: 'Dependabot',\n field: 'securityFeatures.dependabot',\n renderCell: (row) => {\n return row.securityFeatures.dependabot.length > 0 ? (\n \n {row.securityFeatures.dependabot.map((feature) => {\n return \n })}\n \n ) : null\n },\n },\n {\n header: 'Code scanning',\n field: 'securityFeatures.codeScanning',\n renderCell: (row) => {\n return row.securityFeatures.codeScanning.length > 0 ? (\n \n {row.securityFeatures.codeScanning.map((feature) => {\n return \n })}\n \n ) : null\n },\n },\n ]}\n />\n \n \n)" + }, + { + "id": "components-datatable-features--with-loading", + "code": "() => {\n const [loading] = React.useState(true)\n return (\n \n \n Repositories\n \n \n A subtitle could appear here to give extra context to the data.\n \n {loading ? (\n \n ) : (\n \n )}\n \n )\n}" } ], "props": [ @@ -1947,6 +1951,25 @@ "required": true } ] + }, + { + "name": "Table.Skeleton", + "props": [ + { + "name": "cellPadding", + "type": "'condensed' | 'normal' | 'spacious'", + "description": "Specify the amount of space that should be available around the contents of a cell" + }, + { + "name": "columns", + "type": "Array>" + }, + { + "name": "rows", + "type": "number", + "description": "Optionally specify the number of rows which should be included in the skeleton state of the component" + } + ] } ] }, diff --git a/src/DataTable/DataTable.docs.json b/src/DataTable/DataTable.docs.json index 2cfaf0b1194..5a791bade9b 100644 --- a/src/DataTable/DataTable.docs.json +++ b/src/DataTable/DataTable.docs.json @@ -30,6 +30,9 @@ }, { "id": "components-datatable-features--with-custom-heading" + }, + { + "id": "components-datatable-features--with-loading" } ], "props": [ @@ -170,6 +173,25 @@ "required": true } ] + }, + { + "name": "Table.Skeleton", + "props": [ + { + "name": "cellPadding", + "type": "'condensed' | 'normal' | 'spacious'", + "description": "Specify the amount of space that should be available around the contents of a cell" + }, + { + "name": "columns", + "type": "Array>" + }, + { + "name": "rows", + "type": "number", + "description": "Optionally specify the number of rows which should be included in the skeleton state of the component" + } + ] } ] } diff --git a/src/DataTable/DataTable.features.stories.tsx b/src/DataTable/DataTable.features.stories.tsx index b03d27260aa..2fc15c7f84c 100644 --- a/src/DataTable/DataTable.features.stories.tsx +++ b/src/DataTable/DataTable.features.stories.tsx @@ -12,6 +12,7 @@ import Label from '../Label' import LabelGroup from '../LabelGroup' import RelativeTime from '../RelativeTime' import VisuallyHidden from '../_VisuallyHidden' +import {createColumnHelper} from './column' export default { title: 'Components/DataTable/Features', @@ -1132,6 +1133,84 @@ export const WithOverflow = () => ( ) +const columnHelper = createColumnHelper() +const columns = [ + columnHelper.column({ + header: 'Repository', + field: 'name', + rowHeader: true, + }), + columnHelper.column({ + header: 'Type', + field: 'type', + renderCell: row => { + return + }, + }), + columnHelper.column({ + header: 'Updated', + field: 'updatedAt', + renderCell: row => { + return + }, + }), + columnHelper.column({ + header: 'Dependabot', + field: 'securityFeatures.dependabot', + renderCell: row => { + return row.securityFeatures.dependabot.length > 0 ? ( + + {row.securityFeatures.dependabot.map(feature => { + return + })} + + ) : null + }, + }), + columnHelper.column({ + header: 'Code scanning', + field: 'securityFeatures.codeScanning', + renderCell: row => { + return row.securityFeatures.codeScanning.length > 0 ? ( + + {row.securityFeatures.codeScanning.map(feature => { + return + })} + + ) : null + }, + }), +] + +export const WithLoading = () => { + const [loading] = React.useState(true) + return ( + + + Repositories + + + A subtitle could appear here to give extra context to the data. + + {loading ? ( + + ) : ( + + )} + + ) +} + export const WithPlaceholderCells = () => ( diff --git a/src/DataTable/Table.tsx b/src/DataTable/Table.tsx index 8bb1e4fbcb5..b3857e0721e 100644 --- a/src/DataTable/Table.tsx +++ b/src/DataTable/Table.tsx @@ -1,18 +1,26 @@ import {SortAscIcon, SortDescIcon} from '@primer/octicons-react' +import cx from 'classnames' import React from 'react' -import styled from 'styled-components' +import styled, {keyframes} from 'styled-components' import Box from '../Box' import Text from '../Text' import {get} from '../constants' import sx, {SxProp} from '../sx' +import VisuallyHidden from '../_VisuallyHidden' +import {Column, CellAlignment} from './column' +import {UniqueRow} from './row' import {SortDirection} from './sorting' +import {useTableLayout} from './useTable' import {useOverflow} from '../hooks/useOverflow' -import {CellAlignment} from './column' // ---------------------------------------------------------------------------- // Table // ---------------------------------------------------------------------------- +const shimmer = keyframes` + from { mask-position: 200%; } + to { mask-position: 0%; } +` const StyledTable = styled.table>` /* Default table styles */ --table-border-radius: 0.375rem; @@ -104,11 +112,13 @@ const StyledTable = styled.table>` * Offset padding to make sure type aligns regardless of cell padding * selection */ - .TableRow > *:first-child { + .TableRow > *:first-child:not(.TableCellSkeleton), + .TableRow > *:first-child .TableCellSkeletonItem { padding-inline-start: 1rem; } - .TableRow > *:last-child { + .TableRow > *:last-child:not(.TableCellSkeleton), + .TableRow > *:last-child .TableCellSkeletonItem { padding-inline-end: 1rem; } @@ -143,7 +153,7 @@ const StyledTable = styled.table>` } /* TableRow */ - .TableRow:hover .TableCell { + .TableRow:hover .TableCell:not(.TableCellSkeleton) { /* TODO: update this token when the new primitive tokens are released */ background-color: ${get('colors.actionListItem.default.hoverBg')}; } @@ -154,6 +164,66 @@ const StyledTable = styled.table>` font-weight: 600; } + /* TableCellSkeleton */ + .TableCellSkeleton { + padding: 0; + } + + .TableCellSkeletonItems { + display: flex; + flex-direction: column; + } + + .TableCellSkeletonItem { + padding: var(--table-cell-padding); + + &:nth-of-type(5n + 1) { + --skeleton-item-width: 85%; + } + + &:nth-of-type(5n + 2) { + --skeleton-item-width: 67.5%; + } + + &:nth-of-type(5n + 3) { + --skeleton-item-width: 80%; + } + + &:nth-of-type(5n + 4) { + --skeleton-item-width: 60%; + } + + &:nth-of-type(5n + 5) { + --skeleton-item-width: 75%; + } + } + + .TableCellSkeletonItem:not(:last-of-type) { + border-bottom: 1px solid ${get('colors.border.default')}; + } + + .TableCellSkeletonItem::before { + display: block; + content: ''; + height: 1rem; + width: var(--skeleton-item-width, 67%); + background-color: ${get('colors.canvas.subtle')}; + border-radius: 3px; + + @media (prefers-reduced-motion: no-preference) { + mask-image: linear-gradient(75deg, #000 30%, rgba(0, 0, 0, 0.65) 80%); + mask-size: 200%; + animation: ${shimmer}; + animation-duration: 1s; + animation-iteration-count: infinite; + } + + @media (forced-colors: active) { + outline: 1px solid transparent; + outline-offset: -1px; + } + } + /* Grid layout */ .TableHead, .TableBody, @@ -195,7 +265,7 @@ export type TableProps = React.ComponentPropsWithoutRef<'table'> & { } const Table = React.forwardRef(function Table( - {'aria-labelledby': labelledby, cellPadding = 'normal', gridTemplateColumns, ...rest}, + {'aria-labelledby': labelledby, cellPadding = 'normal', className, gridTemplateColumns, ...rest}, ref, ) { return ( @@ -204,7 +274,7 @@ const Table = React.forwardRef(function Table( {...rest} aria-labelledby={labelledby} data-cell-padding={cellPadding} - className="Table" + className={cx('Table', className)} role="table" ref={ref} style={{'--grid-template-columns': gridTemplateColumns} as React.CSSProperties} @@ -332,12 +402,12 @@ export type TableCellProps = Omit, 'align'> scope?: 'row' } -function TableCell({align, children, scope, ...rest}: TableCellProps) { +function TableCell({align, className, children, scope, ...rest}: TableCellProps) { const BaseComponent = scope ? 'th' : 'td' const role = scope ? 'rowheader' : 'cell' return ( - + {children} ) @@ -504,6 +574,66 @@ function TableActions({children}: TableActionsProps) { return
{children}
} +// ---------------------------------------------------------------------------- +// TableSkeleton +// ---------------------------------------------------------------------------- +export type TableSkeletonProps = React.ComponentPropsWithoutRef<'table'> & { + /** + * Specify the amount of space that should be available around the contents of + * a cell + */ + cellPadding?: 'condensed' | 'normal' | 'spacious' + + /** + * Provide an array of columns for the table. Columns will render as the headers + * of the table. + */ + columns: Array> + + /** + * Optionally specify the number of rows which should be included in the + * skeleton state of the component + */ + rows?: number +} + +function TableSkeleton({cellPadding, columns, rows = 10, ...rest}: TableSkeletonProps) { + const {gridTemplateColumns} = useTableLayout(columns) + return ( + + + + {Array.isArray(columns) + ? columns.map((column, i) => { + return ( + + {typeof column.header === 'string' ? column.header : column.header()} + + ) + }) + : null} + + + + + {Array.from({length: columns.length}).map((_, i) => { + return ( + + Loading +
+ {Array.from({length: rows}).map((_, i) => { + return
+ })} +
+ + ) + })} + + +
+ ) +} + // ---------------------------------------------------------------------------- // Utilities // ---------------------------------------------------------------------------- @@ -567,4 +697,5 @@ export { TableSortHeader, TableCell, TableCellPlaceholder, + TableSkeleton, } diff --git a/src/DataTable/__tests__/DataTable.test.tsx b/src/DataTable/__tests__/DataTable.test.tsx index 326cfeba762..085b1d93ecb 100644 --- a/src/DataTable/__tests__/DataTable.test.tsx +++ b/src/DataTable/__tests__/DataTable.test.tsx @@ -823,6 +823,7 @@ describe('DataTable', () => { 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 = [ @@ -835,6 +836,7 @@ describe('DataTable', () => { expect(getGridTemplateFromColumns(columns)).toEqual(['minmax(max-content, 1fr)']) }) + it('correctly sets the column width when width === "growCollapse"', () => { const columnHelper = createColumnHelper<{id: number; name: string}>() const columns = [ @@ -847,6 +849,7 @@ describe('DataTable', () => { 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 = [ @@ -859,6 +862,7 @@ describe('DataTable', () => { 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 = [ @@ -871,6 +875,7 @@ describe('DataTable', () => { 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 = [ @@ -883,6 +888,7 @@ describe('DataTable', () => { expect(getGridTemplateFromColumns(columns)).toEqual(['200px']) }) + it('correctly sets min-widths for the column', () => { const columnHelper = createColumnHelper<{id: number; name: string}>() const columns: Record[]> = { @@ -921,6 +927,7 @@ describe('DataTable', () => { 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[]> = { @@ -959,6 +966,7 @@ describe('DataTable', () => { 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 = [ diff --git a/src/DataTable/__tests__/Table.test.tsx b/src/DataTable/__tests__/Table.test.tsx index d82cefa4ffa..9e91eef925a 100644 --- a/src/DataTable/__tests__/Table.test.tsx +++ b/src/DataTable/__tests__/Table.test.tsx @@ -1,6 +1,7 @@ import {render, screen} from '@testing-library/react' import React from 'react' import {Table} from '../../DataTable' +import {createColumnHelper} from '../column' import {TableProps} from '../Table' function createTable({columns, rows}: {columns: Array; rows: Array>}) { @@ -230,4 +231,36 @@ describe('Table', () => { expect(screen.getByRole('rowheader', {name: 'Cell'})).toBeInTheDocument() }) }) + + describe('Table.Skeleton', () => { + it('should render a table with columns and loading content', () => { + const columnHelper = createColumnHelper() + const columns = [ + columnHelper.column({ + header: 'Column A', + }), + columnHelper.column({ + header: 'Column B', + }), + columnHelper.column({ + header: 'Column C', + }), + ] + render( + <> +

Test

+ + , + ) + + expect(screen.getByRole('table', {name: 'Test'})).toBeInTheDocument() + for (const column of columns) { + expect(screen.getByRole('columnheader', {name: column.header as string})).toBeInTheDocument() + } + + for (const cell of screen.getAllByRole('cell')) { + expect(cell).toHaveTextContent('Loading') + } + }) + }) }) diff --git a/src/DataTable/index.ts b/src/DataTable/index.ts index b98a280cfb8..b6a3ebc63b6 100644 --- a/src/DataTable/index.ts +++ b/src/DataTable/index.ts @@ -12,6 +12,7 @@ import { TableSubtitle, TableActions, TableDivider, + TableSkeleton, } from './Table' const Table = Object.assign(TableImpl, { @@ -20,6 +21,7 @@ const Table = Object.assign(TableImpl, { Subtitle: TableSubtitle, Actions: TableActions, Divider: TableDivider, + Skeleton: TableSkeleton, Head: TableHead, Body: TableBody, Header: TableHeader, @@ -41,4 +43,5 @@ export type { TableTitleProps, TableSubtitleProps, TableActionsProps, + TableSkeletonProps, } from './Table' diff --git a/src/DataTable/useTable.ts b/src/DataTable/useTable.ts index 67a1c145ddf..cf9579a8764 100644 --- a/src/DataTable/useTable.ts +++ b/src/DataTable/useTable.ts @@ -42,48 +42,6 @@ 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 "growCollapse" don't need a min width unless one is explicitly provided. - if (columnWidth === 'growCollapse') { - 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', 'growCollapse', '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, @@ -96,6 +54,7 @@ export function useTable({ const [sortByColumn, setSortByColumn] = useState(() => { return getInitialSortState(columns, initialSortColumn, initialSortDirection) }) + const {gridTemplateColumns} = useTableLayout(columns) // Reset the `sortByColumn` state if the columns change and that column is no // longer provided @@ -235,7 +194,7 @@ export function useTable({ actions: { sortBy, }, - gridTemplateColumns: getGridTemplateFromColumns(columns).join(' '), + gridTemplateColumns, } } @@ -310,6 +269,54 @@ function getInitialSortState( return null } +export function useTableLayout(columns: Array>): {gridTemplateColumns: string} { + return { + gridTemplateColumns: getGridTemplateFromColumns(columns).join(' '), + } +} + +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 "growCollapse" don't need a min width unless one is explicitly provided. + if (columnWidth === 'growCollapse') { + 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', 'growCollapse', '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 + }) +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any function get, Path extends string>( object: ObjectType,