Skip to content

Commit 42037c9

Browse files
authored
feat(DataTable): add Table.Skeleton (#3055)
* feat(DataTable): add TableSkeleton and loading * docs(DataTable): update loading story * feat(TableSkeleton): add skeleton shimmer for loading * chore: adjust skeleton widths * fix(Table): add support for HCM on skeleton item * feat(DataTable): add Table.Skeleton * Update generated/components.json * chore: add changeset * docs(DataTable): add aria-labelledby and aria-describedby to Table.Skeleton * Update generated/components.json --------- Co-authored-by: Josh Black <[email protected]>
1 parent 0ca1bec commit 42037c9

File tree

9 files changed

+363
-52
lines changed

9 files changed

+363
-52
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Add Table.Skeleton component

generated/components.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1807,6 +1807,10 @@
18071807
{
18081808
"id": "components-datatable-features--with-custom-heading",
18091809
"code": "() => (\n <>\n <Heading as=\"h2\" id=\"repositories\">\n Security coverage\n </Heading>\n <p id=\"repositories-subtitle\">\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 </p>\n <Table.Container>\n <DataTable\n aria-labelledby=\"repositories\"\n aria-describedby=\"repositories-subtitle\"\n data={data}\n columns={[\n {\n header: 'Repository',\n field: 'name',\n rowHeader: true,\n },\n {\n header: 'Type',\n field: 'type',\n renderCell: (row) => {\n return <Label>{uppercase(row.type)}</Label>\n },\n },\n {\n header: 'Updated',\n field: 'updatedAt',\n renderCell: (row) => {\n return <RelativeTime date={new Date(row.updatedAt)} />\n },\n },\n {\n header: 'Dependabot',\n field: 'securityFeatures.dependabot',\n renderCell: (row) => {\n return row.securityFeatures.dependabot.length > 0 ? (\n <LabelGroup>\n {row.securityFeatures.dependabot.map((feature) => {\n return <Label key={feature}>{uppercase(feature)}</Label>\n })}\n </LabelGroup>\n ) : null\n },\n },\n {\n header: 'Code scanning',\n field: 'securityFeatures.codeScanning',\n renderCell: (row) => {\n return row.securityFeatures.codeScanning.length > 0 ? (\n <LabelGroup>\n {row.securityFeatures.codeScanning.map((feature) => {\n return <Label key={feature}>{uppercase(feature)}</Label>\n })}\n </LabelGroup>\n ) : null\n },\n },\n ]}\n />\n </Table.Container>\n </>\n)"
1810+
},
1811+
{
1812+
"id": "components-datatable-features--with-loading",
1813+
"code": "() => {\n const [loading] = React.useState(true)\n return (\n <Table.Container>\n <Table.Title as=\"h2\" id=\"repositories\">\n Repositories\n </Table.Title>\n <Table.Subtitle as=\"p\" id=\"repositories-subtitle\">\n A subtitle could appear here to give extra context to the data.\n </Table.Subtitle>\n {loading ? (\n <Table.Skeleton\n aria-labelledby=\"repositories\"\n aria-describedby=\"repositories-subtitle\"\n columns={columns}\n rows={10}\n />\n ) : (\n <DataTable\n aria-labelledby=\"repositories\"\n aria-describedby=\"repositories-subtitle\"\n data={data}\n columns={columns}\n />\n )}\n </Table.Container>\n )\n}"
18101814
}
18111815
],
18121816
"props": [
@@ -1947,6 +1951,25 @@
19471951
"required": true
19481952
}
19491953
]
1954+
},
1955+
{
1956+
"name": "Table.Skeleton",
1957+
"props": [
1958+
{
1959+
"name": "cellPadding",
1960+
"type": "'condensed' | 'normal' | 'spacious'",
1961+
"description": "Specify the amount of space that should be available around the contents of a cell"
1962+
},
1963+
{
1964+
"name": "columns",
1965+
"type": "Array<Column<Data>>"
1966+
},
1967+
{
1968+
"name": "rows",
1969+
"type": "number",
1970+
"description": "Optionally specify the number of rows which should be included in the skeleton state of the component"
1971+
}
1972+
]
19501973
}
19511974
]
19521975
},

src/DataTable/DataTable.docs.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
},
3131
{
3232
"id": "components-datatable-features--with-custom-heading"
33+
},
34+
{
35+
"id": "components-datatable-features--with-loading"
3336
}
3437
],
3538
"props": [
@@ -170,6 +173,25 @@
170173
"required": true
171174
}
172175
]
176+
},
177+
{
178+
"name": "Table.Skeleton",
179+
"props": [
180+
{
181+
"name": "cellPadding",
182+
"type": "'condensed' | 'normal' | 'spacious'",
183+
"description": "Specify the amount of space that should be available around the contents of a cell"
184+
},
185+
{
186+
"name": "columns",
187+
"type": "Array<Column<Data>>"
188+
},
189+
{
190+
"name": "rows",
191+
"type": "number",
192+
"description": "Optionally specify the number of rows which should be included in the skeleton state of the component"
193+
}
194+
]
173195
}
174196
]
175197
}

src/DataTable/DataTable.features.stories.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Label from '../Label'
1212
import LabelGroup from '../LabelGroup'
1313
import RelativeTime from '../RelativeTime'
1414
import VisuallyHidden from '../_VisuallyHidden'
15+
import {createColumnHelper} from './column'
1516

1617
export default {
1718
title: 'Components/DataTable/Features',
@@ -1132,6 +1133,84 @@ export const WithOverflow = () => (
11321133
</div>
11331134
)
11341135

1136+
const columnHelper = createColumnHelper<Repo>()
1137+
const columns = [
1138+
columnHelper.column({
1139+
header: 'Repository',
1140+
field: 'name',
1141+
rowHeader: true,
1142+
}),
1143+
columnHelper.column({
1144+
header: 'Type',
1145+
field: 'type',
1146+
renderCell: row => {
1147+
return <Label>{uppercase(row.type)}</Label>
1148+
},
1149+
}),
1150+
columnHelper.column({
1151+
header: 'Updated',
1152+
field: 'updatedAt',
1153+
renderCell: row => {
1154+
return <RelativeTime date={new Date(row.updatedAt)} />
1155+
},
1156+
}),
1157+
columnHelper.column({
1158+
header: 'Dependabot',
1159+
field: 'securityFeatures.dependabot',
1160+
renderCell: row => {
1161+
return row.securityFeatures.dependabot.length > 0 ? (
1162+
<LabelGroup>
1163+
{row.securityFeatures.dependabot.map(feature => {
1164+
return <Label key={feature}>{uppercase(feature)}</Label>
1165+
})}
1166+
</LabelGroup>
1167+
) : null
1168+
},
1169+
}),
1170+
columnHelper.column({
1171+
header: 'Code scanning',
1172+
field: 'securityFeatures.codeScanning',
1173+
renderCell: row => {
1174+
return row.securityFeatures.codeScanning.length > 0 ? (
1175+
<LabelGroup>
1176+
{row.securityFeatures.codeScanning.map(feature => {
1177+
return <Label key={feature}>{uppercase(feature)}</Label>
1178+
})}
1179+
</LabelGroup>
1180+
) : null
1181+
},
1182+
}),
1183+
]
1184+
1185+
export const WithLoading = () => {
1186+
const [loading] = React.useState(true)
1187+
return (
1188+
<Table.Container>
1189+
<Table.Title as="h2" id="repositories">
1190+
Repositories
1191+
</Table.Title>
1192+
<Table.Subtitle as="p" id="repositories-subtitle">
1193+
A subtitle could appear here to give extra context to the data.
1194+
</Table.Subtitle>
1195+
{loading ? (
1196+
<Table.Skeleton
1197+
aria-labelledby="repositories"
1198+
aria-describedby="repositories-subtitle"
1199+
columns={columns}
1200+
rows={10}
1201+
/>
1202+
) : (
1203+
<DataTable
1204+
aria-labelledby="repositories"
1205+
aria-describedby="repositories-subtitle"
1206+
data={data}
1207+
columns={columns}
1208+
/>
1209+
)}
1210+
</Table.Container>
1211+
)
1212+
}
1213+
11351214
export const WithPlaceholderCells = () => (
11361215
<Table.Container>
11371216
<Table.Title as="h2" id="repositories">

src/DataTable/Table.tsx

Lines changed: 140 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
import {SortAscIcon, SortDescIcon} from '@primer/octicons-react'
2+
import cx from 'classnames'
23
import React from 'react'
3-
import styled from 'styled-components'
4+
import styled, {keyframes} from 'styled-components'
45
import Box from '../Box'
56
import Text from '../Text'
67
import {get} from '../constants'
78
import sx, {SxProp} from '../sx'
9+
import VisuallyHidden from '../_VisuallyHidden'
10+
import {Column, CellAlignment} from './column'
11+
import {UniqueRow} from './row'
812
import {SortDirection} from './sorting'
13+
import {useTableLayout} from './useTable'
914
import {useOverflow} from '../hooks/useOverflow'
10-
import {CellAlignment} from './column'
1115

1216
// ----------------------------------------------------------------------------
1317
// Table
1418
// ----------------------------------------------------------------------------
1519

20+
const shimmer = keyframes`
21+
from { mask-position: 200%; }
22+
to { mask-position: 0%; }
23+
`
1624
const StyledTable = styled.table<React.ComponentPropsWithoutRef<'table'>>`
1725
/* Default table styles */
1826
--table-border-radius: 0.375rem;
@@ -104,11 +112,13 @@ const StyledTable = styled.table<React.ComponentPropsWithoutRef<'table'>>`
104112
* Offset padding to make sure type aligns regardless of cell padding
105113
* selection
106114
*/
107-
.TableRow > *:first-child {
115+
.TableRow > *:first-child:not(.TableCellSkeleton),
116+
.TableRow > *:first-child .TableCellSkeletonItem {
108117
padding-inline-start: 1rem;
109118
}
110119
111-
.TableRow > *:last-child {
120+
.TableRow > *:last-child:not(.TableCellSkeleton),
121+
.TableRow > *:last-child .TableCellSkeletonItem {
112122
padding-inline-end: 1rem;
113123
}
114124
@@ -143,7 +153,7 @@ const StyledTable = styled.table<React.ComponentPropsWithoutRef<'table'>>`
143153
}
144154
145155
/* TableRow */
146-
.TableRow:hover .TableCell {
156+
.TableRow:hover .TableCell:not(.TableCellSkeleton) {
147157
/* TODO: update this token when the new primitive tokens are released */
148158
background-color: ${get('colors.actionListItem.default.hoverBg')};
149159
}
@@ -154,6 +164,66 @@ const StyledTable = styled.table<React.ComponentPropsWithoutRef<'table'>>`
154164
font-weight: 600;
155165
}
156166
167+
/* TableCellSkeleton */
168+
.TableCellSkeleton {
169+
padding: 0;
170+
}
171+
172+
.TableCellSkeletonItems {
173+
display: flex;
174+
flex-direction: column;
175+
}
176+
177+
.TableCellSkeletonItem {
178+
padding: var(--table-cell-padding);
179+
180+
&:nth-of-type(5n + 1) {
181+
--skeleton-item-width: 85%;
182+
}
183+
184+
&:nth-of-type(5n + 2) {
185+
--skeleton-item-width: 67.5%;
186+
}
187+
188+
&:nth-of-type(5n + 3) {
189+
--skeleton-item-width: 80%;
190+
}
191+
192+
&:nth-of-type(5n + 4) {
193+
--skeleton-item-width: 60%;
194+
}
195+
196+
&:nth-of-type(5n + 5) {
197+
--skeleton-item-width: 75%;
198+
}
199+
}
200+
201+
.TableCellSkeletonItem:not(:last-of-type) {
202+
border-bottom: 1px solid ${get('colors.border.default')};
203+
}
204+
205+
.TableCellSkeletonItem::before {
206+
display: block;
207+
content: '';
208+
height: 1rem;
209+
width: var(--skeleton-item-width, 67%);
210+
background-color: ${get('colors.canvas.subtle')};
211+
border-radius: 3px;
212+
213+
@media (prefers-reduced-motion: no-preference) {
214+
mask-image: linear-gradient(75deg, #000 30%, rgba(0, 0, 0, 0.65) 80%);
215+
mask-size: 200%;
216+
animation: ${shimmer};
217+
animation-duration: 1s;
218+
animation-iteration-count: infinite;
219+
}
220+
221+
@media (forced-colors: active) {
222+
outline: 1px solid transparent;
223+
outline-offset: -1px;
224+
}
225+
}
226+
157227
/* Grid layout */
158228
.TableHead,
159229
.TableBody,
@@ -195,7 +265,7 @@ export type TableProps = React.ComponentPropsWithoutRef<'table'> & {
195265
}
196266

197267
const Table = React.forwardRef<HTMLTableElement, TableProps>(function Table(
198-
{'aria-labelledby': labelledby, cellPadding = 'normal', gridTemplateColumns, ...rest},
268+
{'aria-labelledby': labelledby, cellPadding = 'normal', className, gridTemplateColumns, ...rest},
199269
ref,
200270
) {
201271
return (
@@ -204,7 +274,7 @@ const Table = React.forwardRef<HTMLTableElement, TableProps>(function Table(
204274
{...rest}
205275
aria-labelledby={labelledby}
206276
data-cell-padding={cellPadding}
207-
className="Table"
277+
className={cx('Table', className)}
208278
role="table"
209279
ref={ref}
210280
style={{'--grid-template-columns': gridTemplateColumns} as React.CSSProperties}
@@ -332,12 +402,12 @@ export type TableCellProps = Omit<React.ComponentPropsWithoutRef<'td'>, 'align'>
332402
scope?: 'row'
333403
}
334404

335-
function TableCell({align, children, scope, ...rest}: TableCellProps) {
405+
function TableCell({align, className, children, scope, ...rest}: TableCellProps) {
336406
const BaseComponent = scope ? 'th' : 'td'
337407
const role = scope ? 'rowheader' : 'cell'
338408

339409
return (
340-
<BaseComponent {...rest} className="TableCell" scope={scope} role={role} data-cell-align={align}>
410+
<BaseComponent {...rest} className={cx('TableCell', className)} scope={scope} role={role} data-cell-align={align}>
341411
{children}
342412
</BaseComponent>
343413
)
@@ -504,6 +574,66 @@ function TableActions({children}: TableActionsProps) {
504574
return <div className="TableActions">{children}</div>
505575
}
506576

577+
// ----------------------------------------------------------------------------
578+
// TableSkeleton
579+
// ----------------------------------------------------------------------------
580+
export type TableSkeletonProps<Data extends UniqueRow> = React.ComponentPropsWithoutRef<'table'> & {
581+
/**
582+
* Specify the amount of space that should be available around the contents of
583+
* a cell
584+
*/
585+
cellPadding?: 'condensed' | 'normal' | 'spacious'
586+
587+
/**
588+
* Provide an array of columns for the table. Columns will render as the headers
589+
* of the table.
590+
*/
591+
columns: Array<Column<Data>>
592+
593+
/**
594+
* Optionally specify the number of rows which should be included in the
595+
* skeleton state of the component
596+
*/
597+
rows?: number
598+
}
599+
600+
function TableSkeleton<Data extends UniqueRow>({cellPadding, columns, rows = 10, ...rest}: TableSkeletonProps<Data>) {
601+
const {gridTemplateColumns} = useTableLayout(columns)
602+
return (
603+
<Table {...rest} cellPadding={cellPadding} gridTemplateColumns={gridTemplateColumns}>
604+
<TableHead>
605+
<TableRow>
606+
{Array.isArray(columns)
607+
? columns.map((column, i) => {
608+
return (
609+
<TableHeader key={i}>
610+
{typeof column.header === 'string' ? column.header : column.header()}
611+
</TableHeader>
612+
)
613+
})
614+
: null}
615+
</TableRow>
616+
</TableHead>
617+
<TableBody>
618+
<TableRow>
619+
{Array.from({length: columns.length}).map((_, i) => {
620+
return (
621+
<TableCell key={i} className="TableCellSkeleton">
622+
<VisuallyHidden>Loading</VisuallyHidden>
623+
<div className="TableCellSkeletonItems">
624+
{Array.from({length: rows}).map((_, i) => {
625+
return <div key={i} className="TableCellSkeletonItem" />
626+
})}
627+
</div>
628+
</TableCell>
629+
)
630+
})}
631+
</TableRow>
632+
</TableBody>
633+
</Table>
634+
)
635+
}
636+
507637
// ----------------------------------------------------------------------------
508638
// Utilities
509639
// ----------------------------------------------------------------------------
@@ -567,4 +697,5 @@ export {
567697
TableSortHeader,
568698
TableCell,
569699
TableCellPlaceholder,
700+
TableSkeleton,
570701
}

0 commit comments

Comments
 (0)