Skip to content
Open
2 changes: 2 additions & 0 deletions .changeset/api-keys-panel-skeleton.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
6 changes: 6 additions & 0 deletions packages/mosaic/src/components/table/table.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export const styles = stylex.create({
verticalAlign: 'middle',
whiteSpace: 'nowrap',
},
headerCellSkeleton: {
width: '8ch',
},
sortableHeaderCell: {
paddingBlock: space['1.5'],
paddingInline: space['2'],
Expand All @@ -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,
Expand Down
53 changes: 53 additions & 0 deletions packages/mosaic/src/components/table/table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Table.Root skeleton>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell skeleton={false}>Actions</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>Web app</Table.Cell>
<Table.Cell skeleton={false}>Menu</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>,
);

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(
<Table.Root>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Name</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body skeleton>
<Table.Row>
<Table.Cell>Web app</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>,
);

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');
});
});
});
97 changes: 76 additions & 21 deletions packages/mosaic/src/components/table/table.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,11 +22,18 @@ type TableSection = 'header' | 'body';

const TableSectionContext = React.createContext<TableSection>('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'>;

Expand Down Expand Up @@ -94,9 +104,18 @@ const Search = React.forwardRef<HTMLInputElement, TableSearchProps>(function Mos
);
});

const Root = React.forwardRef<HTMLTableElement, TableProps>(function MosaicTable({ xstyle, ...rest }, ref) {
const Root = React.forwardRef<HTMLTableElement, TableProps>(function MosaicTable(
{ skeleton = false, xstyle, ...rest },
ref,
) {
return (
<div {...mergeStyleProps(themeProps('table-shell'), stylex.props(reset.base, scrollAreaRoot, styles.shell))}>
<div
{...(skeleton ? { 'aria-hidden': true, ...inertProps(true) } : {})}
{...mergeStyleProps(
themeProps('table-shell', { skeleton }),
stylex.props(reset.base, scrollAreaRoot, styles.shell),
)}
>
<div
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Safari does not focus an overflowing scroll container on its own, and a table without sortable or selectable cells holds nothing focusable.
tabIndex={0}
Expand All @@ -105,10 +124,12 @@ const Root = React.forwardRef<HTMLTableElement, TableProps>(function MosaicTable
stylex.props(reset.base, scrollAreaViewport('auto', 'inline'), styles.viewport),
)}
>
<table
ref={ref}
{...mergeStyleProps(themeProps('table'), stylex.props(reset.base, styles.table, xstyle), rest)}
/>
<TableSkeletonContext.Provider value={skeleton}>
<table
ref={ref}
{...mergeStyleProps(themeProps('table'), stylex.props(reset.base, styles.table, xstyle), rest)}
/>
</TableSkeletonContext.Provider>
</div>
</div>
);
Expand All @@ -129,19 +150,27 @@ const Header = React.forwardRef<HTMLTableSectionElement, TableHeaderProps>(funct
return <TableSectionContext.Provider value='header'>{element}</TableSectionContext.Provider>;
});

export type TableBodyProps = MosaicComponentProps<'tbody'>;
export type TableBodyProps = MosaicComponentProps<'tbody'> & { skeleton?: boolean };

const Body = React.forwardRef<HTMLTableSectionElement, TableBodyProps>(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 <TableSectionContext.Provider value='body'>{element}</TableSectionContext.Provider>;
return (
<TableSectionContext.Provider value='body'>
<TableSkeletonContext.Provider value={skeleton}>{element}</TableSkeletonContext.Provider>
</TableSectionContext.Provider>
);
});

export interface TableRowProps extends MosaicComponentProps<'tr'> {
Expand Down Expand Up @@ -170,6 +199,7 @@ export interface TableHeaderCellProps extends Omit<MosaicComponentProps<'th'>, '
align?: TableAlign;
sort?: TableSort;
onSort?: () => void;
skeleton?: boolean;
}

const sortIcons = {
Expand All @@ -179,23 +209,35 @@ const sortIcons = {
} as const;

const HeaderCell = React.forwardRef<HTMLTableCellElement, TableHeaderCellProps>(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<HTMLSpanElement>(skeleton);
const sortable = onSort !== undefined && !skeleton;
return useRender({
defaultTagName: 'th',
render,
ref,
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 ? (
<span
ref={wave}
{...stylex.props(skeletonStyles.bone, skeletonStyles.wave, skeletonStyles.line, styles.headerCellSkeleton)}
/>
) : sortable ? (
<Button
variant='ghost'
color='neutral'
Expand All @@ -220,21 +262,34 @@ const HeaderCell = React.forwardRef<HTMLTableCellElement, TableHeaderCellProps>(
export interface TableCellProps extends Omit<MosaicComponentProps<'td'>, 'align'> {
align?: TableAlign;
noWrap?: boolean;
skeleton?: boolean;
}

const Cell = React.forwardRef<HTMLTableCellElement, TableCellProps>(function MosaicTableCell(
{ align = 'start', noWrap = false, render, xstyle, ...rest },
{ align = 'start', noWrap = false, skeleton: skeletonProp, children, render, xstyle, ...rest },
ref,
) {
const skeleton = useInheritedSkeleton(skeletonProp);
const wave = useSkeletonWave<HTMLSpanElement>(skeleton);
return useRender({
defaultTagName: 'td',
render,
ref,
props: mergeStyleProps(
themeProps('table-cell', { align, noWrap }),
stylex.props(reset.base, styles.cell, aligns[align], noWrap && styles.noWrap, xstyle),
rest,
),
props: {
...mergeStyleProps(
themeProps('table-cell', { align, noWrap, skeleton }),
stylex.props(reset.base, styles.cell, aligns[align], noWrap && styles.noWrap, xstyle),
rest,
),
children: skeleton ? (
<span
ref={wave}
{...stylex.props(skeletonStyles.bone, skeletonStyles.wave, skeletonStyles.line, styles.cellSkeleton)}
/>
) : (
children
),
},
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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> = {}): 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(<APIKeysTableSkeleton />);

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(<APIKeysTableView {...view} />);

await userEvent.click(screen.getByRole('button', { name: messages.nextPage }));
expect(view.onPageChange).toHaveBeenCalledWith(2);
rerender(
<APIKeysTableView
{...view}
page={2}
isFetching
/>,
);

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(<APIKeysTableView {...view} />);

await userEvent.type(screen.getByRole('searchbox', { name: messages.search }), 'k');
expect(view.onSearchChange).toHaveBeenCalledWith('k');
rerender(
<APIKeysTableView
{...view}
searchValue='k'
isFetching
/>,
);

expect(bodyRows(container)).toHaveLength(3);
});

it('keeps the current rows while fetching without refetchSkeleton', () => {
render(
<APIKeysTableView
{...props({ refetchSkeleton: false })}
page={2}
isFetching
/>,
);

expect(within(screen.getByRole('table')).getByText('Key 1')).toBeInTheDocument();
expect(screen.queryByRole('status')).toBeNull();
});
});
Loading
Loading