diff --git a/.changeset/api-keys-panel-skeleton.md b/.changeset/api-keys-panel-skeleton.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/api-keys-panel-skeleton.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/table/table.styles.ts b/packages/mosaic/src/components/table/table.styles.ts index ed80339534b..a6c711862e9 100644 --- a/packages/mosaic/src/components/table/table.styles.ts +++ b/packages/mosaic/src/components/table/table.styles.ts @@ -51,6 +51,9 @@ export const styles = stylex.create({ verticalAlign: 'middle', whiteSpace: 'nowrap', }, + headerCellSkeleton: { + width: '8ch', + }, sortableHeaderCell: { paddingBlock: space['1.5'], paddingInline: space['2'], @@ -67,6 +70,9 @@ export const styles = stylex.create({ lineHeight: typeScaleVars['--cl-text-sm-leading'], verticalAlign: 'middle', }, + cellSkeleton: { + width: '11ch', + }, selectCell: { lineHeight: 0, paddingInlineEnd: 0, diff --git a/packages/mosaic/src/components/table/table.test.tsx b/packages/mosaic/src/components/table/table.test.tsx index 04a40af9ca5..4012443a20b 100644 --- a/packages/mosaic/src/components/table/table.test.tsx +++ b/packages/mosaic/src/components/table/table.test.tsx @@ -321,4 +321,57 @@ describe('Mosaic Table', () => { expect(screen.getByRole('cell')).toHaveAttribute('colspan', '1000'); }); }); + + describe('skeleton', () => { + it('hides a skeleton table and turns its cells into placeholders', () => { + const { container } = render( + + + + Name + Actions + + + + + Web app + Menu + + + , + ); + + const shell = container.querySelector('.cl-table-shell'); + expect(shell).toHaveAttribute('aria-hidden', 'true'); + expect(shell).toHaveAttribute('inert'); + expect(shell).not.toHaveTextContent('Name'); + expect(shell).not.toHaveTextContent('Web app'); + expect(shell).toHaveTextContent('Actions'); + expect(shell).toHaveTextContent('Menu'); + expect(container.querySelectorAll('th[data-skeleton], td[data-skeleton]')).toHaveLength(2); + }); + + it('turns only the body into placeholders when the body is a skeleton', () => { + const { container } = render( + + + + Name + + + + + Web app + + + , + ); + + expect(screen.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument(); + const body = container.querySelector('tbody'); + expect(body).toHaveAttribute('aria-hidden', 'true'); + expect(body).toHaveAttribute('inert'); + expect(body).not.toHaveTextContent('Web app'); + }); + }); }); diff --git a/packages/mosaic/src/components/table/table.tsx b/packages/mosaic/src/components/table/table.tsx index f13a8e56ac6..8a3a74252a7 100644 --- a/packages/mosaic/src/components/table/table.tsx +++ b/packages/mosaic/src/components/table/table.tsx @@ -1,11 +1,14 @@ +import { inertProps } from '@clerk/shared/inert'; import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; +import { useSkeletonWave } from '../../hooks/use-skeleton-wave'; import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps, MosaicElementProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../styles/reset.styles'; +import { skeletonStyles } from '../../styles/skeleton.styles'; import { Button } from '../button'; import type { CheckboxProps } from '../checkbox'; import { Checkbox } from '../checkbox'; @@ -19,11 +22,18 @@ type TableSection = 'header' | 'body'; const TableSectionContext = React.createContext('body'); +const TableSkeletonContext = React.createContext(false); + +function useInheritedSkeleton(skeleton: boolean | undefined) { + const inherited = React.useContext(TableSkeletonContext); + return skeleton ?? inherited; +} + export type TableAlign = keyof typeof aligns; export type TableSort = 'ascending' | 'descending' | 'none'; -export type TableProps = MosaicElementProps<'table'>; +export type TableProps = MosaicElementProps<'table'> & { skeleton?: boolean }; export type TableToolbarProps = MosaicComponentProps<'div'>; @@ -94,9 +104,18 @@ const Search = React.forwardRef(function Mos ); }); -const Root = React.forwardRef(function MosaicTable({ xstyle, ...rest }, ref) { +const Root = React.forwardRef(function MosaicTable( + { skeleton = false, xstyle, ...rest }, + ref, +) { return ( -
+
(function MosaicTable stylex.props(reset.base, scrollAreaViewport('auto', 'inline'), styles.viewport), )} > - + +
+ ); @@ -129,19 +150,27 @@ const Header = React.forwardRef(funct return {element}; }); -export type TableBodyProps = MosaicComponentProps<'tbody'>; +export type TableBodyProps = MosaicComponentProps<'tbody'> & { skeleton?: boolean }; const Body = React.forwardRef(function MosaicTableBody( - { render, xstyle, ...rest }, + { skeleton: skeletonProp, render, xstyle, ...rest }, ref, ) { + const skeleton = useInheritedSkeleton(skeletonProp); const element = useRender({ defaultTagName: 'tbody', render, ref, - props: mergeStyleProps(themeProps('table-body'), stylex.props(reset.base, xstyle), rest), + props: { + ...(skeletonProp ? { 'aria-hidden': true, ...inertProps(true) } : {}), + ...mergeStyleProps(themeProps('table-body', { skeleton }), stylex.props(reset.base, xstyle), rest), + }, }); - return {element}; + return ( + + {element} + + ); }); export interface TableRowProps extends MosaicComponentProps<'tr'> { @@ -170,6 +199,7 @@ export interface TableHeaderCellProps extends Omit, ' align?: TableAlign; sort?: TableSort; onSort?: () => void; + skeleton?: boolean; } const sortIcons = { @@ -179,10 +209,12 @@ const sortIcons = { } as const; const HeaderCell = React.forwardRef(function MosaicTableHeaderCell( - { align = 'start', sort = 'none', onSort, children, render, xstyle, ...rest }, + { align = 'start', sort = 'none', onSort, skeleton: skeletonProp, children, render, xstyle, ...rest }, ref, ) { - const sortable = onSort !== undefined; + const skeleton = useInheritedSkeleton(skeletonProp); + const wave = useSkeletonWave(skeleton); + const sortable = onSort !== undefined && !skeleton; return useRender({ defaultTagName: 'th', render, @@ -190,12 +222,22 @@ const HeaderCell = React.forwardRef( props: { scope: 'col', ...mergeStyleProps( - themeProps('table-header-cell', { align, sortable, sort: sortable && sort !== 'none' ? sort : undefined }), + themeProps('table-header-cell', { + align, + sortable, + sort: sortable && sort !== 'none' ? sort : undefined, + skeleton, + }), stylex.props(reset.base, styles.headerCell, aligns[align], sortable && styles.sortableHeaderCell, xstyle), { 'aria-sort': sortable && sort !== 'none' ? sort : undefined }, rest, ), - children: sortable ? ( + children: skeleton ? ( + + ) : sortable ? ( - ) : null} - +
+ {skeleton || rowsSkeleton ? {m.loading} : null} + {skeleton ? ( + + + {onCreate ? : null} + + ) : ( + + + {onCreate ? ( + + ) : null} + + )} @@ -143,22 +251,26 @@ export function APIKeysTableView({ {m.createdAt} {m.lastUsed} {onRevoke ? ( - + {m.actions} ) : null} - - {/* TODO: Replace with a shared Table.Loading built on a Mosaic Skeleton component (skeleton rows sized to the columns). */} - {isLoading ? ( + + {rowsSkeleton ? ( + placeholderRows.map(apiKey => renderRow(apiKey)) + ) : isLoading && !skeleton ? ( {m.loading} - ) : isError ? ( + ) : isError && !skeleton ? ( @@ -177,7 +289,7 @@ export function APIKeysTableView({ ) : null} - ) : table.rows.length === 0 ? ( + ) : table.rows.length === 0 && !skeleton ? ( @@ -186,62 +298,11 @@ export function APIKeysTableView({ ) : ( - table.rows.map(row => ( - - {onBulkAction ? ( - - ) : null} - -
- {row.original.name} - - {truncateWithEndVisible(row.original.id, 10, 4)} ·{' '} - } - size='xs' - color={row.original.expiresAtLabel === null ? 'foreground-secondary' : 'warning'} - > - {row.original.expiresAtLabel === null - ? m.neverExpires - : fill(m.expires, { - expiresDate: row.original.expiresAtLabel, - })} - - -
-
- - {row.original.createdAtLabel} - - - {row.original.lastUsedAtLabel ?? m.neverUsed} - - {onRevoke ? ( - - revokeKey.open(apiKey)} - /> - - ) : null} -
- )) + table.rows.map(row => renderRow(row.original, row)) )}
- {table.getPageCount() > 1 || (totalCount > 0 && onPageSizeChange) ? ( + {!skeleton && (table.getPageCount() > 1 || (totalCount > 0 && onPageSizeChange)) ? ( ) : null}
- {createDialog ? : null} - {onRevoke ? ( + {createDialog && !skeleton ? : null} + {onRevoke && !skeleton ? ( fill(m.revokeTitle, { name: apiKey.name })} @@ -281,6 +342,17 @@ export function APIKeysTableView({ ); } +function Bone({ line = false, xstyle }: { line?: boolean; xstyle: stylex.StyleXStyles }) { + const wave = useSkeletonWave(true); + + return ( + + ); +} + function APIKeyActions({ messages: m, apiKey, diff --git a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.skeleton.tsx b/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.skeleton.tsx new file mode 100644 index 00000000000..9e820719089 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.skeleton.tsx @@ -0,0 +1,12 @@ +import { Panel } from '../../components/panel'; +import { themeProps } from '../../props'; +import { APIKeysTableSkeleton } from '../api-keys/api-keys-table.skeleton'; + +export function UserProfileApiKeysPanelSkeleton() { + return ( + }> + + + + ); +} diff --git a/packages/swingset/src/stories/fixtures/api-keys-table.tsx b/packages/swingset/src/stories/fixtures/api-keys-table.tsx index 4d13749693c..248433e29a4 100644 --- a/packages/swingset/src/stories/fixtures/api-keys-table.tsx +++ b/packages/swingset/src/stories/fixtures/api-keys-table.tsx @@ -79,10 +79,12 @@ export function useAPIKeysTableFixture({ initialKeys = exampleAPIKeys, enableSorting = false, subjectKind = 'user', + fetchDelay = 0, }: { initialKeys?: FixtureAPIKey[]; enableSorting?: boolean; subjectKind?: APIKeysTableSubjectKind; + fetchDelay?: number; } = {}): APIKeysTableViewProps { const locale = useLocale(); const messages = resolveAPIKeysTableMessages(useMessages('apiKeysTable'), subjectKind); @@ -134,8 +136,23 @@ export function useAPIKeysTableFixture({ return () => clearTimeout(timer); }, [searchValue, query]); + const requested = { page, query, sort, pageSize }; + const [settled, setSettled] = useState(requested); + const isFetching = + fetchDelay > 0 && + (settled.page !== page || settled.query !== query || settled.sort !== sort || settled.pageSize !== pageSize); + useEffect(() => { + if (!isFetching) { + return; + } + const timer = setTimeout(() => setSettled({ page, query, sort, pageSize }), fetchDelay); + return () => clearTimeout(timer); + }, [isFetching, page, query, sort, pageSize, fetchDelay]); + const shown = fetchDelay > 0 ? settled : requested; + const filtered = items.filter(item => item.name.toLowerCase().includes(query.toLowerCase())); - const sorted = sortAPIKeys(filtered, sort); + const shownFiltered = items.filter(item => item.name.toLowerCase().includes(shown.query.toLowerCase())); + const sorted = sortAPIKeys(shownFiltered, shown.sort); const pageCount = Math.ceil(filtered.length / pageSize); useEffect(() => { if (page > Math.max(1, pageCount)) { @@ -145,7 +162,7 @@ export function useAPIKeysTableFixture({ return { messages, - apiKeys: sorted.slice((page - 1) * pageSize, page * pageSize).map(item => ({ + apiKeys: sorted.slice((shown.page - 1) * shown.pageSize, shown.page * shown.pageSize).map(item => ({ id: item.id, name: item.name, createdAtLabel: dateLabel(item.createdAt), @@ -155,11 +172,12 @@ export function useAPIKeysTableFixture({ ? null : relativeTime.format(Math.round((item.lastUsedAt - exampleTime) / 60_000), 'minute'), })), - totalCount: filtered.length, + totalCount: shownFiltered.length, page, pageSize, searchValue, isLoading: false, + isFetching, onCreate: create.onOpen, createDialog: create.dialog, onSearchChange: setSearchValue, diff --git a/packages/swingset/src/stories/table.mdx b/packages/swingset/src/stories/table.mdx index 38dbdae184c..2d016f1b549 100644 --- a/packages/swingset/src/stories/table.mdx +++ b/packages/swingset/src/stories/table.mdx @@ -57,26 +57,27 @@ import { Table } from '@clerk/mosaic/components/table'; - `Table.HeaderCell` renders a `
`. Pass `onSort` to make it sortable: the label becomes a button with a direction icon, and `sort` (`'ascending'`, `'descending'`, or `'none'`) reflects the current direction as `aria-sort`. `align` moves the content to `'center'` or `'end'`. - `Table.Cell` renders a `` with the same `align` prop. `noWrap` defaults to `false`; enable it to keep dates and other short values on one line. - `Table.SelectAllCell` and `Table.SelectCell` render a narrow header or body cell holding a [Checkbox](/components/checkbox). They take Checkbox props, so pass `checked`, `indeterminate`, and an `aria-label`. `Table.SelectCell` also takes `onToggleSelected`, called with `{ range: true }` when the checkbox was shift-clicked, which fits the row's `toggleSelected` from `useDataTable` directly. +- `Table.Root` and `Table.Body` take `skeleton` for a loading state. Every `Table.HeaderCell` and `Table.Cell` inside inherits it and renders a placeholder line in place of its children; pass `skeleton={false}` to opt a cell out, or `skeleton` to make one cell a placeholder on its own. A skeleton `Table.Root` or `Table.Body` is inert and hidden from assistive technology, so pair it with a visually hidden status message. A skeleton `Table.Body` leaves the header as it is, for a table loading another page. Placeholders pulse in the same wave as the [Section](/components/section) skeletons. - `Table.Empty` renders a full-width row with a single cell that spans every column. Put an [EmptyState](/components/empty-state) inside it. The cell uses the largest `colSpan` browsers allow, which stretches across the columns the table actually has; pass `colSpan` yourself if you need the exact count. ## Styling Each part carries a stable `.cl-*` class, and the parts with state reflect it as `data-*` attributes: -| Class | Part | Attributes | -| ----------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| `.cl-table-shell` | The bordered wrapper around the table | | -| `.cl-table-viewport` | The horizontally scrolling viewport | | -| `.cl-table` | `Table.Root` | | -| `.cl-table-toolbar` | `Table.Toolbar` | | -| `.cl-table-search` | `Table.Search` input group | | -| `.cl-table-header` | `Table.Header` | | -| `.cl-table-body` | `Table.Body` | | -| `.cl-table-row` | `Table.Row` | `data-selected` when `selected` | -| `.cl-table-header-cell` | `Table.HeaderCell` | `data-align` (`start` / `center` / `end`), `data-sortable` when `onSort` is set, `data-sort` (`ascending` / `descending`) | -| `.cl-table-cell` | `Table.Cell` | `data-align` (`start` / `center` / `end`), `data-no-wrap` when `noWrap` | -| `.cl-table-select-cell` | `Table.SelectAllCell`, `Table.SelectCell` | | -| `.cl-table-empty` | `Table.Empty` | | +| Class | Part | Attributes | +| ----------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `.cl-table-shell` | The bordered wrapper around the table | `data-skeleton` when `skeleton` | +| `.cl-table-viewport` | The horizontally scrolling viewport | | +| `.cl-table` | `Table.Root` | | +| `.cl-table-toolbar` | `Table.Toolbar` | | +| `.cl-table-search` | `Table.Search` input group | | +| `.cl-table-header` | `Table.Header` | | +| `.cl-table-body` | `Table.Body` | `data-skeleton` when `skeleton` | +| `.cl-table-row` | `Table.Row` | `data-selected` when `selected` | +| `.cl-table-header-cell` | `Table.HeaderCell` | `data-align` (`start` / `center` / `end`), `data-sortable` when `onSort` is set, `data-sort` (`ascending` / `descending`), `data-skeleton` when `skeleton` | +| `.cl-table-cell` | `Table.Cell` | `data-align` (`start` / `center` / `end`), `data-no-wrap` when `noWrap`, `data-skeleton` when `skeleton` | +| `.cl-table-select-cell` | `Table.SelectAllCell`, `Table.SelectCell` | | +| `.cl-table-empty` | `Table.Empty` | | ```css .cl-table-row[data-selected] { diff --git a/packages/swingset/src/stories/user-profile-api-keys-panel.mdx b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx index 54fd6002b8d..64dcf5c1286 100644 --- a/packages/swingset/src/stories/user-profile-api-keys-panel.mdx +++ b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx @@ -20,3 +20,15 @@ The API keys panel lists the keys that belong to the signed-in user under the pa ### Empty + +### Loading + +`UserProfileApiKeysPanelSkeleton` renders the panel's first load: the panel title and `APIKeysTableSkeleton`, which is `APIKeysTableView` with `skeleton` and three placeholder keys. When to show it, and for how long, is up to the caller. + + + +### Page change + +With `refetchSkeleton`, the table turns its rows into placeholders while `isFetching` is set, and keeps its headers and controls. After a page change or a sort it shows exactly as many rows as the next page holds; after a search it shows three. This example takes 2s to fetch a page; go to page 2. + + diff --git a/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx b/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx index c204e83adcc..9639efa8396 100644 --- a/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx @@ -1,9 +1,25 @@ +import { Button } from '@clerk/mosaic/components/button'; +import { UserProfileApiKeysPanelSkeleton } from '@clerk/mosaic/features/user-profile/user-profile-api-keys-panel.skeleton'; +import { space } from '@clerk/mosaic/tokens.stylex'; +import * as stylex from '@stylexjs/stylex'; +import { useEffect, useState } from 'react'; + import type { StoryMeta } from '@/lib/types'; import { APIKeysPanelExample, useAPIKeysTableFixture } from './fixtures/api-keys-table'; export { default as __source } from './user-profile-api-keys-panel.stories?raw'; +const styles = stylex.create({ + loadingStack: { + alignItems: 'flex-start', + display: 'flex', + flexDirection: 'column', + gap: space['4'], + width: '100%', + }, +}); + export const meta: StoryMeta = { group: 'User Profile', status: 'wip', @@ -22,3 +38,41 @@ export function Empty() { const props = useAPIKeysTableFixture({ initialKeys: [] }); return ; } + +export function Loading() { + const props = useAPIKeysTableFixture(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!loading) { + return; + } + const timer = setTimeout(() => setLoading(false), 2000); + return () => clearTimeout(timer); + }, [loading]); + + return ( +
+ + {loading ? : } +
+ ); +} + +export function PageChange() { + const props = useAPIKeysTableFixture({ fetchDelay: 2000 }); + return ( + + ); +}