Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strange-donkeys-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Add Table.Skeleton component
23 changes: 23 additions & 0 deletions generated/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -1915,6 +1915,10 @@
{
"id": "components-datatable-features--with-custom-heading",
"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)"
},
{
"id": "components-datatable-features--with-loading",
"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}"
}
],
"props": [
Expand Down Expand Up @@ -2055,6 +2059,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<Column<Data>>"
},
{
"name": "rows",
"type": "number",
"description": "Optionally specify the number of rows which should be included in the skeleton state of the component"
}
]
}
]
},
Expand Down
22 changes: 22 additions & 0 deletions src/DataTable/DataTable.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
},
{
"id": "components-datatable-features--with-custom-heading"
},
{
"id": "components-datatable-features--with-loading"
}
],
"props": [
Expand Down Expand Up @@ -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<Column<Data>>"
},
{
"name": "rows",
"type": "number",
"description": "Optionally specify the number of rows which should be included in the skeleton state of the component"
}
]
}
]
}
79 changes: 79 additions & 0 deletions src/DataTable/DataTable.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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',
Expand Down Expand Up @@ -1130,3 +1131,81 @@ export const WithOverflow = () => (
</Table.Container>
</div>
)

const columnHelper = createColumnHelper<Repo>()
const columns = [
columnHelper.column({
header: 'Repository',
field: 'name',
rowHeader: true,
}),
columnHelper.column({
header: 'Type',
field: 'type',
renderCell: row => {
return <Label>{uppercase(row.type)}</Label>
},
}),
columnHelper.column({
header: 'Updated',
field: 'updatedAt',
renderCell: row => {
return <RelativeTime date={new Date(row.updatedAt)} />
},
}),
columnHelper.column({
header: 'Dependabot',
field: 'securityFeatures.dependabot',
renderCell: row => {
return row.securityFeatures.dependabot.length > 0 ? (
<LabelGroup>
{row.securityFeatures.dependabot.map(feature => {
return <Label key={feature}>{uppercase(feature)}</Label>
})}
</LabelGroup>
) : null
},
}),
columnHelper.column({
header: 'Code scanning',
field: 'securityFeatures.codeScanning',
renderCell: row => {
return row.securityFeatures.codeScanning.length > 0 ? (
<LabelGroup>
{row.securityFeatures.codeScanning.map(feature => {
return <Label key={feature}>{uppercase(feature)}</Label>
})}
</LabelGroup>
) : null
},
}),
]

export const WithLoading = () => {
const [loading] = React.useState(true)
return (
<Table.Container>
<Table.Title as="h2" id="repositories">
Repositories
</Table.Title>
<Table.Subtitle as="p" id="repositories-subtitle">
A subtitle could appear here to give extra context to the data.
</Table.Subtitle>
{loading ? (
<Table.Skeleton
aria-labelledby="repositories"
aria-describedby="repositories-subtitle"
columns={columns}
rows={10}
/>
) : (
<DataTable
aria-labelledby="repositories"
aria-describedby="repositories-subtitle"
data={data}
columns={columns}
/>
)}
</Table.Container>
)
}
148 changes: 140 additions & 8 deletions src/DataTable/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
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 {get} from '../constants'
import sx, {SxProp} from '../sx'
import VisuallyHidden from '../_VisuallyHidden'
import {Column} from './column'
import {UniqueRow} from './row'
import {SortDirection} from './sorting'
import {useTableLayout} from './useTable'
import {useOverflow} from '../hooks/useOverflow'

// ----------------------------------------------------------------------------
// Table
// ----------------------------------------------------------------------------

const shimmer = keyframes`
from { mask-position: 200%; }
to { mask-position: 0%; }
`
const StyledTable = styled.table<React.ComponentPropsWithoutRef<'table'>>`
/* Default table styles */
--table-border-radius: 0.375rem;
Expand Down Expand Up @@ -89,11 +98,13 @@ const StyledTable = styled.table<React.ComponentPropsWithoutRef<'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;
}

Expand Down Expand Up @@ -129,7 +140,7 @@ const StyledTable = styled.table<React.ComponentPropsWithoutRef<'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')};
}
Expand All @@ -141,6 +152,66 @@ const StyledTable = styled.table<React.ComponentPropsWithoutRef<'table'>>`
text-align: start;
}

/* TableCellSkeleton */
.TableCellSkeleton {
padding: 0;
}

.TableCellSkeletonItems {
display: flex;
flex-direction: column;
}

.TableCellSkeletonItem {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we see this style of "loading" placeholder come up again, we should consider creating some kind of "SkeletonLoader" component that can be reused. We already use something identical in TreeView.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mperrotti agreed 100% 💯 Would be so useful

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,
Expand Down Expand Up @@ -182,7 +253,7 @@ export type TableProps = React.ComponentPropsWithoutRef<'table'> & {
}

const Table = React.forwardRef<HTMLTableElement, TableProps>(function Table(
{'aria-labelledby': labelledby, cellPadding = 'normal', gridTemplateColumns, ...rest},
{'aria-labelledby': labelledby, cellPadding = 'normal', className, gridTemplateColumns, ...rest},
ref,
) {
return (
Expand All @@ -191,7 +262,7 @@ const Table = React.forwardRef<HTMLTableElement, TableProps>(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}
Expand Down Expand Up @@ -307,12 +378,12 @@ export type TableCellProps = React.ComponentPropsWithoutRef<'td'> & {
scope?: 'row'
}

function TableCell({children, scope, ...rest}: TableCellProps) {
function TableCell({children, className, scope, ...rest}: TableCellProps) {
const BaseComponent = scope ? 'th' : 'td'
const role = scope ? 'rowheader' : 'cell'

return (
<BaseComponent {...rest} className="TableCell" scope={scope} role={role}>
<BaseComponent {...rest} className={cx('TableCell', className)} scope={scope} role={role}>
{children}
</BaseComponent>
)
Expand Down Expand Up @@ -473,6 +544,66 @@ function TableActions({children}: TableActionsProps) {
return <div className="TableActions">{children}</div>
}

// ----------------------------------------------------------------------------
// TableSkeleton
// ----------------------------------------------------------------------------
export type TableSkeletonProps<Data extends UniqueRow> = 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<Column<Data>>

/**
* Optionally specify the number of rows which should be included in the
* skeleton state of the component
*/
rows?: number
}

function TableSkeleton<Data extends UniqueRow>({cellPadding, columns, rows = 10, ...rest}: TableSkeletonProps<Data>) {
const {gridTemplateColumns} = useTableLayout(columns)
return (
<Table {...rest} cellPadding={cellPadding} gridTemplateColumns={gridTemplateColumns}>
<TableHead>
<TableRow>
{Array.isArray(columns)
? columns.map((column, i) => {
return (
<TableHeader key={i}>
{typeof column.header === 'string' ? column.header : column.header()}
</TableHeader>
)
})
: null}
</TableRow>
</TableHead>
<TableBody>
<TableRow>
{Array.from({length: columns.length}).map((_, i) => {
return (
<TableCell key={i} className="TableCellSkeleton">
<VisuallyHidden>Loading</VisuallyHidden>
<div className="TableCellSkeletonItems">
{Array.from({length: rows}).map((_, i) => {
return <div key={i} className="TableCellSkeletonItem" />
})}
</div>
</TableCell>
)
})}
</TableRow>
</TableBody>
</Table>
)
}

// ----------------------------------------------------------------------------
// Utilities
// ----------------------------------------------------------------------------
Expand Down Expand Up @@ -535,4 +666,5 @@ export {
TableHeader,
TableSortHeader,
TableCell,
TableSkeleton,
}
Loading