From cd3d7dad3de588273a34b95e66478548570de689 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 1 Oct 2026 18:11:19 -0600 Subject: [PATCH 1/5] feat(mosaic): add a skeleton loading state for the user profile API keys panel Co-Authored-By: Claude Opus 5.5 --- .changeset/api-keys-panel-skeleton.md | 2 + .../src/components/table/table.styles.ts | 6 ++ .../mosaic/src/components/table/table.tsx | 65 ++++++++++++++----- .../user-profile-api-keys-panel.skeleton.tsx | 63 ++++++++++++++++++ .../user-profile-api-keys-panel.styles.ts | 11 +++- packages/swingset/src/stories/table.mdx | 29 +++++---- .../stories/user-profile-api-keys-panel.mdx | 6 ++ .../user-profile-api-keys-panel.stories.tsx | 43 ++++++++++++ 8 files changed, 195 insertions(+), 30 deletions(-) create mode 100644 .changeset/api-keys-panel-skeleton.md create mode 100644 packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.skeleton.tsx 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..79db7200d17 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: space['16'], + }, 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: space['20'], + }, selectCell: { lineHeight: 0, paddingInlineEnd: 0, diff --git a/packages/mosaic/src/components/table/table.tsx b/packages/mosaic/src/components/table/table.tsx index eb645f30056..05608be439f 100644 --- a/packages/mosaic/src/components/table/table.tsx +++ b/packages/mosaic/src/components/table/table.tsx @@ -2,10 +2,12 @@ import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; +import { useSkeletonWave } from '../../hooks/useSkeletonWave'; import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps, MosaicElementProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; +import { skeletonStyles } from '../../utils/skeleton.styles'; import { Button } from '../button'; import type { CheckboxProps } from '../checkbox'; import { Checkbox } from '../checkbox'; @@ -23,7 +25,7 @@ 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,12 +96,21 @@ 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 ( -
+
, ' align?: TableAlign; sort?: TableSort; onSort?: () => void; + skeleton?: boolean; } const sortIcons = { @@ -179,10 +191,11 @@ 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 = false, children, render, xstyle, ...rest }, ref, ) { - const sortable = onSort !== undefined; + const wave = useSkeletonWave(skeleton); + const sortable = onSort !== undefined && !skeleton; return useRender({ defaultTagName: 'th', render, @@ -190,12 +203,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 ? ( + {loading ? : } +
+ ); +} From 51c30de2c3fe8862dbd399bd10b9a161d0e6631b Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 2 Oct 2026 11:35:06 -0600 Subject: [PATCH 2/5] fix(mosaic): size API keys skeleton bones in ch and drop the name cell wrapper Co-Authored-By: Claude Opus 5.5 --- .../mosaic/src/components/table/table.styles.ts | 4 ++-- .../user-profile-api-keys-panel.skeleton.tsx | 16 ++++++++++------ .../user-profile-api-keys-panel.styles.ts | 5 +++-- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/mosaic/src/components/table/table.styles.ts b/packages/mosaic/src/components/table/table.styles.ts index 79db7200d17..a6c711862e9 100644 --- a/packages/mosaic/src/components/table/table.styles.ts +++ b/packages/mosaic/src/components/table/table.styles.ts @@ -52,7 +52,7 @@ export const styles = stylex.create({ whiteSpace: 'nowrap', }, headerCellSkeleton: { - width: space['16'], + width: '8ch', }, sortableHeaderCell: { paddingBlock: space['1.5'], @@ -71,7 +71,7 @@ export const styles = stylex.create({ verticalAlign: 'middle', }, cellSkeleton: { - width: space['20'], + width: '11ch', }, selectCell: { lineHeight: 0, 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 index 56327cdff6b..f8b18be6ca5 100644 --- 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 @@ -35,10 +35,14 @@ export function UserProfileApiKeysPanelSkeleton() { {SKELETON_ROWS.map(row => ( -
- - -
+ +
@@ -51,13 +55,13 @@ export function UserProfileApiKeysPanelSkeleton() { ); } -function Bone({ xstyle }: { xstyle: stylex.StyleXStyles }) { +function Bone({ line = false, xstyle }: { line?: boolean; xstyle: stylex.StyleXStyles }) { const wave = useSkeletonWave(true); return ( ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.styles.ts index b3159c921b0..f30f596e14b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.styles.ts @@ -7,11 +7,12 @@ export const styles = stylex.create({ metadata: { gap: space['0.5'], display: 'flex', flexDirection: 'column', minWidth: '25ch' }, searchSkeleton: { height: space['8'], maxWidth: '100%', width: '17rem' }, createSkeleton: { height: space['8'], width: space['28'] }, - nameSkeleton: { width: space['32'] }, + nameSkeleton: { width: '12ch' }, metadataSkeleton: { fontSize: typeScaleVars['--cl-text-xs-size'], lineHeight: typeScaleVars['--cl-text-xs-leading'], - width: space['40'], + marginBlockStart: space['0.5'], + width: '20ch', }, actionsSkeleton: { width: `calc(${space['7']} + 2 * ${space['4']})` }, }); From 380ccc04857a0899ce8ec470351fe00f94c3efee Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 2 Oct 2026 12:16:51 -0600 Subject: [PATCH 3/5] feat(mosaic): render one API keys skeleton row per page size Co-Authored-By: Claude Opus 5.5 --- .../user-profile/user-profile-api-keys-panel.skeleton.tsx | 7 ++++--- .../swingset/src/stories/user-profile-api-keys-panel.mdx | 2 +- .../src/stories/user-profile-api-keys-panel.stories.tsx | 6 +++++- 3 files changed, 10 insertions(+), 5 deletions(-) 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 index f8b18be6ca5..007525fe85d 100644 --- 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 @@ -8,10 +8,11 @@ import { useMessages } from '../../localization'; import { themeProps } from '../../props'; import { skeletonStyles } from '../../utils/skeleton.styles'; import { styles } from './user-profile-api-keys-panel.styles'; +import type { UserProfileApiKeysPanelViewProps } from './user-profile-api-keys-panel.types'; -const SKELETON_ROWS = [0, 1, 2]; +export type UserProfileApiKeysPanelSkeletonProps = Pick; -export function UserProfileApiKeysPanelSkeleton() { +export function UserProfileApiKeysPanelSkeleton({ pageSize = 10 }: UserProfileApiKeysPanelSkeletonProps) { const m = useMessages('userProfileApiKeysPanel'); return ( @@ -32,7 +33,7 @@ export function UserProfileApiKeysPanelSkeleton() {
- {SKELETON_ROWS.map(row => ( + {Array.from({ length: pageSize }, (_, row) => ( 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 b7c8ed9d42e..9b7f4b5aada 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 @@ -79,7 +79,11 @@ export function Loading() { > Reload - {loading ? : } + {loading ? ( + + ) : ( + + )}
); } From bf94356237856d3cc6fbd3308eab52840c8fe4f8 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 2 Oct 2026 17:13:39 -0600 Subject: [PATCH 4/5] refactor(mosaic): render the API keys skeleton from the real table view Co-Authored-By: Claude Opus 5.5 --- .../src/components/table/table.test.tsx | 53 ++++ .../mosaic/src/components/table/table.tsx | 46 +++- .../__tests__/api-keys-table.view.test.tsx | 99 ++++++++ .../api-keys/api-keys-table.skeleton.tsx | 75 ++---- .../api-keys/api-keys-table.styles.ts | 1 - .../features/api-keys/api-keys-table.types.ts | 2 + .../features/api-keys/api-keys-table.view.tsx | 230 ++++++++++++------ .../user-profile-api-keys-panel.skeleton.tsx | 5 +- .../src/stories/fixtures/api-keys-table.tsx | 24 +- .../stories/user-profile-api-keys-panel.mdx | 8 +- .../user-profile-api-keys-panel.stories.tsx | 12 +- 11 files changed, 397 insertions(+), 158 deletions(-) create mode 100644 packages/mosaic/src/features/api-keys/__tests__/api-keys-table.view.test.tsx 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 8488977c5dd..8a3a74252a7 100644 --- a/packages/mosaic/src/components/table/table.tsx +++ b/packages/mosaic/src/components/table/table.tsx @@ -1,3 +1,4 @@ +import { inertProps } from '@clerk/shared/inert'; import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -21,6 +22,13 @@ 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'; @@ -102,24 +110,26 @@ const Root = React.forwardRef(function MosaicTable ) { return (
- + +
+ ); @@ -140,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'> { @@ -191,9 +209,10 @@ const sortIcons = { } as const; const HeaderCell = React.forwardRef(function MosaicTableHeaderCell( - { align = 'start', sort = 'none', onSort, skeleton = false, children, render, xstyle, ...rest }, + { align = 'start', sort = 'none', onSort, skeleton: skeletonProp, children, render, xstyle, ...rest }, ref, ) { + const skeleton = useInheritedSkeleton(skeletonProp); const wave = useSkeletonWave(skeleton); const sortable = onSort !== undefined && !skeleton; return useRender({ @@ -247,9 +266,10 @@ export interface TableCellProps extends Omit, 'align' } const Cell = React.forwardRef(function MosaicTableCell( - { align = 'start', noWrap = false, skeleton = false, children, render, xstyle, ...rest }, + { align = 'start', noWrap = false, skeleton: skeletonProp, children, render, xstyle, ...rest }, ref, ) { + const skeleton = useInheritedSkeleton(skeletonProp); const wave = useSkeletonWave(skeleton); return useRender({ defaultTagName: 'td', diff --git a/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.view.test.tsx b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.view.test.tsx new file mode 100644 index 00000000000..fd8342f452f --- /dev/null +++ b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.view.test.tsx @@ -0,0 +1,99 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { apiKeysTableMessages, resolveAPIKeysTableMessages } from '../api-keys-table.messages'; +import { APIKeysTableSkeleton } from '../api-keys-table.skeleton'; +import type { APIKey, APIKeysTableViewProps } from '../api-keys-table.types'; +import { APIKeysTableView } from '../api-keys-table.view'; + +const messages = resolveAPIKeysTableMessages(apiKeysTableMessages, 'user'); + +const keys = (count: number, start = 1): APIKey[] => + Array.from({ length: count }, (_, index) => ({ + id: `ak_${start + index}`, + name: `Key ${start + index}`, + createdAtLabel: 'Jan 5, 2026', + expiresAtLabel: null, + lastUsedAtLabel: null, + })); + +function props(overrides: Partial = {}): APIKeysTableViewProps { + return { + messages, + apiKeys: keys(10), + totalCount: 12, + page: 1, + searchValue: '', + isLoading: false, + onPageChange: vi.fn(), + onSearchChange: vi.fn(), + refetchSkeleton: true, + ...overrides, + }; +} + +const bodyRows = (container: HTMLElement) => container.querySelectorAll('tbody tr'); + +describe('APIKeysTableView skeleton', () => { + it('renders the first load as three placeholder rows behind a loading status', () => { + const { container } = render(); + + expect(screen.getByRole('status')).toHaveTextContent(messages.loading); + expect(screen.queryByRole('table')).toBeNull(); + expect(screen.queryByRole('searchbox')).toBeNull(); + expect(screen.queryByRole('button')).toBeNull(); + expect(bodyRows(container)).toHaveLength(3); + expect(container.querySelector('tbody')).not.toHaveTextContent('API key'); + }); + + it('shows exactly the rows of the next page while it loads', async () => { + const view = props(); + const { container, rerender } = render(); + + await userEvent.click(screen.getByRole('button', { name: messages.nextPage })); + expect(view.onPageChange).toHaveBeenCalledWith(2); + rerender( + , + ); + + expect(bodyRows(container)).toHaveLength(2); + expect(container.querySelector('tbody')).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByRole('columnheader', { name: messages.name })).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent(messages.loading); + }); + + it('shows three rows while a new search loads', async () => { + const view = props(); + const { container, rerender } = render(); + + await userEvent.type(screen.getByRole('searchbox', { name: messages.search }), 'k'); + expect(view.onSearchChange).toHaveBeenCalledWith('k'); + rerender( + , + ); + + expect(bodyRows(container)).toHaveLength(3); + }); + + it('keeps the current rows while fetching without refetchSkeleton', () => { + render( + , + ); + + expect(within(screen.getByRole('table')).getByText('Key 1')).toBeInTheDocument(); + expect(screen.queryByRole('status')).toBeNull(); + }); +}); diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.skeleton.tsx b/packages/mosaic/src/features/api-keys/api-keys-table.skeleton.tsx index 41e72c5d1dc..6379e1e8cc9 100644 --- a/packages/mosaic/src/features/api-keys/api-keys-table.skeleton.tsx +++ b/packages/mosaic/src/features/api-keys/api-keys-table.skeleton.tsx @@ -1,66 +1,27 @@ -import * as stylex from '@stylexjs/stylex'; - -import { Table } from '../../components/table'; -import { VisuallyHidden } from '../../components/visually-hidden'; -import { useSkeletonWave } from '../../hooks/use-skeleton-wave'; import { useMessages } from '../../localization'; -import { mergeStyleProps, themeProps } from '../../props'; -import { skeletonStyles } from '../../styles/skeleton.styles'; -import { styles } from './api-keys-table.styles'; -import type { APIKeysTableViewProps } from './api-keys-table.types'; - -export type APIKeysTableSkeletonProps = Pick; +import { resolveAPIKeysTableMessages } from './api-keys-table.messages'; +import { APIKeysTableView, placeholderAPIKeys } from './api-keys-table.view'; -export function APIKeysTableSkeleton({ pageSize = 10 }: APIKeysTableSkeletonProps) { - const m = useMessages('apiKeysTable'); +const PLACEHOLDER_API_KEYS = placeholderAPIKeys(3); - return ( -
- {m.loading} - - - - - - - - - - - - - - - {Array.from({ length: pageSize }, (_, row) => ( - - - - - - - - - - ))} - - -
- ); -} +const noop = () => undefined; -function Bone({ line = false, xstyle }: { line?: boolean; xstyle: stylex.StyleXStyles }) { - const wave = useSkeletonWave(true); +export function APIKeysTableSkeleton() { + const messages = resolveAPIKeysTableMessages(useMessages('apiKeysTable'), 'user'); return ( - Promise.resolve()} /> ); } diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.styles.ts b/packages/mosaic/src/features/api-keys/api-keys-table.styles.ts index ff7927dd5a8..db5413837a8 100644 --- a/packages/mosaic/src/features/api-keys/api-keys-table.styles.ts +++ b/packages/mosaic/src/features/api-keys/api-keys-table.styles.ts @@ -12,7 +12,6 @@ export const styles = stylex.create({ metadataSkeleton: { fontSize: typeScaleVars['--cl-text-xs-size'], lineHeight: typeScaleVars['--cl-text-xs-leading'], - marginBlockStart: space['0.5'], width: '20ch', }, actionsSkeleton: { width: `calc(${space['7']} + 2 * ${space['4']})` }, diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.types.ts b/packages/mosaic/src/features/api-keys/api-keys-table.types.ts index 1eedeb5181e..4cc65d48473 100644 --- a/packages/mosaic/src/features/api-keys/api-keys-table.types.ts +++ b/packages/mosaic/src/features/api-keys/api-keys-table.types.ts @@ -44,4 +44,6 @@ export interface APIKeysTableViewProps { onBulkAction?: (ids: string[]) => void; sort?: APIKeysTableSort | null; onSortChange?: (sort: APIKeysTableSort | null) => void; + skeleton?: boolean; + refetchSkeleton?: boolean; } diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.view.tsx b/packages/mosaic/src/features/api-keys/api-keys-table.view.tsx index ba16708c493..b629c27354e 100644 --- a/packages/mosaic/src/features/api-keys/api-keys-table.view.tsx +++ b/packages/mosaic/src/features/api-keys/api-keys-table.view.tsx @@ -12,9 +12,11 @@ import { Table } from '../../components/table'; import { Text } from '../../components/text'; import { VisuallyHidden } from '../../components/visually-hidden'; import { useListRemovalFocus } from '../../hooks/use-list-removal-focus'; +import { useSkeletonWave } from '../../hooks/use-skeleton-wave'; import { fill } from '../../localization'; import { useDataTable } from '../../primitives/hooks'; import { mergeStyleProps, themeProps } from '../../props'; +import { skeletonStyles } from '../../styles/skeleton.styles'; import { truncateWithEndVisible } from '../../utils/truncate-text-with-end-visible'; import { styles } from './api-keys-table.styles'; import type { APIKey, APIKeysTableMessages, APIKeysTableSort, APIKeysTableViewProps } from './api-keys-table.types'; @@ -22,6 +24,18 @@ import { CreateAPIKeyDialog } from './create-api-key.dialog'; const getRowId = (row: APIKey) => row.id; +const PLACEHOLDER_ROW_COUNT = 3; + +export function placeholderAPIKeys(count: number): APIKey[] { + return Array.from({ length: count }, (_, index) => ({ + id: `placeholder_${index}`, + name: 'API key', + createdAtLabel: '', + expiresAtLabel: null, + lastUsedAtLabel: null, + })); +} + export function APIKeysTableView({ messages: m, apiKeys, @@ -42,7 +56,10 @@ export function APIKeysTableView({ isFetching = false, isError = false, onRetry, + skeleton = false, + refetchSkeleton = false, }: APIKeysTableViewProps) { + const lastChange = useRef<'page' | 'search'>('page'); const searchInput = useRef(null); const createButton = useRef(null); const removalFocus = useListRemovalFocus({ @@ -61,6 +78,7 @@ export function APIKeysTableView({ ? update => { const next = typeof update === 'function' ? update(table.sorting) : update; const active = next[0]; + lastChange.current = 'page'; table.setRowSelection({}); onSortChange( active && (active.id === 'name' || active.id === 'createdAt' || active.id === 'lastUsed') @@ -72,6 +90,7 @@ export function APIKeysTableView({ pagination, onPaginationChange: update => { const next = typeof update === 'function' ? update(pagination) : update; + lastChange.current = 'page'; table.setRowSelection({}); if (next.pageSize !== pageSize) { onPageSizeChange?.(next.pageSize); @@ -80,6 +99,7 @@ export function APIKeysTableView({ }, globalFilter: searchValue, onGlobalFilterChange: update => { + lastChange.current = 'search'; table.setRowSelection({}); onSearchChange(typeof update === 'function' ? update(searchValue) : update); }, @@ -101,31 +121,119 @@ export function APIKeysTableView({ }; }; const columnCount = 3 + Number(Boolean(onRevoke)) + Number(Boolean(onBulkAction)); + const rowsSkeleton = refetchSkeleton && isFetching && !skeleton && !isLoading; + const remaining = totalCount - (page - 1) * pageSize; + const placeholderRows = placeholderAPIKeys( + lastChange.current === 'page' && remaining > 0 ? Math.min(pageSize, remaining) : PLACEHOLDER_ROW_COUNT, + ); const query = searchValue.trim(); + const bones = skeleton || rowsSkeleton; + const renderRow = (apiKey: APIKey, row?: (typeof table.rows)[number]) => ( + + {onBulkAction ? ( + + ) : null} + +
+ {bones ? ( + <> + + + + ) : ( + <> + {apiKey.name} + + {truncateWithEndVisible(apiKey.id, 10, 4)} ·{' '} + } + size='xs' + color={apiKey.expiresAtLabel === null ? 'foreground-secondary' : 'warning'} + > + {apiKey.expiresAtLabel === null + ? m.neverExpires + : fill(m.expires, { + expiresDate: apiKey.expiresAtLabel, + })} + + + + )} +
+
+ + {apiKey.createdAtLabel} + + + {apiKey.lastUsedAtLabel ?? m.neverUsed} + + {onRevoke ? ( + + {bones ? null : ( + revokeKey.open(key)} + /> + )} + + ) : null} +
+ ); const emptyState = query ? { label: m.empty, description: fill(m.emptyDescription, { query }) } : { label: m.noKeys, description: m.noKeysDescription }; return ( <> -
- - - {onCreate ? ( - - ) : 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 index 3c4f66cc92e..9e820719089 100644 --- 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 @@ -1,13 +1,12 @@ import { Panel } from '../../components/panel'; import { themeProps } from '../../props'; -import type { APIKeysTableSkeletonProps } from '../api-keys/api-keys-table.skeleton'; import { APIKeysTableSkeleton } from '../api-keys/api-keys-table.skeleton'; -export function UserProfileApiKeysPanelSkeleton({ pageSize }: APIKeysTableSkeletonProps) { +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/user-profile-api-keys-panel.mdx b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx index 59a2c146c26..64dcf5c1286 100644 --- a/packages/swingset/src/stories/user-profile-api-keys-panel.mdx +++ b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx @@ -23,6 +23,12 @@ The API keys panel lists the keys that belong to the signed-in user under the pa ### Loading -`UserProfileApiKeysPanelSkeleton` renders the panel's first load: the title, toolbar, column headers, and one row per `pageSize` (default 10) as placeholders. It composes `APIKeysTableSkeleton`. When to show it, and for how long, is up to the caller. +`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 871156747a2..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 @@ -62,7 +62,17 @@ export function Loading() { > Reload - {loading ? : } + {loading ? : }
); } + +export function PageChange() { + const props = useAPIKeysTableFixture({ fetchDelay: 2000 }); + return ( + + ); +} From c679d5c261a8171022a7beaa9b1abf507f4a98b9 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 2 Oct 2026 17:13:59 -0600 Subject: [PATCH 5/5] docs(mosaic): document inherited table skeletons Co-Authored-By: Claude Opus 5.5 --- packages/swingset/src/stories/table.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/swingset/src/stories/table.mdx b/packages/swingset/src/stories/table.mdx index 0507d178fb2..2d016f1b549 100644 --- a/packages/swingset/src/stories/table.mdx +++ b/packages/swingset/src/stories/table.mdx @@ -57,7 +57,7 @@ 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`, `Table.HeaderCell`, and `Table.Cell` take `skeleton` for a loading state. A skeleton cell renders a placeholder line in place of its children, and a skeleton `Table.Root` is hidden from assistive technology, so pair it with a visually hidden status message. Placeholders pulse in the same wave as the [Section](/components/section) skeletons. +- `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 @@ -72,7 +72,7 @@ Each part carries a stable `.cl-*` class, and the parts with state reflect it as | `.cl-table-toolbar` | `Table.Toolbar` | | | `.cl-table-search` | `Table.Search` input group | | | `.cl-table-header` | `Table.Header` | | -| `.cl-table-body` | `Table.Body` | | +| `.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` |