Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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 @@
---
---
54 changes: 54 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,58 @@ 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(screen.getByText('Name')).toHaveAttribute('aria-hidden', 'true');
expect(screen.getByText('Web app')).toHaveAttribute('aria-hidden', 'true');
expect(screen.getByText('Actions')).not.toHaveAttribute('aria-hidden');
expect(screen.getByText('Menu')).not.toHaveAttribute('aria-hidden');
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(screen.getByText('Web app')).toHaveAttribute('aria-hidden', 'true');
expect(screen.getByText('Name')).not.toHaveAttribute('aria-hidden');
});
});
});
84 changes: 63 additions & 21 deletions packages/mosaic/src/components/table/table.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -6,6 +7,7 @@ import { useRender } from '../../primitives/utils';
import type { MosaicComponentProps, MosaicElementProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { reset } from '../../styles/reset.styles';
import { SkeletonText } from '../../utils/skeleton-text';
import { Button } from '../button';
import type { CheckboxProps } from '../checkbox';
import { Checkbox } from '../checkbox';
Expand All @@ -19,11 +21,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 +103,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 +123,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 +149,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 +198,7 @@ export interface TableHeaderCellProps extends Omit<MosaicComponentProps<'th'>, '
align?: TableAlign;
sort?: TableSort;
onSort?: () => void;
skeleton?: boolean;
}

const sortIcons = {
Expand All @@ -179,23 +208,31 @@ 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 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 ? (
<SkeletonText>{children}</SkeletonText>
) : sortable ? (
<Button
variant='ghost'
color='neutral'
Expand All @@ -220,21 +257,26 @@ 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);
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 ? <SkeletonText>{children}</SkeletonText> : children,
},
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { describe, expect, it, onTestFinished, vi } from 'vitest';

import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../__tests__/feature/fake-fapi';
import {
fapiApiKey,
fapiClient,
Expand All @@ -14,11 +14,11 @@ import {
fapiOrganization,
fapiSession,
fapiUser,
} from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
import { MosaicProvider } from '../../../mosaic-provider';
import type { APIKeysTableProps } from '../api-keys-table';
import { APIKeysTable } from '../api-keys-table';
} from '../../__tests__/feature/fapi';
import { renderWithClerk } from '../../__tests__/feature/render';
import { MosaicProvider } from '../../mosaic-provider';
import type { APIKeysTableProps } from './api-keys-table';
import { APIKeysTable } from './api-keys-table';

const DAY = 24 * 60 * 60 * 1000;

Expand Down
27 changes: 27 additions & 0 deletions packages/mosaic/src/features/api-keys/api-keys-table.skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useMessages } from '../../localization';
import { resolveAPIKeysTableMessages } from './api-keys-table.messages';
import { APIKeysTableView, placeholderAPIKeys } from './api-keys-table.view';

const PLACEHOLDER_API_KEYS = placeholderAPIKeys(3);

const noop = () => undefined;

export function APIKeysTableSkeleton() {
const messages = resolveAPIKeysTableMessages(useMessages('apiKeysTable'), 'user');

return (
<APIKeysTableView
skeleton
messages={messages}
apiKeys={PLACEHOLDER_API_KEYS}
totalCount={PLACEHOLDER_API_KEYS.length}
page={1}
searchValue=''
isLoading={false}
onPageChange={noop}
onSearchChange={noop}
onCreate={noop}
onRevoke={() => Promise.resolve()}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@ export const styles = stylex.create({
root: { gap: space['6'], display: 'flex', flexDirection: 'column', width: '100%' },
name: { fontWeight: fontWeightVars['--cl-font-medium'] },
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'] },
actionsSkeleton: { width: `calc(${space['7']} + 2 * ${space['4']})` },
});
2 changes: 2 additions & 0 deletions packages/mosaic/src/features/api-keys/api-keys-table.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,6 @@ export interface APIKeysTableViewProps {
onBulkAction?: (ids: string[]) => void;
sort?: APIKeysTableSort | null;
onSortChange?: (sort: APIKeysTableSort | null) => void;
skeleton?: boolean;
refetchSkeleton?: boolean;
}
Loading
Loading