(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 (
+
+ );
+}
|