-
+
-
+
);
});
@@ -412,14 +364,12 @@ export const Table = {
Root,
Toolbar,
Search,
- ColumnGroup,
- Column,
Header,
+ Column,
Body,
Row,
- HeaderCell,
Cell,
- SelectAllCell,
- SelectCell,
- Empty,
+ SelectionColumn,
+ SelectionCell,
+ ExpandButton,
};
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 403b5e0123e..1abf65a710b 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
@@ -9,7 +9,6 @@ import { VisuallyHidden } from '../../components/visually-hidden';
import { mergeStyleProps, themeProps } from '../../props';
import { styles } from './api-keys-table.styles';
import type { APIKeysTableMessages } from './api-keys-table.types';
-import { APIKeysTableColumns } from './api-keys-table.view';
export function APIKeysTableSkeleton({
messages: m,
@@ -37,25 +36,26 @@ export function APIKeysTableSkeleton({
layout='fixed'
minWidth='36rem'
>
-
-
- {m.name}
- {m.createdAt}
- {m.lastUsed}
- {canManage ? (
-
- {m.actions}
-
- ) : null}
-
+ {m.name}
+ {m.createdAt}
+ {m.lastUsed}
+ {canManage ? (
+
+ {m.actions}
+
+ ) : null}
-
- {PLACEHOLDER_API_KEYS.map(apiKey => (
-
+ apiKey.id}
+ >
+ {apiKey => (
+
@@ -89,7 +89,7 @@ export function APIKeysTableSkeleton({
) : null}
- ))}
+ )}
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 cfe62e1199a..7611966a521 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
@@ -16,11 +16,6 @@ export interface APIKey {
lastUsedAtLabel: string | null;
}
-export interface APIKeysTableSort {
- column: 'name' | 'createdAt' | 'lastUsed';
- direction: 'ascending' | 'descending';
-}
-
export type APIKeysTableSubjectKind = 'user' | 'organization';
type Strings = { readonly [K in keyof T]: T[K] extends string ? string : Strings };
@@ -50,6 +45,4 @@ export interface APIKeysTableViewProps {
revoke?: ConfirmController;
finalFocus?: DialogFocusTarget;
onBulkAction?: (ids: string[]) => void;
- sort?: APIKeysTableSort | null;
- onSortChange?: (sort: APIKeysTableSort | null) => void;
}
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 a6c1fee8b94..ae0ada81627 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
@@ -10,7 +10,6 @@ import { Pagination } from '../../components/pagination';
import { Table } from '../../components/table';
import { Text } from '../../components/text';
import { VisuallyHidden } from '../../components/visually-hidden';
-import { useServerDataTable } from '../../hooks/use-server-data-table';
import { ExclamationCircle } from '../../icons/glyphs/exclamation-circle';
import { Key } from '../../icons/glyphs/key';
import { fill } from '../../localization';
@@ -40,28 +39,11 @@ export function APIKeysTableView({
revoke,
finalFocus,
onBulkAction,
- sort,
- onSortChange,
isFetching = false,
isError = false,
onRetry,
}: APIKeysTableViewProps) {
- const { table, sortHeader, pagination } = useServerDataTable({
- data: apiKeys,
- totalCount,
- getRowId,
- sortableColumns: ['name', 'createdAt', 'lastUsed'],
- sort,
- onSortChange,
- page,
- pageSize,
- onPageChange,
- onPageSizeChange,
- searchValue,
- onSearchChange,
- isFetching,
- });
- const columnCount = 3 + Number(Boolean(revoke)) + Number(Boolean(onBulkAction));
+ const showPagination = Math.ceil(totalCount / pageSize) > 1 || (totalCount > 0 && Boolean(onPageSizeChange));
const query = searchValue.trim();
const emptyState = query
? { label: m.empty, description: fill(m.emptyDescription, { query }) }
@@ -75,8 +57,8 @@ export function APIKeysTableView({
ref={searchInputRef}
label={m.search}
clearLabel={m.clearSearch}
- value={table.globalFilter}
- onValueChange={table.setGlobalFilter}
+ value={searchValue}
+ onValueChange={onSearchChange}
/>
{onCreate ? (
-
-
- {onBulkAction ? (
-
- ) : null}
- {m.name}
- {m.createdAt}
- {m.lastUsed}
- {revoke ? (
-
- {m.actions}
-
- ) : null}
-
+ {onBulkAction ? : null}
+ {m.name}
+ {m.createdAt}
+ {m.lastUsed}
+ {revoke ? (
+
+ {m.actions}
+
+ ) : null}
-
- {isError ? (
-
+
+ isError ? (
{m.loadError}
@@ -136,58 +111,59 @@ export function APIKeysTableView({
) : null}
-
- ) : table.rows.length === 0 ? (
-
+ ) : (
{emptyState.label}
{emptyState.description}
-
- ) : (
- table.rows.map(row => (
-
- {onBulkAction ? (
-
- ) : null}
-
-
+ {apiKey => (
+
+ {onBulkAction ? : null}
+
+
+
+
+ {apiKey.createdAtLabel}
+
+
+ {apiKey.lastUsedAtLabel ?? m.neverUsed}
+
+ {revoke ? (
+
+ revoke.open(key)}
/>
-
- {row.original.createdAtLabel}
-
-
- {row.original.lastUsedAtLabel ?? m.neverUsed}
-
- {revoke ? (
-
- revoke.open(apiKey)}
- />
-
- ) : null}
-
- ))
+ ) : null}
+
)}
- {pagination ? (
+ {showPagination ? (
{
+ onPageSizeChange(next);
+ onPageChange(1);
+ }
+ : undefined
+ }
label={m.pagination}
pageSizeLabel={m.pageSize}
previousPageLabel={m.previousPage}
@@ -270,15 +246,3 @@ function APIKeyActions({
);
}
-
-export function APIKeysTableColumns({ selectable, revocable }: { selectable: boolean; revocable: boolean }) {
- return (
-
- {selectable ? : null}
-
-
-
- {revocable ? : null}
-
- );
-}
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.skeleton.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.skeleton.tsx
index 54fa71b657f..7b08548ce8c 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.skeleton.tsx
+++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.skeleton.tsx
@@ -65,46 +65,31 @@ export function InvitationsTableTabSkeleton({
minWidth='35rem'
aria-label={m.title}
aria-busy
+ selectionMode={hasBulkAction ? 'multiple' : 'none'}
>
-
- {hasBulkAction ? : null}
-
-
-
- {hasRevoke ? : null}
-
-
- {hasBulkAction ? (
-
- ) : null}
- {m.email}
- {m.invitedAt}
- {m.roleLabel}
- {hasRevoke ? (
-
- {m.actions}
-
- ) : null}
-
+ {hasBulkAction ? : null}
+ {m.email}
+ {m.invitedAt}
+ {m.roleLabel}
+ {hasRevoke ? (
+
+ {m.actions}
+
+ ) : null}
invitation.id}
>
- {PLACEHOLDER_INVITATIONS.map(invitation => (
-
- {hasBulkAction ? (
-
- ) : null}
+ {invitation => (
+
+ {hasBulkAction ? : null}
@@ -135,7 +120,7 @@ export function InvitationsTableTabSkeleton({
) : null}
- ))}
+ )}
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.view.tsx
index dbd77035c2d..5024f01e290 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.view.tsx
+++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.view.tsx
@@ -10,7 +10,6 @@ import { Icon } from '../../../components/icon';
import { Pagination } from '../../../components/pagination';
import { Table } from '../../../components/table';
import { VisuallyHidden } from '../../../components/visually-hidden';
-import { useServerDataTable } from '../../../hooks/use-server-data-table';
import { Envelope } from '../../../icons/glyphs/envelope';
import { ExclamationCircle } from '../../../icons/glyphs/exclamation-circle';
import { Plus } from '../../../icons/glyphs/plus';
@@ -40,19 +39,7 @@ export function InvitationsTableTabView({
onRetry,
}: InvitationsTableTabViewProps) {
const m = useMessages('invitationsTableTab');
- const { table, pagination } = useServerDataTable({
- data: invitations,
- totalCount,
- getRowId,
- sortableColumns: [],
- page,
- pageSize,
- onPageChange,
- searchValue: '',
- onSearchChange: () => {},
- isFetching,
- });
- const columnCount = 3 + Number(Boolean(revoke)) + Number(Boolean(onBulkAction));
+ const showPagination = Math.ceil(totalCount / pageSize) > 1;
return (
<>
-
- {onBulkAction ? : null}
-
-
-
- {revoke ? : null}
-
-
- {onBulkAction ? (
-
- ) : null}
- {m.email}
- {m.invitedAt}
- {m.roleLabel}
- {revoke ? (
-
- {m.actions}
-
- ) : null}
-
+ {onBulkAction ? : null}
+ {m.email}
+ {m.invitedAt}
+ {m.roleLabel}
+ {revoke ? (
+
+ {m.actions}
+
+ ) : null}
- {pagination ? (
+ {showPagination ? (
boolean;
- toggleSelected: () => void;
- }>;
- columnCount: number;
+ invitations: OrganizationProfileInvitation[];
isError: boolean;
onRetry?: () => void;
hasBulkAction: boolean;
@@ -166,10 +137,12 @@ function InvitationsTableBody({
registerTrigger: (id: string) => Ref;
}) {
const m = useMessages('invitationsTableTab');
- if (isError) {
- return (
-
-
+ return (
+
+ isError ? (
{m.loadError}
@@ -179,63 +152,44 @@ function InvitationsTableBody({
) : null}
-
-
- );
- }
- if (rows.length === 0) {
- return (
-
-
+ ) : (
{m.noInvitations}
{m.noInvitationsDescription}
-
-
- );
- }
- return (
-
- {rows.map(row => (
-
- {hasBulkAction ? (
-
- ) : null}
+ )
+ }
+ >
+ {invitation => (
+
+ {hasBulkAction ? : null}
- {row.original.email}
+ {invitation.email}
- {row.original.invitedAtLabel}
- {row.original.roleLabel}
+ {invitation.invitedAtLabel}
+ {invitation.roleLabel}
{onRevokeClick ? (
onRevokeClick(row.original),
+ onClick: () => onRevokeClick(invitation),
},
]}
/>
) : null}
- ))}
+ )}
);
}
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.skeleton.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.skeleton.tsx
index 6906ab49fa2..61aa25a8cdc 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.skeleton.tsx
+++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.skeleton.tsx
@@ -75,46 +75,31 @@ export function MembersTableTabSkeleton({
minWidth='37rem'
aria-label={m.title}
aria-busy
+ selectionMode={hasBulkAction ? 'multiple' : 'none'}
>
-
- {hasBulkAction ? : null}
-
-
-
- {hasRemove ? : null}
-
-
- {hasBulkAction ? (
-
- ) : null}
- {m.name}
- {m.joinedAt}
- {m.role}
- {hasRemove ? (
-
- {m.actions}
-
- ) : null}
-
+ {hasBulkAction ? : null}
+ {m.name}
+ {m.joinedAt}
+ {m.role}
+ {hasRemove ? (
+
+ {m.actions}
+
+ ) : null}
member.id}
>
- {PLACEHOLDER_MEMBERS.map(member => (
-
- {hasBulkAction ? (
-
- ) : null}
+ {member => (
+
+ {hasBulkAction ? : null}
) : null}
- ))}
+ )}
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.view.tsx
index b8228e4f24b..b03344c1354 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.view.tsx
+++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.view.tsx
@@ -14,7 +14,6 @@ import { Pagination } from '../../../components/pagination';
import { Select } from '../../../components/select';
import { Table } from '../../../components/table';
import { VisuallyHidden } from '../../../components/visually-hidden';
-import { useServerDataTable } from '../../../hooks/use-server-data-table';
import { ExclamationCircle } from '../../../icons/glyphs/exclamation-circle';
import { Plus } from '../../../icons/glyphs/plus';
import { Users } from '../../../icons/glyphs/users';
@@ -53,30 +52,8 @@ export function MembersTableTabView({
roleError,
}: MembersTableTabViewProps) {
const m = useMessages('membersTableTab');
- const { table, pagination } = useServerDataTable({
- data: members,
- totalCount,
- getRowId,
- isRowSelectable: canManageMember,
- sortableColumns: [],
- page,
- pageSize,
- onPageChange,
- searchValue,
- onSearchChange,
- isFetching,
- });
- const columnCount = 3 + Number(Boolean(remove)) + Number(Boolean(onBulkAction));
const query = searchValue.trim();
- const removeAndClearSelection = remove
- ? {
- ...remove,
- confirm: (member: OrganizationProfileMember) => {
- table.setRowSelection({});
- remove.confirm(member);
- },
- }
- : undefined;
+ const showPagination = Math.ceil(totalCount / pageSize) > 1;
return (
<>
@@ -86,8 +63,8 @@ export function MembersTableTabView({
ref={searchInputRef}
label={m.search}
clearLabel={m.clearSearch}
- value={table.globalFilter}
- onValueChange={table.setGlobalFilter}
+ value={searchValue}
+ onValueChange={onSearchChange}
/>
{onInvite ? (
-
- {onBulkAction ? : null}
-
-
-
- {remove ? : null}
-
-
- {onBulkAction ? (
-
- ) : null}
- {m.name}
- {m.joinedAt}
- {m.role}
- {remove ? (
-
- {m.actions}
-
- ) : null}
-
+ {onBulkAction ? : null}
+ {m.name}
+ {m.joinedAt}
+ {m.role}
+ {remove ? (
+
+ {m.actions}
+
+ ) : null}
- {pagination ? (
+ {showPagination ? (
) : null}
- {removeAndClearSelection ? (
+ {remove ? (
fill(m.removeTitle, { name: member.name })}
description={m.removeDescription}
actionLabel={m.remove}
@@ -204,8 +172,7 @@ function MembersTableNotices({
}
function MembersTableBody({
- rows,
- columnCount,
+ members,
query,
isError,
onRetry,
@@ -216,8 +183,7 @@ function MembersTableBody({
onRemoveClick,
registerTrigger,
}: {
- rows: Array<{ original: OrganizationProfileMember; getIsSelected: () => boolean; toggleSelected: () => void }>;
- columnCount: number;
+ members: OrganizationProfileMember[];
query: string;
isError: boolean;
onRetry?: () => void;
@@ -229,10 +195,12 @@ function MembersTableBody({
registerTrigger: (id: string) => Ref;
}) {
const m = useMessages('membersTableTab');
- if (isError) {
- return (
-
-
+ return (
+
+ isError ? (
{m.loadError}
@@ -242,14 +210,7 @@ function MembersTableBody({
) : null}
-
-
- );
- }
- if (rows.length === 0) {
- return (
-
-
+ ) : (
{query ? m.empty : m.noMembers}
@@ -257,25 +218,20 @@ function MembersTableBody({
{query ? fill(m.emptyDescription, { query }) : m.noMembersDescription}
-
-
- );
- }
- return (
-
- {rows.map(row => (
+ )
+ }
+ >
+ {member => (
onRemoveClick(row.original) : undefined}
- triggerRef={registerTrigger(row.original.id)}
+ onRemoveClick={onRemoveClick ? () => onRemoveClick(member) : undefined}
+ triggerRef={registerTrigger(member.id)}
/>
- ))}
+ )}
);
}
@@ -283,8 +239,7 @@ function MembersTableBody({
function MemberRow({
member,
roles,
- selected,
- onToggleSelected,
+ hasBulkAction,
onChangeRole,
isChangingRole,
onRemoveClick,
@@ -292,8 +247,7 @@ function MemberRow({
}: {
member: OrganizationProfileMember;
roles: MembersTableTabViewProps['roles'];
- selected: boolean;
- onToggleSelected?: () => void;
+ hasBulkAction: boolean;
onChangeRole?: MembersTableTabViewProps['onChangeRole'];
isChangingRole: boolean;
onRemoveClick?: () => void;
@@ -303,18 +257,10 @@ function MemberRow({
const manageable = canManageMember(member);
return (
- {onToggleSelected ? (
-
- ) : null}
+ {hasBulkAction ? : null}
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.skeleton.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.skeleton.tsx
index 4b636729a25..e7aaa456273 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.skeleton.tsx
+++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.skeleton.tsx
@@ -69,44 +69,30 @@ export function RequestsTableTabSkeleton({
minWidth='33rem'
aria-label={m.title}
aria-busy
+ selectionMode={hasBulkAction ? 'multiple' : 'none'}
>
-
- {hasBulkAction ? : null}
-
-
- {hasActions ? : null}
-
-
- {hasBulkAction ? (
-
- ) : null}
- {m.email}
- {m.requestedAt}
- {hasActions ? (
-
- {m.actions}
-
- ) : null}
-
+ {hasBulkAction ? : null}
+ {m.email}
+ {m.requestedAt}
+ {hasActions ? (
+
+ {m.actions}
+
+ ) : null}
request.id}
>
- {PLACEHOLDER_REQUESTS.map(request => (
-
- {hasBulkAction ? (
-
- ) : null}
+ {request => (
+
+ {hasBulkAction ? : null}
) : null}
- ))}
+ )}
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.view.tsx
index 49068db0ee6..1f6b669d394 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.view.tsx
+++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.view.tsx
@@ -12,7 +12,6 @@ import { Section } from '../../../components/section';
import { Table } from '../../../components/table';
import { VisuallyHidden } from '../../../components/visually-hidden';
import { usePendingAction } from '../../../hooks/use-pending-action';
-import { useServerDataTable } from '../../../hooks/use-server-data-table';
import { ExclamationCircle } from '../../../icons/glyphs/exclamation-circle';
import { Plus } from '../../../icons/glyphs/plus';
import { Users } from '../../../icons/glyphs/users';
@@ -44,20 +43,8 @@ export function RequestsTableTabView({
onRetry,
}: RequestsTableTabViewProps) {
const m = useMessages('requestsTableTab');
- const { table, pagination } = useServerDataTable({
- data: requests,
- totalCount,
- getRowId,
- sortableColumns: [],
- page,
- pageSize,
- onPageChange,
- searchValue: '',
- onSearchChange: () => {},
- isFetching,
- });
+ const showPagination = Math.ceil(totalCount / pageSize) > 1;
const hasActions = Boolean(onAccept || onDecline);
- const columnCount = 2 + Number(hasActions) + Number(Boolean(onBulkAction));
return (
-
- {onBulkAction ? : null}
-
-
- {hasActions ? : null}
-
-
- {onBulkAction ? (
-
- ) : null}
- {m.email}
- {m.requestedAt}
- {hasActions ? (
-
- {m.actions}
-
- ) : null}
-
+ {onBulkAction ? : null}
+ {m.email}
+ {m.requestedAt}
+ {hasActions ? (
+
+ {m.actions}
+
+ ) : null}
- {pagination ? (
+ {showPagination ? (
boolean;
- toggleSelected: () => void;
- }>;
- columnCount: number;
+ requests: OrganizationProfileRequest[];
isError: boolean;
onRetry?: () => void;
hasActions: boolean;
@@ -166,10 +138,12 @@ function RequestsTableBody({
registerDeclineTrigger?: (id: string) => Ref;
}) {
const m = useMessages('requestsTableTab');
- if (isError) {
- return (
-
-
+ return (
+
+ isError ? (
{m.loadError}
@@ -179,76 +153,54 @@ function RequestsTableBody({
) : null}
-
-
- );
- }
- if (rows.length === 0) {
- return (
-
-
+ ) : (
{m.noRequests}
{m.noRequestsDescription}
-
-
- );
- }
- return (
-
- {rows.map(row => {
- const request = row.original;
- return (
-
- {hasBulkAction ? (
-
+ {request => (
+
+ {hasBulkAction ? : null}
+
+
+
+
+ {request.imageUrl ? (
+
+ ) : null}
+
+
+
+
+ {request.name ?? request.email}
+ {request.name ? {request.email} : null}
+
+
+
+ {request.requestedAtLabel}
+ {hasActions ? (
+
+ onAccept(request.id) : undefined}
+ onDecline={onDecline ? () => onDecline(request.id) : undefined}
+ registerAcceptTrigger={registerAcceptTrigger}
+ registerDeclineTrigger={registerDeclineTrigger}
/>
- ) : null}
-
-
-
-
- {request.imageUrl ? (
-
- ) : null}
-
-
-
-
- {request.name ?? request.email}
- {request.name ? {request.email} : null}
-
-
- {request.requestedAtLabel}
- {hasActions ? (
-
- onAccept(request.id) : undefined}
- onDecline={onDecline ? () => onDecline(request.id) : undefined}
- registerAcceptTrigger={registerAcceptTrigger}
- registerDeclineTrigger={registerDeclineTrigger}
- />
-
- ) : null}
-
- );
- })}
+ ) : null}
+
+ )}
);
}
diff --git a/packages/mosaic/src/hooks/__tests__/use-server-data-table.test.ts b/packages/mosaic/src/hooks/__tests__/use-server-data-table.test.ts
deleted file mode 100644
index 1c7b8a96414..00000000000
--- a/packages/mosaic/src/hooks/__tests__/use-server-data-table.test.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-import { act, renderHook } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
-
-import { type ServerDataTableSort, useServerDataTable } from '../use-server-data-table';
-
-type Row = { id: string };
-type Column = 'name' | 'createdAt';
-
-const ROWS: Row[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
-
-function setup(overrides: Partial>[0]> = {}) {
- const props = {
- data: ROWS,
- totalCount: 3,
- getRowId: (row: Row) => row.id,
- sortableColumns: ['name', 'createdAt'] as const,
- page: 1,
- pageSize: 10,
- onPageChange: vi.fn(),
- searchValue: '',
- onSearchChange: vi.fn(),
- ...overrides,
- };
- const hook = renderHook(() => useServerDataTable(props));
- return { ...hook, props };
-}
-
-describe('useServerDataTable', () => {
- describe('sortHeader', () => {
- it('reflects the sort prop on the active column only', () => {
- const { result } = setup({ sort: { column: 'name', direction: 'descending' }, onSortChange: vi.fn() });
-
- expect(result.current.sortHeader('name').sort).toBe('descending');
- expect(result.current.sortHeader('createdAt').sort).toBe('none');
- });
-
- it.each<[ServerDataTableSort | null, ServerDataTableSort | null]>([
- [null, { column: 'name', direction: 'ascending' }],
- [
- { column: 'name', direction: 'ascending' },
- { column: 'name', direction: 'descending' },
- ],
- [{ column: 'name', direction: 'descending' }, null],
- [
- { column: 'createdAt', direction: 'descending' },
- { column: 'name', direction: 'ascending' },
- ],
- ])('cycles from %j to %j', (sort, expected) => {
- const onSortChange = vi.fn();
- const { result } = setup({ sort, onSortChange });
-
- act(() => result.current.sortHeader('name').onSort?.());
-
- expect(onSortChange).toHaveBeenCalledWith(expected);
- });
-
- it('is not sortable without onSortChange', () => {
- const { result } = setup();
-
- expect(result.current.sortHeader('name').onSort).toBeUndefined();
- });
-
- it('reports null for a column that is not sortable', () => {
- const onSortChange = vi.fn();
- const { result } = setup({ onSortChange });
-
- act(() => result.current.table.setSorting([{ id: 'other', desc: false }]));
-
- expect(onSortChange).toHaveBeenCalledWith(null);
- });
- });
-
- describe('pagination', () => {
- it('is hidden for a single page without a page size control', () => {
- const { result } = setup();
-
- expect(result.current.pagination).toBeNull();
- });
-
- it('is shown when there is more than one page', () => {
- const { result } = setup({ totalCount: 25 });
-
- expect(result.current.pagination).toMatchObject({ page: 1, pageSize: 10, totalItems: 25 });
- expect(result.current.pagination?.onPageSizeChange).toBeUndefined();
- });
-
- it('is shown for a single page when the page size can change', () => {
- const { result } = setup({ onPageSizeChange: vi.fn() });
-
- expect(result.current.pagination).not.toBeNull();
- });
-
- it('is hidden when there are no rows, even with a page size control', () => {
- const { result } = setup({ data: [], totalCount: 0, onPageSizeChange: vi.fn() });
-
- expect(result.current.pagination).toBeNull();
- });
-
- it('reports a 1-based page', () => {
- const { result, props } = setup({ totalCount: 25, page: 2 });
-
- expect(result.current.pagination?.page).toBe(2);
- act(() => result.current.pagination?.onChange(3));
-
- expect(props.onPageChange).toHaveBeenCalledWith(3);
- });
-
- it('returns to the first page when the page size changes', () => {
- const onPageSizeChange = vi.fn();
- const { result, props } = setup({ totalCount: 25, page: 2, onPageSizeChange });
-
- act(() => result.current.pagination?.onPageSizeChange?.(25));
-
- expect(onPageSizeChange).toHaveBeenCalledWith(25);
- expect(props.onPageChange).toHaveBeenCalledWith(1);
- });
- });
-
- it('forwards search changes', () => {
- const { result, props } = setup();
-
- act(() => result.current.table.setGlobalFilter('ali'));
-
- expect(props.onSearchChange).toHaveBeenCalledWith('ali');
- });
-
- describe('selection', () => {
- function selectAll(result: { current: ReturnType> }) {
- act(() => result.current.table.toggleAllRowsSelected());
- expect(result.current.table.getIsAllRowsSelected()).toBe(true);
- }
-
- it('clears when the sort changes', () => {
- const { result } = setup({ onSortChange: vi.fn() });
- selectAll(result);
-
- act(() => result.current.sortHeader('name').onSort?.());
-
- expect(result.current.table.rowSelection).toEqual({});
- });
-
- it('clears when the page changes', () => {
- const { result } = setup({ totalCount: 25 });
- selectAll(result);
-
- act(() => result.current.pagination?.onChange(2));
-
- expect(result.current.table.rowSelection).toEqual({});
- });
-
- it('clears when the search changes', () => {
- const { result } = setup();
- selectAll(result);
-
- act(() => result.current.table.setGlobalFilter('ali'));
-
- expect(result.current.table.rowSelection).toEqual({});
- });
-
- it('skips rows that are not selectable', () => {
- const { result } = setup({ isRowSelectable: row => row.id !== 'b' });
-
- act(() => result.current.table.toggleAllRowsSelected());
-
- expect(result.current.table.rowSelection).toEqual({ a: true, c: true });
- });
- });
-});
diff --git a/packages/mosaic/src/hooks/use-server-data-table.ts b/packages/mosaic/src/hooks/use-server-data-table.ts
deleted file mode 100644
index 617df677cd3..00000000000
--- a/packages/mosaic/src/hooks/use-server-data-table.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import type { PaginationProps } from '../components/pagination';
-import type { TableHeaderCellProps } from '../components/table';
-import { functionalUpdate, type PaginationState, type SortingState, useDataTable } from '../primitives/hooks';
-
-export interface ServerDataTableSort {
- column: TColumn;
- direction: 'ascending' | 'descending';
-}
-
-export type ServerDataTablePagination = Required<
- Pick
-> &
- Pick;
-
-export function useServerDataTable({
- data,
- totalCount,
- getRowId,
- isRowSelectable,
- sortableColumns,
- sort,
- onSortChange,
- page,
- pageSize,
- onPageChange,
- onPageSizeChange,
- searchValue,
- onSearchChange,
- isFetching = false,
-}: {
- data: TData[];
- totalCount: number;
- getRowId: (row: TData) => string;
- isRowSelectable?: (row: TData) => boolean;
- sortableColumns: readonly TColumn[];
- sort?: ServerDataTableSort | null;
- onSortChange?: (sort: ServerDataTableSort | null) => void;
- page: number;
- pageSize: number;
- onPageChange: (page: number) => void;
- onPageSizeChange?: (pageSize: number) => void;
- searchValue: string;
- onSearchChange: (value: string) => void;
- isFetching?: boolean;
-}) {
- const sorting: SortingState = sort ? [{ id: sort.column, desc: sort.direction === 'descending' }] : [];
- const pagination: PaginationState = { pageIndex: page - 1, pageSize };
-
- const table = useDataTable({
- data,
- totalCount,
- getRowId,
- isRowSelectable,
- sorting,
- onSortingChange: onSortChange
- ? update => {
- const active = functionalUpdate(update, sorting)[0];
- const column = sortableColumns.find(c => c === active?.id);
- table.setRowSelection({});
- onSortChange(active && column ? { column, direction: active.desc ? 'descending' : 'ascending' } : null);
- }
- : undefined,
- pagination,
- onPaginationChange: update => {
- const next = functionalUpdate(update, pagination);
- table.setRowSelection({});
- if (next.pageSize !== pageSize) {
- onPageSizeChange?.(next.pageSize);
- }
- onPageChange(next.pageIndex + 1);
- },
- globalFilter: searchValue,
- onGlobalFilterChange: update => {
- table.setRowSelection({});
- onSearchChange(functionalUpdate(update, searchValue));
- },
- });
-
- const sortHeader = (column: TColumn): Pick => {
- const active = table.sorting[0];
- return {
- sort: active?.id === column ? (active.desc ? 'descending' : 'ascending') : 'none',
- onSort: onSortChange
- ? () =>
- table.setSorting(current => {
- const active = current[0];
- if (active?.id !== column) {
- return [{ id: column, desc: false }];
- }
- return active.desc ? [] : [{ id: column, desc: true }];
- })
- : undefined,
- };
- };
-
- const showPagination = table.getPageCount() > 1 || (totalCount > 0 && Boolean(onPageSizeChange));
- const paginationProps: ServerDataTablePagination | null = showPagination
- ? {
- page: table.pagination.pageIndex + 1,
- pageSize: table.pagination.pageSize,
- totalItems: totalCount,
- disabled: isFetching,
- onChange: next => table.setPagination(current => ({ ...current, pageIndex: next - 1 })),
- onPageSizeChange: onPageSizeChange ? next => table.setPagination({ pageIndex: 0, pageSize: next }) : undefined,
- }
- : null;
-
- return { table, sortHeader, pagination: paginationProps };
-}
diff --git a/packages/mosaic/src/primitives/hooks/__tests__/use-data-table.test.ts b/packages/mosaic/src/primitives/hooks/__tests__/use-data-table.test.ts
deleted file mode 100644
index 93bcf43db27..00000000000
--- a/packages/mosaic/src/primitives/hooks/__tests__/use-data-table.test.ts
+++ /dev/null
@@ -1,509 +0,0 @@
-import { act, renderHook } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
-
-import { type PaginationState, useDataTable } from '../use-data-table';
-
-type Person = { id: number; name: string; role: string };
-
-const DATA: Person[] = [
- { id: 1, name: 'Alice', role: 'Admin' },
- { id: 2, name: 'Bob', role: 'Member' },
- { id: 3, name: 'Carol', role: 'Admin' },
-];
-
-describe('useDataTable', () => {
- describe('rows', () => {
- it('maps data to enriched row objects with stable string ids', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- expect(result.current.rows).toHaveLength(3);
- expect(result.current.rows[0].id).toBe('0');
- expect(result.current.rows[1].id).toBe('1');
- expect(result.current.rows[2].id).toBe('2');
- });
-
- it('uses getRowId for stable ids when provided', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA, getRowId: row => String(row.id) }));
-
- expect(result.current.rows[0].id).toBe('1');
- expect(result.current.rows[1].id).toBe('2');
- expect(result.current.rows[2].id).toBe('3');
- });
-
- it('exposes original data on each row', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- expect(result.current.rows[0].original).toBe(DATA[0]);
- expect(result.current.rows[2].original).toBe(DATA[2]);
- });
-
- it('updates when data changes', () => {
- const { result, rerender } = renderHook(({ data }) => useDataTable({ data }), {
- initialProps: { data: DATA },
- });
-
- expect(result.current.rows).toHaveLength(3);
-
- rerender({ data: DATA.slice(0, 2) });
-
- expect(result.current.rows).toHaveLength(2);
- });
-
- it('returns empty rows for empty data', () => {
- const { result } = renderHook(() => useDataTable({ data: [] }));
-
- expect(result.current.rows).toHaveLength(0);
- });
- });
-
- describe('sorting', () => {
- it('defaults to empty sorting state', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- expect(result.current.sorting).toEqual([]);
- });
-
- it('uses defaultSorting for initial state', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA, defaultSorting: [{ id: 'name', desc: false }] }));
-
- expect(result.current.sorting).toEqual([{ id: 'name', desc: false }]);
- });
-
- it('updates sorting via setSorting with a value', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- act(() => result.current.setSorting([{ id: 'name', desc: true }]));
-
- expect(result.current.sorting).toEqual([{ id: 'name', desc: true }]);
- });
-
- it('updates sorting via setSorting with a functional updater', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA, defaultSorting: [{ id: 'name', desc: false }] }));
-
- act(() => result.current.setSorting(old => [{ ...old[0], desc: true }]));
-
- expect(result.current.sorting).toEqual([{ id: 'name', desc: true }]);
- });
-
- it('fires onSortingChange with resolved value', () => {
- const onSortingChange = vi.fn();
- const { result } = renderHook(() => useDataTable({ data: DATA, onSortingChange }));
-
- act(() => result.current.setSorting([{ id: 'role', desc: false }]));
-
- expect(onSortingChange).toHaveBeenCalledWith([{ id: 'role', desc: false }]);
- });
-
- it('respects controlled sorting prop', () => {
- const controlled = [{ id: 'name', desc: false }];
- const { result } = renderHook(() => useDataTable({ data: DATA, sorting: controlled }));
-
- act(() => result.current.setSorting([]));
-
- // Stays controlled — internal state does not override
- expect(result.current.sorting).toEqual(controlled);
- });
- });
-
- describe('columnFilters', () => {
- it('defaults to empty filters', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- expect(result.current.columnFilters).toEqual([]);
- });
-
- it('sets a filter with a value', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- act(() => result.current.setColumnFilters([{ id: 'role', value: 'Admin' }]));
-
- expect(result.current.columnFilters).toEqual([{ id: 'role', value: 'Admin' }]);
- });
-
- it('sets a filter with a functional updater', () => {
- const { result } = renderHook(() =>
- useDataTable({ data: DATA, defaultColumnFilters: [{ id: 'role', value: 'Admin' }] }),
- );
-
- act(() => result.current.setColumnFilters(old => [...old, { id: 'name', value: 'Alice' }]));
-
- expect(result.current.columnFilters).toEqual([
- { id: 'role', value: 'Admin' },
- { id: 'name', value: 'Alice' },
- ]);
- });
-
- it('fires onColumnFiltersChange with resolved value', () => {
- const onColumnFiltersChange = vi.fn();
- const { result } = renderHook(() => useDataTable({ data: DATA, onColumnFiltersChange }));
-
- act(() => result.current.setColumnFilters([{ id: 'role', value: 'Member' }]));
-
- expect(onColumnFiltersChange).toHaveBeenCalledWith([{ id: 'role', value: 'Member' }]);
- });
-
- it('respects controlled columnFilters prop', () => {
- const controlled: Array<{ id: string; value: unknown }> = [{ id: 'role', value: 'Admin' }];
- const { result } = renderHook(() => useDataTable({ data: DATA, columnFilters: controlled }));
-
- act(() => result.current.setColumnFilters([]));
-
- expect(result.current.columnFilters).toEqual(controlled);
- });
- });
-
- describe('globalFilter', () => {
- it('defaults to empty string', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- expect(result.current.globalFilter).toBe('');
- });
-
- it('sets globalFilter', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- act(() => result.current.setGlobalFilter('alice'));
-
- expect(result.current.globalFilter).toBe('alice');
- });
-
- it('fires onGlobalFilterChange', () => {
- const onGlobalFilterChange = vi.fn();
- const { result } = renderHook(() => useDataTable({ data: DATA, onGlobalFilterChange }));
-
- act(() => result.current.setGlobalFilter('bob'));
-
- expect(onGlobalFilterChange).toHaveBeenCalledWith('bob');
- });
-
- it('respects controlled globalFilter prop', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA, globalFilter: 'alice' }));
-
- act(() => result.current.setGlobalFilter(''));
-
- expect(result.current.globalFilter).toBe('alice');
- });
- });
-
- describe('pagination', () => {
- it('defaults to pageIndex 0 and pageSize 10', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 10 });
- });
-
- it('uses defaultPagination for initial state', () => {
- const { result } = renderHook(() =>
- useDataTable({ data: DATA, defaultPagination: { pageIndex: 2, pageSize: 25 } }),
- );
-
- expect(result.current.pagination).toEqual({ pageIndex: 2, pageSize: 25 });
- });
-
- it('getPageCount returns 0 when totalCount is undefined', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- expect(result.current.getPageCount()).toBe(0);
- });
-
- it('getPageCount computes from totalCount and pageSize', () => {
- const { result } = renderHook(() =>
- useDataTable({ data: DATA, totalCount: 55, defaultPagination: { pageIndex: 0, pageSize: 10 } }),
- );
-
- expect(result.current.getPageCount()).toBe(6);
- });
-
- it('getCanNextPage is false on the last page', () => {
- const { result } = renderHook(() =>
- useDataTable({
- data: DATA,
- totalCount: 10,
- defaultPagination: { pageIndex: 0, pageSize: 10 },
- }),
- );
-
- expect(result.current.getCanNextPage()).toBe(false);
- });
-
- it('getCanNextPage is true when more pages exist', () => {
- const { result } = renderHook(() =>
- useDataTable({
- data: DATA,
- totalCount: 20,
- defaultPagination: { pageIndex: 0, pageSize: 10 },
- }),
- );
-
- expect(result.current.getCanNextPage()).toBe(true);
- });
-
- it('getCanPreviousPage is false on page 0', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- expect(result.current.getCanPreviousPage()).toBe(false);
- });
-
- it('getCanPreviousPage is true on page > 0', () => {
- const { result } = renderHook(() =>
- useDataTable({ data: DATA, defaultPagination: { pageIndex: 1, pageSize: 10 } }),
- );
-
- expect(result.current.getCanPreviousPage()).toBe(true);
- });
-
- it('nextPage increments pageIndex', () => {
- const { result } = renderHook(() =>
- useDataTable({
- data: DATA,
- totalCount: 30,
- defaultPagination: { pageIndex: 0, pageSize: 10 },
- }),
- );
-
- act(() => result.current.nextPage());
-
- expect(result.current.pagination.pageIndex).toBe(1);
- });
-
- it('previousPage decrements pageIndex', () => {
- const { result } = renderHook(() =>
- useDataTable({ data: DATA, defaultPagination: { pageIndex: 2, pageSize: 10 } }),
- );
-
- act(() => result.current.previousPage());
-
- expect(result.current.pagination.pageIndex).toBe(1);
- });
-
- it('nextPage does not advance past the last page', () => {
- const { result } = renderHook(() =>
- useDataTable({ data: DATA, totalCount: 10, defaultPagination: { pageIndex: 0, pageSize: 10 } }),
- );
-
- act(() => result.current.nextPage());
-
- expect(result.current.pagination.pageIndex).toBe(0);
- });
-
- it('previousPage does not go below page 0', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- act(() => result.current.previousPage());
-
- expect(result.current.pagination.pageIndex).toBe(0);
- });
-
- it('setPageSize updates pageSize and resets pageIndex to 0', () => {
- const { result } = renderHook(() =>
- useDataTable({ data: DATA, defaultPagination: { pageIndex: 3, pageSize: 10 } }),
- );
-
- act(() => result.current.setPageSize(25));
-
- expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 25 });
- });
-
- it('fires onPaginationChange when page changes', () => {
- const onPaginationChange = vi.fn();
- const { result } = renderHook(() =>
- useDataTable({
- data: DATA,
- totalCount: 30,
- defaultPagination: { pageIndex: 0, pageSize: 10 },
- onPaginationChange,
- }),
- );
-
- act(() => result.current.nextPage());
-
- expect(onPaginationChange).toHaveBeenCalledWith({ pageIndex: 1, pageSize: 10 });
- });
-
- it('respects controlled pagination prop', () => {
- const controlled: PaginationState = { pageIndex: 2, pageSize: 10 };
- const { result } = renderHook(() => useDataTable({ data: DATA, totalCount: 50, pagination: controlled }));
-
- act(() => result.current.nextPage());
-
- expect(result.current.pagination).toEqual(controlled);
- });
- });
-
- describe('row selection', () => {
- it('defaults to no rows selected', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- expect(result.current.rowSelection).toEqual({});
- });
-
- it('row.getIsSelected() returns false by default', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- expect(result.current.rows[0].getIsSelected()).toBe(false);
- });
-
- it('row.toggleSelected() selects the row', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- act(() => result.current.rows[0].toggleSelected());
-
- expect(result.current.rows[0].getIsSelected()).toBe(true);
- expect(result.current.rowSelection).toEqual({ '0': true });
- });
-
- it('row.toggleSelected() deselects an already-selected row', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA, defaultRowSelection: { '0': true } }));
-
- act(() => result.current.rows[0].toggleSelected());
-
- expect(result.current.rows[0].getIsSelected()).toBe(false);
- });
-
- it('getIsAllRowsSelected() returns false when no rows selected', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- expect(result.current.getIsAllRowsSelected()).toBe(false);
- });
-
- it('getIsAllRowsSelected() returns true when all rows selected', () => {
- const { result } = renderHook(() =>
- useDataTable({ data: DATA, defaultRowSelection: { '0': true, '1': true, '2': true } }),
- );
-
- expect(result.current.getIsAllRowsSelected()).toBe(true);
- });
-
- it('getIsAllRowsSelected() returns false for empty data', () => {
- const { result } = renderHook(() => useDataTable({ data: [] }));
-
- expect(result.current.getIsAllRowsSelected()).toBe(false);
- });
-
- it('getIsSomeRowsSelected() returns true on partial selection', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA, defaultRowSelection: { '1': true } }));
-
- expect(result.current.getIsSomeRowsSelected()).toBe(true);
- });
-
- it('getIsSomeRowsSelected() returns false when nothing selected', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- expect(result.current.getIsSomeRowsSelected()).toBe(false);
- });
-
- it('getIsSomeRowsSelected() returns false when all rows are selected', () => {
- const { result } = renderHook(() =>
- useDataTable({ data: DATA, defaultRowSelection: { '0': true, '1': true, '2': true } }),
- );
-
- expect(result.current.getIsSomeRowsSelected()).toBe(false);
- });
-
- it('toggleAllRowsSelected() selects all rows', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- act(() => result.current.toggleAllRowsSelected());
-
- expect(result.current.getIsAllRowsSelected()).toBe(true);
- expect(result.current.rowSelection).toEqual({ '0': true, '1': true, '2': true });
- });
-
- it('toggleAllRowsSelected() deselects all when all are selected', () => {
- const { result } = renderHook(() =>
- useDataTable({ data: DATA, defaultRowSelection: { '0': true, '1': true, '2': true } }),
- );
-
- act(() => result.current.toggleAllRowsSelected());
-
- expect(result.current.rowSelection).toEqual({});
- });
-
- it('fires onRowSelectionChange when selection changes', () => {
- const onRowSelectionChange = vi.fn();
- const { result } = renderHook(() => useDataTable({ data: DATA, onRowSelectionChange }));
-
- act(() => result.current.rows[1].toggleSelected());
-
- expect(onRowSelectionChange).toHaveBeenCalledWith({ '1': true });
- });
-
- it('respects controlled rowSelection prop', () => {
- const controlled = { '0': true };
- const { result } = renderHook(() => useDataTable({ data: DATA, rowSelection: controlled }));
-
- act(() => result.current.rows[0].toggleSelected());
-
- expect(result.current.rowSelection).toEqual(controlled);
- });
-
- it('excludes non-selectable rows from direct, range, and select-all selection', () => {
- const { result } = renderHook(() =>
- useDataTable({
- data: DATA,
- getRowId: row => String(row.id),
- isRowSelectable: row => row.role === 'Admin',
- }),
- );
- act(() => result.current.rows[1].toggleSelected());
- expect(result.current.rowSelection).toEqual({});
- act(() => result.current.rows[0].toggleSelected());
- expect(result.current.getIsSomeRowsSelected()).toBe(true);
- act(() => result.current.rows[2].toggleSelected({ range: true }));
- expect(result.current.rowSelection).toEqual({ '1': true, '3': true });
- expect(result.current.getIsAllRowsSelected()).toBe(true);
- act(() => result.current.toggleAllRowsSelected());
- expect(result.current.rowSelection).toEqual({});
- act(() => result.current.toggleAllRowsSelected());
- expect(result.current.rowSelection).toEqual({ '1': true, '3': true });
- });
-
- it('row.toggleSelected({ range: true }) selects every row between the last toggled row and this one', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- act(() => result.current.rows[0].toggleSelected());
- act(() => result.current.rows[2].toggleSelected({ range: true }));
-
- expect(result.current.rowSelection).toEqual({ '0': true, '1': true, '2': true });
- });
-
- it('row.toggleSelected({ range: true }) selects a range above the last toggled row', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- act(() => result.current.rows[2].toggleSelected());
- act(() => result.current.rows[0].toggleSelected({ range: true }));
-
- expect(result.current.rowSelection).toEqual({ '0': true, '1': true, '2': true });
- });
-
- it('row.toggleSelected({ range: true }) deselects the range when this row is selected', () => {
- const { result } = renderHook(() =>
- useDataTable({ data: DATA, defaultRowSelection: { '0': true, '1': true, '2': true } }),
- );
-
- act(() => result.current.rows[0].toggleSelected());
- act(() => result.current.rows[2].toggleSelected({ range: true }));
-
- expect(result.current.rowSelection).toEqual({ '0': false, '1': false, '2': false });
- });
-
- it('row.toggleSelected({ range: true }) toggles only this row when no row was toggled before', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- act(() => result.current.rows[2].toggleSelected({ range: true }));
-
- expect(result.current.rowSelection).toEqual({ '2': true });
- });
-
- it('row.toggleSelected({ range: true }) anchors on the row that ended the last range', () => {
- const { result } = renderHook(() => useDataTable({ data: DATA }));
-
- act(() => result.current.rows[0].toggleSelected());
- act(() => result.current.rows[1].toggleSelected({ range: true }));
- act(() => result.current.rows[2].toggleSelected({ range: true }));
- act(() => result.current.rows[1].toggleSelected({ range: true }));
-
- expect(result.current.rowSelection).toEqual({ '0': true, '1': false, '2': false });
- });
- });
-});
diff --git a/packages/mosaic/src/primitives/hooks/index.ts b/packages/mosaic/src/primitives/hooks/index.ts
index 8ce698dbe90..2885e595712 100644
--- a/packages/mosaic/src/primitives/hooks/index.ts
+++ b/packages/mosaic/src/primitives/hooks/index.ts
@@ -1,16 +1,4 @@
export { useAnimationsFinished } from './use-animations-finished';
-export { functionalUpdate, useDataTable } from './use-data-table';
-export type {
- ColumnFiltersState,
- DataTableRow,
- OnChangeFn,
- PaginationState,
- RowSelectionState,
- SortingState,
- Updater,
- UseDataTableOptions,
- UseDataTableReturn,
-} from './use-data-table';
export { useControllableState } from './use-controllable-state';
export { useFormattedInput, type UseFormattedInputOptions } from './use-formatted-input';
export { useLayoutAnimation } from './use-layout-animation';
diff --git a/packages/mosaic/src/primitives/hooks/use-data-table.ts b/packages/mosaic/src/primitives/hooks/use-data-table.ts
deleted file mode 100644
index 1d63bc184d5..00000000000
--- a/packages/mosaic/src/primitives/hooks/use-data-table.ts
+++ /dev/null
@@ -1,254 +0,0 @@
-'use client';
-
-import { useCallback, useMemo, useRef } from 'react';
-
-import { useControllableState } from './use-controllable-state';
-
-export type Updater = T | ((old: T) => T);
-export type OnChangeFn = (updaterOrValue: Updater) => void;
-
-export type SortingState = Array<{ id: string; desc: boolean }>;
-export type ColumnFiltersState = Array<{ id: string; value: unknown }>;
-export type PaginationState = { pageIndex: number; pageSize: number };
-export type RowSelectionState = Record;
-
-export function functionalUpdate(updater: Updater, old: T): T {
- return typeof updater === 'function' ? (updater as (old: T) => T)(old) : updater;
-}
-
-export interface UseDataTableOptions {
- data: TData[];
- getRowId?: (row: TData, index: number) => string;
- isRowSelectable?: (row: TData) => boolean;
- totalCount?: number;
-
- sorting?: SortingState;
- defaultSorting?: SortingState;
- onSortingChange?: OnChangeFn;
-
- columnFilters?: ColumnFiltersState;
- defaultColumnFilters?: ColumnFiltersState;
- onColumnFiltersChange?: OnChangeFn;
-
- globalFilter?: string;
- defaultGlobalFilter?: string;
- onGlobalFilterChange?: OnChangeFn;
-
- pagination?: PaginationState;
- defaultPagination?: PaginationState;
- onPaginationChange?: OnChangeFn;
-
- rowSelection?: RowSelectionState;
- defaultRowSelection?: RowSelectionState;
- onRowSelectionChange?: OnChangeFn;
-}
-
-export interface DataTableRow {
- id: string;
- original: TData;
- getIsSelected: () => boolean;
- toggleSelected: (options?: { range?: boolean }) => void;
-}
-
-export interface UseDataTableReturn {
- rows: DataTableRow[];
-
- sorting: SortingState;
- setSorting: OnChangeFn;
-
- columnFilters: ColumnFiltersState;
- setColumnFilters: OnChangeFn;
-
- globalFilter: string;
- setGlobalFilter: OnChangeFn;
-
- pagination: PaginationState;
- setPagination: OnChangeFn;
- nextPage: () => void;
- previousPage: () => void;
- setPageSize: (size: number) => void;
- getCanNextPage: () => boolean;
- getCanPreviousPage: () => boolean;
- getPageCount: () => number;
-
- rowSelection: RowSelectionState;
- setRowSelection: OnChangeFn;
- getIsAllRowsSelected: () => boolean;
- getIsSomeRowsSelected: () => boolean;
- toggleAllRowsSelected: () => void;
-}
-
-export function useDataTable(opts: UseDataTableOptions): UseDataTableReturn {
- // ── State slices ────────────────────────────────────────────────────────────
-
- const [sorting, setSortingRaw] = useControllableState(opts.sorting, opts.defaultSorting ?? [], opts.onSortingChange);
-
- const [columnFilters, setColumnFiltersRaw] = useControllableState(
- opts.columnFilters,
- opts.defaultColumnFilters ?? [],
- opts.onColumnFiltersChange,
- );
-
- const [globalFilter, setGlobalFilterRaw] = useControllableState(
- opts.globalFilter,
- opts.defaultGlobalFilter ?? '',
- opts.onGlobalFilterChange,
- );
-
- const [pagination, setPaginationRaw] = useControllableState(
- opts.pagination,
- opts.defaultPagination ?? { pageIndex: 0, pageSize: 10 },
- opts.onPaginationChange,
- );
-
- const [rowSelection, setRowSelectionRaw] = useControllableState(
- opts.rowSelection,
- opts.defaultRowSelection ?? {},
- opts.onRowSelectionChange,
- );
-
- // ── Updater-aware public setters ────────────────────────────────────────────
-
- const setSorting: OnChangeFn = useCallback(
- u => setSortingRaw(functionalUpdate(u, sorting)),
- [sorting, setSortingRaw],
- );
-
- const setColumnFilters: OnChangeFn = useCallback(
- u => setColumnFiltersRaw(functionalUpdate(u, columnFilters)),
- [columnFilters, setColumnFiltersRaw],
- );
-
- const setGlobalFilter: OnChangeFn = useCallback(
- u => setGlobalFilterRaw(functionalUpdate(u, globalFilter)),
- [globalFilter, setGlobalFilterRaw],
- );
-
- const setPagination: OnChangeFn = useCallback(
- u => setPaginationRaw(functionalUpdate(u, pagination)),
- [pagination, setPaginationRaw],
- );
-
- const setRowSelection: OnChangeFn = useCallback(
- u => setRowSelectionRaw(functionalUpdate(u, rowSelection)),
- [rowSelection, setRowSelectionRaw],
- );
-
- // ── Rows ────────────────────────────────────────────────────────────────────
-
- const { getRowId, isRowSelectable } = opts;
- const selectionAnchor = useRef(null);
- const rows = useMemo[]>(() => {
- const entries = opts.data.map((original, i) => ({ original, id: getRowId ? getRowId(original, i) : String(i) }));
- const selectableIds = entries
- .filter(entry => !isRowSelectable || isRowSelectable(entry.original))
- .map(entry => entry.id);
- return entries.map(({ original, id }) => {
- return {
- id,
- original,
- getIsSelected: () => !!rowSelection[id],
- toggleSelected: options => {
- const index = selectableIds.indexOf(id);
- if (index === -1) {
- return;
- }
- const anchor = selectionAnchor.current;
- const anchorIndex = options?.range && anchor !== null ? selectableIds.indexOf(anchor) : -1;
- selectionAnchor.current = id;
- setRowSelection(old => {
- const selected = !old[id];
- if (anchorIndex === -1) {
- return { ...old, [id]: selected };
- }
- const rangeIds = selectableIds.slice(Math.min(anchorIndex, index), Math.max(anchorIndex, index) + 1);
- return { ...old, ...Object.fromEntries(rangeIds.map(rangeId => [rangeId, selected])) };
- });
- },
- };
- });
- }, [opts.data, getRowId, isRowSelectable, rowSelection, setRowSelection]);
-
- // ── Pagination helpers ──────────────────────────────────────────────────────
-
- const getPageCount = useCallback(
- () => (opts.totalCount != null ? Math.ceil(opts.totalCount / pagination.pageSize) : 0),
- [opts.totalCount, pagination.pageSize],
- );
-
- const getCanNextPage = useCallback(
- () => pagination.pageIndex < getPageCount() - 1,
- [pagination.pageIndex, getPageCount],
- );
-
- const getCanPreviousPage = useCallback(() => pagination.pageIndex > 0, [pagination.pageIndex]);
-
- const nextPage = useCallback(() => {
- if (getCanNextPage()) {
- setPagination(p => ({ ...p, pageIndex: p.pageIndex + 1 }));
- }
- }, [getCanNextPage, setPagination]);
-
- const previousPage = useCallback(
- () => setPagination(p => ({ ...p, pageIndex: Math.max(0, p.pageIndex - 1) })),
- [setPagination],
- );
-
- const setPageSize = useCallback(
- (size: number) => {
- if (size > 0) {
- setPagination(() => ({ pageIndex: 0, pageSize: size }));
- }
- },
- [setPagination],
- );
-
- // ── Selection helpers ───────────────────────────────────────────────────────
-
- const selectableRows = useMemo(
- () => rows.filter(row => !isRowSelectable || isRowSelectable(row.original)),
- [rows, isRowSelectable],
- );
-
- const getIsAllRowsSelected = useCallback(
- () => selectableRows.length > 0 && selectableRows.every(r => !!rowSelection[r.id]),
- [selectableRows, rowSelection],
- );
-
- const getIsSomeRowsSelected = useCallback(
- () => selectableRows.some(r => !!rowSelection[r.id]) && !getIsAllRowsSelected(),
- [selectableRows, rowSelection, getIsAllRowsSelected],
- );
-
- const toggleAllRowsSelected = useCallback(() => {
- setRowSelection(getIsAllRowsSelected() ? {} : Object.fromEntries(selectableRows.map(r => [r.id, true])));
- }, [selectableRows, getIsAllRowsSelected, setRowSelection]);
-
- return {
- rows,
-
- sorting,
- setSorting,
-
- columnFilters,
- setColumnFilters,
-
- globalFilter,
- setGlobalFilter,
-
- pagination,
- setPagination,
- nextPage,
- previousPage,
- setPageSize,
- getCanNextPage,
- getCanPreviousPage,
- getPageCount,
-
- rowSelection,
- setRowSelection,
- getIsAllRowsSelected,
- getIsSomeRowsSelected,
- toggleAllRowsSelected,
- };
-}
diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts
index f1b9be095f5..89c6b5bf22d 100644
--- a/packages/mosaic/src/styles/index.ts
+++ b/packages/mosaic/src/styles/index.ts
@@ -107,13 +107,13 @@ export type {
TableAlign,
TableBodyProps,
TableCellProps,
- TableEmptyProps,
- TableHeaderCellProps,
+ TableColumnProps,
+ TableExpandButtonProps,
TableHeaderProps,
- TableProps,
+ TableRootProps,
TableRowProps,
- TableSelectAllCellProps,
- TableSelectCellProps,
+ TableSelectionCellProps,
+ TableSelectionColumnProps,
TableSort,
} from '../components/table';
export { SegmentedControl } from '../components/segmented-control';
diff --git a/packages/mosaic/vitest.config.mts b/packages/mosaic/vitest.config.mts
index efca41d2e4e..4d9b79a4415 100644
--- a/packages/mosaic/vitest.config.mts
+++ b/packages/mosaic/vitest.config.mts
@@ -4,6 +4,8 @@ import react from '@vitejs/plugin-react';
import { resolve } from 'node:path';
import { defineConfig } from 'vitest/config';
+import { mosaicLightningCssTargets } from './stylex-lightningcss.config.mjs';
+
const mosaicPath = resolve(import.meta.dirname, 'src');
const featureTests = 'src/**/*.feature.test.tsx';
const webkitTests = 'src/**/*.webkit.test.tsx';
@@ -11,7 +13,11 @@ const browserTests = 'src/**/*.browser.test.tsx';
export default defineConfig({
plugins: [
- stylex({ dev: true, unstable_moduleResolution: { type: 'commonJS', rootDir: mosaicPath } }),
+ stylex({
+ dev: true,
+ unstable_moduleResolution: { type: 'commonJS', rootDir: mosaicPath },
+ lightningcssOptions: { targets: mosaicLightningCssTargets },
+ }),
react({ jsxRuntime: 'automatic' }),
],
test: {
diff --git a/packages/swingset/CLAUDE.md b/packages/swingset/CLAUDE.md
index dace8b2bab2..66a5eb528a8 100644
--- a/packages/swingset/CLAUDE.md
+++ b/packages/swingset/CLAUDE.md
@@ -22,14 +22,14 @@ Swingset has no tests, and none should be added. Don't add test files, a Vitest
These require reading several files together; the `README.md` covers the step-by-step "add a component" workflow.
-- **Consumes Mosaic from source, not build.** `@clerk/mosaic` is aliased to `../mosaic/src` in *two* places that must stay in sync: `next.config.mjs` (webpack `resolve.alias`) and `tsconfig.json` (`paths`). Editing Mosaic source in `packages/mosaic` reflects live in swingset's dev server — no rebuild of `@clerk/mosaic` needed.
+- **Consumes Mosaic from source, not build.** `@clerk/mosaic` is aliased to `../mosaic/src` in _two_ places that must stay in sync: `next.config.mjs` (webpack `resolve.alias`) and `tsconfig.json` (`paths`). Editing Mosaic source in `packages/mosaic` reflects live in swingset's dev server — no rebuild of `@clerk/mosaic` needed.
- **Knobs are generated from a story's declared variant surface.** A story's `meta.styles` is a hand-written `{ _variants, _defaultVariants }` object describing the component's variant props — StyleX compiles its styles away, so there is no runtime recipe to derive this from. `lib/generateKnobs.ts` turns each variant into a control: variants whose keys are only `true`/`false` become boolean toggles, everything else becomes a select. Knob values are passed as props straight into the story component. This is why story functions take `Record` and cast to the real prop type.
-- **`lib/registry.ts` is the single source of truth for which components exist**, and they are imported *explicitly* (never `import *`) so sidebar order is deterministic. `getSidebarGroups`, `getModuleBySlug`, and slugging (`lib/slug.ts`, from `meta.title`) read from it. Adding a component touches up to three wiring points: `registry.ts` (sidebar entry + per-page playground lookup), `DocsViewer.tsx`'s `docModules` map (MDX docs), and the hardcoded redirect in `app/(explorer)/page.tsx`.
- - ⚠️ **Add each new import and its first usage in the same edit.** The on-save lint-fix (`unused-imports/no-unused-imports` is an `error`) deletes any import that isn't referenced yet, so importing a story export in `registry.ts` (or a component in a `*.stories.tsx`) *before* the code that uses it silently drops the import and you get `X is not defined` at runtime. After wiring, `grep` the new symbol to confirm both the import and its use survived. (Repo-wide footgun; see `clerk-monorepo` skill `references/setup-and-footguns.md`.)
+- **`lib/registry.ts` is the single source of truth for which components exist**, and they are imported _explicitly_ (never `import *`) so sidebar order is deterministic. `getSidebarGroups`, `getModuleBySlug`, and slugging (`lib/slug.ts`, from `meta.title`) read from it. Adding a component touches up to three wiring points: `registry.ts` (sidebar entry + per-page playground lookup), `DocsViewer.tsx`'s `docModules` map (MDX docs), and the hardcoded redirect in `app/(explorer)/page.tsx`.
+ - ⚠️ **Add each new import and its first usage in the same edit.** The on-save lint-fix (`unused-imports/no-unused-imports` is an `error`) deletes any import that isn't referenced yet, so importing a story export in `registry.ts` (or a component in a `*.stories.tsx`) _before_ the code that uses it silently drops the import and you get `X is not defined` at runtime. After wiring, `grep` the new symbol to confirm both the import and its use survived. (Repo-wide footgun; see `clerk-monorepo` skill `references/setup-and-footguns.md`.)
-- **Routing.** Route groups split the app: `(explorer)` is the component playground, `(clerk)` is the Live Sandbox (`/live`, `/sign-in`, `/sign-up`) and needs keys from `packages/swingset/.env.local`. Each component is a single page: `/components/[component]` renders its MDX overview via `DocsViewer`. There are no per-story sub-pages — the interactive playground lives *inside* the overview. `app/(explorer)/page.tsx` is a static redirect (currently to `/components/button`) because `registry.ts` eagerly imports story modules (client components / `createContext`), so registry-derived data can't be computed in a Server Component. `DocsViewer` also renders a "View source" link (`ViewSource.tsx`) from `meta.source` — a repo-root-relative path turned into a GitHub URL by `lib/source.ts`.
+- **Routing.** Route groups split the app: `(explorer)` is the component playground, `(clerk)` is the Live Sandbox (`/live`, `/sign-in`, `/sign-up`) and needs keys from `packages/swingset/.env.local`. Each component is a single page: `/components/[component]` renders its MDX overview via `DocsViewer`. There are no per-story sub-pages — the interactive playground lives _inside_ the overview. `app/(explorer)/page.tsx` is a static redirect (currently to `/components/button`) because `registry.ts` eagerly imports story modules (client components / `createContext`), so registry-derived data can't be computed in a Server Component. `DocsViewer` also renders a "View source" link (`ViewSource.tsx`) from `meta.source` — a repo-root-relative path turned into a GitHub URL by `lib/source.ts`.
- **Live Sandbox pages.** Every `app/(clerk)/live/*/page.tsx` opens with `LivePageHeader` (`app/(clerk)/live-page-header.tsx`), which renders the page's `h1` and, beside it, the entry's status badge (`StatusBadge`) looked up from the live route map in `lib/live-navigation.ts`. The badge belongs in the title row, where the reader is looking, mirroring the docs pages' `DocTitle` — never in the sticky header chrome. Don't hand-write an `h1` on a live page; pass the title and description to `LivePageHeader` so the badge stays attached. The live sidebar is also the inventory for the User Profile and Organization Profile groups: every Panels/Sections entry from the registry appears there, and one without a route in `liveRoutes` renders as a gray `todo` placeholder (`live/todo/[group]/[component]`, a dashed gray box labelled TODO). When you add a live harness for one, add its route to `liveRoutes` and the placeholder disappears.
@@ -43,14 +43,14 @@ These require reading several files together; the `README.md` covers the step-by
- **MDX.** `mdx-components.tsx` injects custom components into all MDX: `` (→ `StoryPreview`), `` (→ `StoryEmbed`, static), `` (→ interactive `PropTable`), `` (→ `UsageBlock`, a live code snippet that reflects the current knob values), and a `` override routing fenced code through Shiki (`CodeBlock`). `next.config.mjs` configures `remark-gfm` and `rehype-raw` (with MDX node pass-through) so raw HTML in tables works.
-- **`` examples can show their source in a collapsible code footer.** When a story module exposes its own source as `__source` — via a `?raw` self-import (`export { default as __source } from './x.stories?raw'`) — `StoryEmbed` runs `extractStorySource` (`lib/extractStorySource.ts`) to pull the *previewed story function's* source out of that raw text, then `toUsageSnippet` (`lib/exampleSnippet.ts`) to reduce that knob harness to a clean usage snippet (unwraps `export function …() { return (…) }` down to the returned JSX and strips the `{...knobsAsProps(props)}` / `{...props}` knob plumbing), and renders a `CodeFooter` (`CodeFooter.tsx`): a "View code" toggle that's collapsed by default and reveals the snippet with a height animation (Base UI's `--collapsible-panel-height` + `data-starting/ending-style`). It's **opt-in per module** — only modules that export `__source` get a footer, and it's keyed to whichever story `name` the `` renders, so each example shows its own code. Shiki highlighting is shared with the ``/`CodeBlock` path through the `useShikiHtml` hook. A `` can carry both a code footer and a `composition` footer; they stack under the preview.
- - The `?raw` query is wired in `next.config.mjs`: an `asset/source` rule handles `?raw` imports, and — crucially — a recursive `excludeRawQuery` pass adds `resourceQuery: { not: [/raw/] }` to every *other* loader so Next's SWC loader doesn't compile the file first (otherwise `__source` would contain `_jsxDEV(…)` output instead of the authored source).
+- **`` examples can show their source in a collapsible code footer.** When a story module exposes its own source as `__source` — via a `?raw` self-import (`export { default as __source } from './x.stories?raw'`) — `StoryEmbed` runs `extractStorySource` (`lib/extractStorySource.ts`) to pull the _previewed story function's_ source out of that raw text, then `toUsageSnippet` (`lib/exampleSnippet.ts`) to reduce that knob harness to a clean usage snippet (unwraps `export function …() { return (…) }` down to the returned JSX and strips the `{...knobsAsProps(props)}` / `{...props}` knob plumbing), and renders a `CodeFooter` (`CodeFooter.tsx`): a "View code" toggle that's collapsed by default and reveals the snippet with a height animation (Base UI's `--collapsible-panel-height` + `data-starting/ending-style`). It's **opt-in per module** — only modules that export `__source` get a footer, and it's keyed to whichever story `name` the `` renders, so each example shows its own code. Shiki highlighting is shared with the ``/`CodeBlock` path through the `useShikiHtml` hook. A `` can carry both a code footer and a `composition` footer; they stack under the preview.
+ - The `?raw` query is wired in `next.config.mjs`: an `asset/source` rule handles `?raw` imports, and — crucially — a recursive `excludeRawQuery` pass adds `resourceQuery: { not: [/raw/] }` to every _other_ loader so Next's SWC loader doesn't compile the file first (otherwise `__source` would contain `_jsxDEV(…)` output instead of the authored source).
-- **Two component layers.** `src/components/ui/*` are shadcn/ui primitives (`components.json`, `base-nova` style, neutral base) used for swingset's *own* chrome (sidebar, tabs, inputs). The components being *documented* come from `@clerk/mosaic`. Don't confuse the two.
+- **Two component layers.** `src/components/ui/*` are shadcn/ui primitives (`components.json`, `base-nova` style, neutral base) used for swingset's _own_ chrome (sidebar, tabs, inputs). The components being _documented_ come from `@clerk/mosaic`. Don't confuse the two.
## Documenting Mosaic components
-The facts above explain *how the app works* and `README.md` covers the *mechanical wiring* of adding a component (registry, `docModules`, redirect). This section is the **house style** — *what to write* once the wiring is in place, so every component page reads consistently.
+The facts above explain _how the app works_ and `README.md` covers the _mechanical wiring_ of adding a component (registry, `docModules`, redirect). This section is the **house style** — _what to write_ once the wiring is in place, so every component page reads consistently.
A component's docs are two files in `src/stories/`:
@@ -63,25 +63,25 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f
`meta.group` places an entry in one of these layers. Group order follows first appearance in the `registry` array. The sidebar sorts `Blocks`, `Components`, `Primitives`, `Styles`, and `Hooks` alphabetically by `title`; `User Button`, `Organization Switcher`, `User Profile`, `Reverification`, and `Localization` render in registry order. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings:
-| Group | What lives here | Archetype |
-| ------------ | -------------------------------------------------------------- | --------- |
-| `User Button` | Composed flow UI (e.g. `UserButton`) | C |
-| `Organization Switcher` | Composed flow UI (e.g. `OrganizationSwitcher`) | C |
-| `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C |
-| `Components` | Styled Mosaic components — simple, with a flat variant surface (`Button`, `Input`), or compound (`Card`, `Field`, `Menu`, `Popover`) | A |
-| `Primitives` | Headless primitives from `@clerk/mosaic`'s `src/primitives/` (`Accordion`) | B |
-| `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) |
-| `Hooks` | Headless hooks (`useDataTable`) | B (adapted) |
-| `Localization` | The `localization` prop on `MosaicProvider`: catalogs, overrides, locale, and the message helpers | B (adapted) |
+| Group | What lives here | Archetype |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
+| `User Button` | Composed flow UI (e.g. `UserButton`) | C |
+| `Organization Switcher` | Composed flow UI (e.g. `OrganizationSwitcher`) | C |
+| `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C |
+| `Components` | Styled Mosaic components — simple, with a flat variant surface (`Button`, `Input`), or compound (`Card`, `Field`, `Menu`, `Popover`) | A |
+| `Primitives` | Headless primitives from `@clerk/mosaic`'s `src/primitives/` (`Accordion`) | B |
+| `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) |
+| `Hooks` | Headless hooks (`useForm`) | B (adapted) |
+| `Localization` | The `localization` prop on `MosaicProvider`: catalogs, overrides, locale, and the message helpers | B (adapted) |
`User Button` / `User Profile` → `Components` → `Primitives` runs high-level-composition → low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B).
`Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow
archetype B's shape (Example → Usage → Parts → Styling) with `Props` replaced by whatever the export
actually surfaces — an argument table for a style function, a return-value table for a hook. A
-`Styles` entry documents the tokens its atoms read, since those tokens _are_ its API; the
-`Hooks` entry (`use-data-table.stories.tsx`) is `meta` alone, with no story exports at all, which is
-the minimum a section entry needs.
+`Styles` entry documents the tokens its atoms read, since those tokens _are_ its API; a
+`Hooks` entry needs only `meta`, with no story exports at all, which is the minimum a section entry
+needs.
Archetype A has two forms, chosen by whether the component exposes a single flat set of variant props: **simple** components (`Button`, `Input`) are knob-driven; **compound** components (`Card`, `Field`, `Menu`, `Popover`) have no flat variant props to knob, so they're documented like a primitive but themed. Both are detailed under Archetype A below.
@@ -104,7 +104,7 @@ export const meta: StoryMeta = {
- `title` is the component's export name; it produces the slug and is what readers match against code. Set `label` only when the sidebar should read differently (the slug and page heading still come from `title`).
- `status` marks the entry's maturity — `'stable'`, `'wip'`, or `'todo'` (planned, not yet implemented; e.g. `Table`) — rendered as a colored dot (green / amber / gray) to the left of the entry in the sidebar, and as a dot-plus-label badge to the right of the page's `# Title` h1. It is explicit: omitting it displays no status anywhere (not every entry carries one, e.g. `Scroll Area`). A todo entry is `meta` alone plus a one-paragraph MDX page; it skips its archetype's required sections (and `source`) until the component exists.
-- `substatus` optionally says *why* a wip entry is wip — one of `'needs wire-up'` (view runs on mock data, not yet connected to its model/controller), `'needs reverification'` (wired to FAPI, but a sensitive action still runs without session reverification), `'needs design'`, `'needs a11y'`, `'needs docs'`, `'needs polish'`. It renders in the page badge (`wip: needs wire-up`) and the sidebar dot's tooltip. Most wip entries won't need one; it is ignored on stable entries.
+- `substatus` optionally says _why_ a wip entry is wip — one of `'needs wire-up'` (view runs on mock data, not yet connected to its model/controller), `'needs reverification'` (wired to FAPI, but a sensitive action still runs without session reverification), `'needs design'`, `'needs a11y'`, `'needs docs'`, `'needs polish'`. It renders in the page badge (`wip: needs wire-up`) and the sidebar dot's tooltip. Most wip entries won't need one; it is ignored on stable entries.
- `source` is always a path **relative to the monorepo root**, pointing at the file that exports the documented component. Always set it — it powers the "View source" link.
- `styles` declares the component's variant props and is **required for archetype A's simple (knob-driven) form** (it generates the knobs and the ``). Keep it in sync with the component's real prop union by hand — StyleX compiles its styles away, so nothing derives it for you. Omit it for compound A components, and for B and C.
@@ -194,7 +194,10 @@ import * as PopoverStories from './popover.component.stories';
## Example
-
+
## Usage
diff --git a/packages/swingset/src/components/ClientRoot.tsx b/packages/swingset/src/components/ClientRoot.tsx
index a2adc6c54a0..6a3ba3618a2 100644
--- a/packages/swingset/src/components/ClientRoot.tsx
+++ b/packages/swingset/src/components/ClientRoot.tsx
@@ -31,8 +31,8 @@ function useBreadcrumb() {
const [groupSlug, ...parts] = pathname.split('/').filter(Boolean);
// Prefer the registry's own `meta.title`, which is the only source that round-trips a slug back
- // to how the entry is actually written — `scroll-area` → `Scroll Area`, `use-data-table` →
- // `useDataTable`. Fall back to title-casing the slug for any path the registry doesn't cover.
+ // to how the entry is actually written — `scroll-area` → `Scroll Area`, `use-form` →
+ // `useForm`. Fall back to title-casing the slug for any path the registry doesn't cover.
return parts.map((part, index) => {
const title = index === 0 ? getModule(groupSlug, part)?.meta.title : undefined;
return title ?? part.replace(/(^|-)([a-z])/g, (_, sep: string, ch: string) => (sep ? ' ' : '') + ch.toUpperCase());
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index 08de7a9d7e2..66e3234b6da 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -132,9 +132,7 @@ const docModules: Record> = {
},
hooks: {
// Headless hooks — alphabetical.
- 'use-data-table': dynamic(() => import('../stories/use-data-table.mdx')),
'use-form': dynamic(() => import('../stories/use-form.mdx')),
- 'use-server-data-table': dynamic(() => import('../stories/use-server-data-table.mdx')),
},
localization: {
localization: dynamic(() => import('../stories/localization.mdx')),
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index ae4460c42d3..088e9994198 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -338,12 +338,7 @@ import {
Placement as TooltipComponentPlacement,
} from '../stories/tooltip.component.stories';
import { meta as tooltipMeta } from '../stories/tooltip.stories';
-import { meta as useDataTableMeta } from '../stories/use-data-table.stories';
import { Default as UseFormDefault, meta as useFormMeta } from '../stories/use-form.stories';
-import {
- Default as UseServerDataTableDefault,
- meta as useServerDataTableMeta,
-} from '../stories/use-server-data-table.stories';
import {
Combined as UserButtonCombined,
meta as userButtonMeta,
@@ -792,12 +787,8 @@ const scrollAreaModule: StoryModule = {
ShadowIndicators: ScrollAreaShadowIndicators,
};
-const useDataTableModule: StoryModule = { meta: useDataTableMeta };
-
const useFormModule: StoryModule = { meta: useFormMeta, Default: UseFormDefault };
-const useServerDataTableModule: StoryModule = { meta: useServerDataTableMeta, Default: UseServerDataTableDefault };
-
const localizationModule: StoryModule = {
meta: localizationMeta,
Overrides: LocalizationOverrides,
@@ -1126,9 +1117,7 @@ export const registry: StoryModule[] = [
// Styles — atomic styles that ship as StyleX atoms rather than components.
scrollAreaModule,
// Hooks
- useDataTableModule,
useFormModule,
- useServerDataTableModule,
// Localization
localizationModule,
];
diff --git a/packages/swingset/src/stories/action-bar.stories.tsx b/packages/swingset/src/stories/action-bar.stories.tsx
index ff48c9c6dc5..ff822d7afe9 100644
--- a/packages/swingset/src/stories/action-bar.stories.tsx
+++ b/packages/swingset/src/stories/action-bar.stories.tsx
@@ -9,7 +9,6 @@ import { Table } from '@clerk/mosaic/components/table';
import { ChevronDown } from '@clerk/mosaic/icons/glyphs/chevron-down';
import { Trash } from '@clerk/mosaic/icons/glyphs/trash';
import { Users } from '@clerk/mosaic/icons/glyphs/users';
-import { useDataTable } from '@clerk/mosaic/primitives/hooks';
import type { ComponentProps } from 'react';
import { useId, useRef, useState } from 'react';
@@ -50,44 +49,39 @@ function MembersTable({ rows = 10, portalRoot }: { rows?: number; portalRoot?: H
const tableId = useId();
const tableRef = useRef(null);
const selectAllRef = useRef(null);
- const table = useDataTable({ data: members.slice(0, rows), getRowId: row => row.id });
- const count = Object.values(table.rowSelection).filter(Boolean).length;
- const clearSelection = () => table.setRowSelection({});
+ const [selection, setSelection] = useState([]);
+ const count = selection.length;
+ const clearSelection = () => setSelection([]);
return (
<>
-
- table.toggleAllRowsSelected()}
- />
- Name
- Role
-
+
+ Name
+ Role
-
- {table.rows.map(row => (
-
-
- {row.original.name}
- {row.original.role}
+ member.id}
+ >
+ {member => (
+
+
+ {member.name}
+ {member.role}
- ))}
+ )}
diff --git a/packages/swingset/src/stories/api-keys-table.stories.tsx b/packages/swingset/src/stories/api-keys-table.stories.tsx
index 2e3afc3fbc8..cab0f2ada8d 100644
--- a/packages/swingset/src/stories/api-keys-table.stories.tsx
+++ b/packages/swingset/src/stories/api-keys-table.stories.tsx
@@ -54,7 +54,7 @@ export function Empty() {
}
export function ProposedTable() {
- const props = useAPIKeysTableFixture({ enableSorting: true });
+ const props = useAPIKeysTableFixture();
return (
{
- if (sort.column === 'name') {
- return direction * a.name.localeCompare(b.name);
- }
- if (sort.column === 'createdAt') {
- return direction * (a.createdAt - b.createdAt);
- }
- if (a.lastUsedAt === null) {
- return b.lastUsedAt === null ? 0 : 1;
- }
- if (b.lastUsedAt === null) {
- return -1;
- }
- return direction * (a.lastUsedAt - b.lastUsedAt);
- });
-}
-
async function createExampleAPIKey() {
await new Promise(resolve => setTimeout(resolve, 600));
return { id: `ak_demo_${crypto.randomUUID()}`, secret: `ak_demo_${crypto.randomUUID()}` };
@@ -80,12 +57,10 @@ async function revokeExampleAPIKey() {
export function useAPIKeysTableFixture({
initialKeys = exampleAPIKeys,
- enableSorting = false,
subjectKind = 'user',
showDescription = false,
}: {
initialKeys?: FixtureAPIKey[];
- enableSorting?: boolean;
subjectKind?: APIKeysTableSubjectKind;
showDescription?: boolean;
} = {}): APIKeysTableViewProps {
@@ -98,7 +73,6 @@ export function useAPIKeysTableFixture({
const [searchValue, setSearchValue] = useState('');
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
- const [sort, setSort] = useState(null);
const [pageSize, setPageSize] = useState(10);
const dateLabel = (date: Date | number) =>
new Intl.DateTimeFormat(locale, {
@@ -141,14 +115,13 @@ export function useAPIKeysTableFixture({
}, [searchValue, query]);
const filtered = items.filter(item => item.name.toLowerCase().includes(query.toLowerCase()));
- const sorted = sortAPIKeys(filtered, sort);
const pageCount = Math.ceil(filtered.length / pageSize);
useEffect(() => {
if (page > Math.max(1, pageCount)) {
setPage(Math.max(1, pageCount));
}
}, [page, pageCount]);
- const apiKeys = sorted.slice((page - 1) * pageSize, page * pageSize).map(item => ({
+ const apiKeys = filtered.slice((page - 1) * pageSize, page * pageSize).map(item => ({
id: item.id,
name: item.name,
createdAtLabel: dateLabel(item.createdAt),
@@ -190,13 +163,6 @@ export function useAPIKeysTableFixture({
onSearchChange: setSearchValue,
onPageChange: setPage,
onPageSizeChange: setPageSize,
- sort: enableSorting ? sort : undefined,
- onSortChange: enableSorting
- ? next => {
- setSort(next);
- setPage(1);
- }
- : undefined,
};
}
diff --git a/packages/swingset/src/stories/table.mdx b/packages/swingset/src/stories/table.mdx
index 09a23032c47..fef3c8b6890 100644
--- a/packages/swingset/src/stories/table.mdx
+++ b/packages/swingset/src/stories/table.mdx
@@ -2,9 +2,9 @@ import * as TableStories from './table.stories';
# Table
-Table renders tabular data in a bordered Mosaic shell. It's composed from parts via dot syntax: `Table.Root` renders the `` inside a shell that scrolls horizontally when the columns outgrow the container, and `Table.Header`, `Table.Body`, `Table.Row`, `Table.HeaderCell`, and `Table.Cell` map onto their native elements.
+Table renders tabular data in a bordered Mosaic shell. It styles the headless [Table primitive](/primitives/table), which owns selection, tree expansion, sort state and keyboard navigation. `Table.Root` renders the `` inside a shell that scrolls horizontally when the columns outgrow the container.
-Sorting, selection, and the empty state are opt-in parts rather than state the table owns. Pair it with the [useDataTable](/hooks/use-data-table) hook, which holds sorting and row selection, and apply the sort to your rows before rendering them. Pagination is rendered by the consumer below the table with the Pagination component.
+The table does not sort, filter or page the items. Sort them yourself or on the server, and render [Pagination](/components/pagination) below the table.
## Example
@@ -28,20 +28,21 @@ import { Table } from '@clerk/mosaic/components/table';
/>
-
+
-
- Name
- Created
-
+ Name
+ Joined
-
- {rows.map(row => (
-
- {row.name}
- {row.created}
+ member.id}
+ >
+ {member => (
+
+ {member.name}
+ {member.joined}
- ))}
+ )}
>;
@@ -51,37 +52,35 @@ import { Table } from '@clerk/mosaic/components/table';
- `Table.Toolbar` wraps search and optional actions in a responsive row. Render it before `Table.Root`, outside the table.
- `Table.Search` composes [InputGroup](/components/input-group), [Icon](/components/icon), and [Button](/components/button). Supply the controlled `value`, `onValueChange`, and localized `label` and `clearLabel`. Clearing returns focus to the input. Its `ref`, `xstyle`, and input props apply to the search input; `placeholder` defaults to `label`. The caller owns filtering and any debounce.
-- `Table.Root` renders the ``. `xstyle`, `ref`, and any table props land on it; the scrolling shell around it is internal. `layout` defaults to `'auto'`, where columns size to their content; `'fixed'` sizes them from the column group instead.
-- `Table.ColumnGroup` renders a ``. Put it first inside `Table.Root`, with one column part per column.
-- `Table.Column` renders a ` `. Pass `width` (any CSS length) to set the column's width. A column without `width` takes the space the others leave, unless the cell in its first row sets its own width, as `Table.SelectAllCell` does.
-- `Table.Header` and `Table.Body` render ` ` and ` `. Rows inside the body get a top border.
-- `Table.Row` renders a ``. Pass `selected` to mark it `aria-selected` and tint it. Pass `onClick` to make the whole row clickable: it gets a pointer cursor and a hover tint, and clicks on links, buttons, and other controls inside the row are left to those controls. The row is not focusable, so put a button or link with the same action in its first cell for keyboard users, and have `onClick` click that control so a dialog it opens returns focus to it on close.
-- `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.HeaderCell` and `Table.Cell` take `sticky` (`'start'` or `'end'`) to pin the cell to that edge while the table scrolls horizontally. Set it on every cell in the column. A pinned cell gets an opaque background that matches its row and a fade on its inner edge.
-- `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.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.
+- `Table.Root` renders the `` and takes the primitive's selection, expanded and sort props. `xstyle`, `ref`, and any table props land on it; the scrolling shell around it is internal. `layout` defaults to `'auto'`, where columns size to their content; `'fixed'` sizes them from the column widths instead. `minWidth` sets the width below which the table scrolls.
+- `Table.Header` renders the `` and its row. Put the columns directly inside it.
+- `Table.Column` renders a ``. Give it a `value` and `sortable` to sort by it: the header becomes the control and shows a direction icon. `rowHeader` renders the column's cells as row headers that name their rows. `width` takes any CSS length. `align` moves the content to `'center'` or `'end'`.
+- `Table.Body` renders a row per item from `items`, keyed by `itemToStringValue`. `itemToChildren` makes the table a tree. `renderEmptyState` renders when there are no items, in one cell that spans every column. Put an [EmptyState](/components/empty-state) inside it.
+- `Table.Row` renders a ` `. Selected rows are tinted. `disabled` removes the row from navigation and selection. `onClick` or `href` gives the row an action: it gets a pointer cursor and a hover tint, and `Enter` runs it.
+- `Table.Cell` renders a ``, or a ` ` in a `rowHeader` column. It takes the same `align` prop. `noWrap` keeps dates and other short values on one line.
+- `Table.Column` and `Table.Cell` take `sticky` (`'start'` or `'end'`) to pin the cell to that edge while the table scrolls horizontally. Set it on every cell in the column. A pinned cell gets an opaque background that matches its row and a fade on its inner edge.
+- `Table.SelectionColumn` and `Table.SelectionCell` render a narrow header or body cell holding a [Checkbox](/components/checkbox) that selects all rows or its row. They render only when `Table.Root` has a `selectionMode`. Both need an `aria-label`; a row's checkbox is also named by the row headers, so `'Select'` reads as "Select Web app".
+- `Table.ExpandButton` toggles its row's children in a tree. It renders only when the row can expand, and needs an `aria-label`. Indent the cell content with the row's `--table-row-depth`.
## 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` | `data-layout` (`auto` / `fixed`) |
-| `.cl-table-toolbar` | `Table.Toolbar` | |
-| `.cl-table-search` | `Table.Search` input group | |
-| `.cl-table-column-group` | `Table.ColumnGroup` | |
-| `.cl-table-column` | `Table.Column` | |
-| `.cl-table-header` | `Table.Header` | |
-| `.cl-table-body` | `Table.Body` | |
-| `.cl-table-row` | `Table.Row` | `data-selected` when `selected`, `data-interactive` when `onClick` is set |
-| `.cl-table-header-cell` | `Table.HeaderCell` | `data-align` (`start` / `center` / `end`), `data-sticky` (`start` / `end`), `data-sortable` when `onSort` is set, `data-sort` (`ascending` / `descending`) |
-| `.cl-table-cell` | `Table.Cell` | `data-align` (`start` / `center` / `end`), `data-sticky` (`start` / `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 | |
+| `.cl-table-viewport` | The horizontally scrolling viewport | |
+| `.cl-table` | `Table.Root` | `data-layout` (`auto` / `fixed`) |
+| `.cl-table-toolbar` | `Table.Toolbar` | |
+| `.cl-table-search` | `Table.Search` input group | |
+| `.cl-table-header` | `Table.Header` | |
+| `.cl-table-column` | `Table.Column` | `data-align`, `data-sticky`, `data-sortable` when sortable, `data-sort-direction` (`ascending` / `descending`) |
+| `.cl-table-body` | `Table.Body` | |
+| `.cl-table-row` | `Table.Row` | `data-selected`, `data-disabled`, `data-expanded`, `data-actionable` when it has an action |
+| `.cl-table-cell` | `Table.Cell` | `data-align` (`start` / `center` / `end`), `data-sticky` (`start` / `end`), `data-no-wrap` when `noWrap` |
+| `.cl-table-select-cell` | `Table.SelectionColumn`, `Table.SelectionCell` | |
+| `.cl-table-expand-button` | `Table.ExpandButton` | `data-expanded`, `data-collapsed` |
+| `.cl-table-empty` | The content wrapper around `renderEmptyState` | |
```css
.cl-table-row[data-selected] {
@@ -104,7 +103,7 @@ The toolbar places the search field beside optional actions and wraps on narrow
### Sorting
-Each sortable header cycles through ascending, descending, and unsorted. The story keeps sorting in `useDataTable` and sorts the rows itself before rendering.
+Activating a sortable column sorts by it ascending, then flips the direction. The story keeps the sort in state and sorts the items before rendering them.
+### Tree rows
+
+`itemToChildren` makes the table a tree. `Table.ExpandButton` shows and hides a row's children, and `ArrowRight` / `ArrowLeft` expand and collapse the focused row.
+
+
+
### Overflow
Wide tables scroll horizontally inside the shell instead of breaking the page layout. The viewport is the shared [Scroll Area](/styles/scroll-area) on its `inline` axis, so the left and right edges fade while there is more to reveal and the scrollbar matches the rest of Mosaic.
@@ -147,7 +155,7 @@ Wide tables scroll horizontally inside the shell instead of breaking the page la
### Column widths
-With `layout='fixed'`, the widths in `Table.ColumnGroup` decide the columns, so they keep their size when the rows change. Here the select column has no `width`, so it takes the select cells' width, and the name column takes the remaining space. A column without `width` can shrink to nothing in a narrow table, so give `Table.Root` a `minWidth` through `xstyle`: below it, the table scrolls instead.
+With `layout='fixed'`, the column `width`s decide the columns, so they keep their size when the rows change. The selection column has a fixed width of its own, and the name column takes the remaining space. A column without `width` can shrink to nothing in a narrow table, so give `Table.Root` a `minWidth`: below it, the table scrolls instead.
` example
// renders a code footer with its function's source. See `StoryModule.__source`.
export { default as __source } from './table.stories?raw';
@@ -89,114 +82,76 @@ const sortKeys = {
created: (key: ApiKey) => key.createdAt,
};
-type SortableColumn = keyof typeof sortKeys;
-
-const isSortableColumn = (id: string): id is SortableColumn => id in sortKeys;
+const isSortableColumn = (column: string): column is keyof typeof sortKeys => column in sortKeys;
function compare(a: string | number, b: string | number) {
return typeof a === 'string' || typeof b === 'string' ? String(a).localeCompare(String(b)) : a - b;
}
-function sortFor(sorting: SortingState, column: SortableColumn): TableSort {
- const current = sorting[0];
- if (!current || current.id !== column) {
- return 'none';
- }
- return current.desc ? 'descending' : 'ascending';
-}
-
-function cycleSort(setSorting: OnChangeFn, column: SortableColumn) {
- setSorting(sorting => {
- const current = sorting[0];
- if (!current || current.id !== column) {
- return [{ id: column, desc: false }];
- }
- return current.desc ? [] : [{ id: column, desc: true }];
- });
-}
-
-function sortRows(rows: ApiKey[], sorting: SortingState) {
- const current = sorting[0];
- if (!current || !isSortableColumn(current.id)) {
+function sortRows(rows: ApiKey[], sort: TableSort | null) {
+ if (!sort || !isSortableColumn(sort.column)) {
return rows;
}
- const key = sortKeys[current.id];
+ const key = sortKeys[sort.column];
const sorted = [...rows].sort((a, b) => compare(key(a), key(b)));
- return current.desc ? sorted.reverse() : sorted;
+ return sort.direction === 'descending' ? sorted.reverse() : sorted;
}
-function useSortedApiKeys(data: ApiKey[]) {
- const [sorting, setSorting] = React.useState([]);
- const sorted = React.useMemo(() => sortRows(data, sorting), [data, sorting]);
- return { sorting, setSorting, sorted };
-}
+const keyValue = (key: ApiKey) => key.id;
export function Default() {
- const { sorting, setSorting, sorted } = useSortedApiKeys(apiKeys);
- const table = useDataTable({
- data: sorted,
- getRowId: row => row.id,
- sorting,
- onSortingChange: setSorting,
- });
+ const [sort, setSort] = React.useState(null);
return (
-
+
-
- table.toggleAllRowsSelected()}
- />
- cycleSort(table.setSorting, 'name')}
- >
- Name
-
- cycleSort(table.setSorting, 'lastUsed')}
- >
- Last used
-
- cycleSort(table.setSorting, 'created')}
- >
- Created
-
-
- Actions
-
-
+
+
+ Name
+
+
+ Last used
+
+
+ Created
+
+
+ Actions
+
-
- {table.rows.map(row => (
-
-
+
+ {key => (
+
+
- {row.original.name}
-
- {row.original.environment}
-
+ {key.name}
+ {key.environment}
- {row.original.lastUsed}
- {row.original.created}
+ {key.lastUsed}
+ {key.created}
-
+
Revoke
@@ -205,7 +160,7 @@ export function Default() {
- ))}
+ )}
);
@@ -227,25 +182,23 @@ export function Search() {
-
- Name
- Created
-
+ Name
+ Created
-
- {filtered.length ? (
- filtered.map(key => (
-
- {key.name}
- {key.created}
-
- ))
- ) : (
-
-
- No API keys found
-
-
+ (
+
+ No API keys found
+
+ )}
+ >
+ {key => (
+
+ {key.name}
+ {key.created}
+
)}
@@ -254,72 +207,67 @@ export function Search() {
}
export function Sorting() {
- const { sorting, setSorting, sorted } = useSortedApiKeys(apiKeys);
+ const [sort, setSort] = React.useState(null);
return (
-
+
-
- cycleSort(setSorting, 'name')}
- >
- Name
-
- cycleSort(setSorting, 'created')}
- >
- Created
-
- Environment
-
+
+ Name
+
+
+ Created
+
+ Environment
-
- {sorted.map(key => (
-
+
+ {key => (
+
{key.name}
{key.created}
{key.environment}
- ))}
+ )}
);
}
export function Selection() {
- const table = useDataTable({ data: apiKeys, getRowId: row => row.id });
-
return (
-
+
-
- table.toggleAllRowsSelected()}
- />
- Name
- Last used
-
+
+ Name
+ Last used
-
- {table.rows.map(row => (
-
-
- {row.original.name}
- {row.original.lastUsed}
+
+ {key => (
+
+
+ {key.name}
+ {key.lastUsed}
- ))}
+ )}
);
@@ -327,16 +275,16 @@ export function Selection() {
export function Empty() {
return (
-
+
-
- Name
- Last used
- Created
-
+ Name
+ Last used
+ Created
-
-
+ (
No API keys
@@ -353,7 +301,72 @@ export function Empty() {
-
+ )}
+ >
+ {key => (
+
+ {key.name}
+ {key.lastUsed}
+ {key.created}
+
+ )}
+
+
+ );
+}
+
+type Endpoint = { path: string; requests: string; children?: Endpoint[] };
+
+const endpoints: Endpoint[] = [
+ {
+ path: '/v1/users',
+ requests: '18,204',
+ children: [
+ { path: '/v1/users/{id}', requests: '12,880' },
+ { path: '/v1/users/{id}/sessions', requests: '3,410' },
+ ],
+ },
+ {
+ path: '/v1/organizations',
+ requests: '4,129',
+ children: [{ path: '/v1/organizations/{id}/members', requests: '2,006' }],
+ },
+ { path: '/v1/jwks', requests: '913' },
+];
+
+export function TreeRows() {
+ return (
+
+
+ Endpoint
+ Requests
+
+ endpoint.path}
+ itemToChildren={endpoint => endpoint.children}
+ >
+ {endpoint => (
+
+
+
+
+ {endpoint.path}
+
+
+ {endpoint.requests}
+
+ )}
);
@@ -364,17 +377,18 @@ export function Overflow() {
return (
-
+
-
- {columns.map(column => (
- {column}
- ))}
-
+ {columns.map(column => (
+ {column}
+ ))}
-
- {apiKeys.map(key => (
-
+
+ {key => (
+
{key.name}
{key.environment}
{key.lastUsed}
@@ -384,7 +398,7 @@ export function Overflow() {
read, write
12,408
- ))}
+ )}
@@ -396,24 +410,26 @@ export function StickyColumn() {
return (
-
+
-
- Name
- {columns.map(column => (
- {column}
- ))}
-
- Actions
-
-
+ Name
+ {columns.map(column => (
+ {column}
+ ))}
+
+ Actions
+
-
- {apiKeys.map(key => (
-
+
+ {key => (
+
{key.name}
{key.environment}
{key.lastUsed}
@@ -435,7 +451,7 @@ export function StickyColumn() {
- ))}
+ )}
@@ -443,53 +459,38 @@ export function StickyColumn() {
}
export function ColumnWidths() {
- const table = useDataTable({ data: apiKeys, getRowId: row => row.id });
-
return (
-
-
-
-
-
-
-
-
- table.toggleAllRowsSelected()}
- />
- Name
- Last used
- Created
-
- Actions
-
-
+
+ Name
+ Last used
+ Created
+
+ Actions
+
-
- {table.rows.map(row => (
-
-
- {row.original.name}
- {row.original.lastUsed}
- {row.original.created}
+
+ {key => (
+
+
+ {key.name}
+ {key.lastUsed}
+ {key.created}
-
+
Revoke
@@ -498,7 +499,7 @@ export function ColumnWidths() {
- ))}
+ )}
);
@@ -509,24 +510,20 @@ const keyDetails = Dialog.createHandle();
export function ClickableRows() {
return (
<>
-
+
-
- Name
- Environment
- Last used
-
- Actions
-
-
+ Name
+ Environment
+ Last used
+
+ Actions
+
-
- {apiKeys.map(key => (
-
- ))}
+
+ {key => }
diff --git a/packages/swingset/src/stories/use-data-table.mdx b/packages/swingset/src/stories/use-data-table.mdx
deleted file mode 100644
index e3e17bf04d4..00000000000
--- a/packages/swingset/src/stories/use-data-table.mdx
+++ /dev/null
@@ -1,117 +0,0 @@
-# useDataTable
-
-State manager for data tables. Tracks pagination, sorting, column filters, global filter, and row selection. Each state slice supports the controlled/uncontrolled pattern. Designed for server-side data: you supply the already-fetched page of rows, and the hook tracks which page, sort, and filters to request next.
-
-## Usage
-
-```tsx
-import { useDataTable } from '@clerk/mosaic/primitives/hooks';
-
-const {
- rows,
- pagination,
- nextPage,
- previousPage,
- getPageCount,
- rowSelection,
- toggleAllRowsSelected,
-} = useDataTable({
- data: pageOfData, // current page fetched from the server
- getRowId: row => row.id,
- totalCount: 1000,
- defaultPagination: { pageIndex: 0, pageSize: 20 },
- onPaginationChange: newPagination => {
- // re-fetch with the new page/size
- },
-});
-```
-
-## Options
-
-| Prop | Type | Default | Description |
-| ----------------------- | -------------------------------- | -------------------------------- | ----------------------------------------------- |
-| `data` | `TData[]` | required | Current page of rows |
-| `getRowId` | `(row, index) => string` | `String(index)` | Stable row identifier |
-| `totalCount` | `number` | — | Total record count (used to compute page count) |
-| `sorting` | `SortingState` | — | Controlled sort state |
-| `defaultSorting` | `SortingState` | `[]` | Initial uncontrolled sort |
-| `onSortingChange` | `OnChangeFn` | — | Called when sort changes |
-| `columnFilters` | `ColumnFiltersState` | — | Controlled column filters |
-| `defaultColumnFilters` | `ColumnFiltersState` | `[]` | Initial uncontrolled column filters |
-| `onColumnFiltersChange` | `OnChangeFn` | — | Called when column filters change |
-| `globalFilter` | `string` | — | Controlled global search string |
-| `defaultGlobalFilter` | `string` | `''` | Initial uncontrolled global filter |
-| `onGlobalFilterChange` | `OnChangeFn` | — | Called when global filter changes |
-| `pagination` | `PaginationState` | — | Controlled pagination |
-| `defaultPagination` | `PaginationState` | `{ pageIndex: 0, pageSize: 10 }` | Initial uncontrolled pagination |
-| `onPaginationChange` | `OnChangeFn` | — | Called when pagination changes |
-| `rowSelection` | `RowSelectionState` | — | Controlled row selection |
-| `defaultRowSelection` | `RowSelectionState` | `{}` | Initial uncontrolled row selection |
-| `onRowSelectionChange` | `OnChangeFn` | — | Called when selection changes |
-
-## Return
-
-### Rows
-
-| Value | Type | Description |
-| ------ | ----------------------- | ---------------------------- |
-| `rows` | `DataTableRow[]` | `data` mapped to row objects |
-
-Each `DataTableRow` has:
-
-| Field | Type | Description |
-| -------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
-| `id` | `string` | Stable row identifier |
-| `original` | `TData` | Original data item |
-| `getIsSelected()` | `() => boolean` | Whether this row is selected |
-| `toggleSelected(options?)` | `(options?: { range?: boolean }) => void` | Toggle selection for this row; with `range`, apply the toggle to every row between the last toggled row and this one |
-
-### Pagination
-
-| Value | Type | Description |
-| ---------------------- | ----------------------------- | ------------------------------------ |
-| `pagination` | `PaginationState` | Current `{ pageIndex, pageSize }` |
-| `setPagination` | `OnChangeFn` | Set pagination state |
-| `nextPage()` | `() => void` | Advance one page |
-| `previousPage()` | `() => void` | Go back one page |
-| `setPageSize(n)` | `(size: number) => void` | Change page size and reset to page 0 |
-| `getCanNextPage()` | `() => boolean` | Whether a next page exists |
-| `getCanPreviousPage()` | `() => boolean` | Whether a previous page exists |
-| `getPageCount()` | `() => number` | `ceil(totalCount / pageSize)` |
-
-### Sorting
-
-| Value | Type | Description |
-| ------------ | -------------------------- | ---------------------------------------------------- |
-| `sorting` | `SortingState` | Current sort: `Array<{ id: string; desc: boolean }>` |
-| `setSorting` | `OnChangeFn` | Set sort state |
-
-### Filters
-
-| Value | Type | Description |
-| ------------------ | -------------------------------- | ---------------------------- |
-| `columnFilters` | `ColumnFiltersState` | Current column filters |
-| `setColumnFilters` | `OnChangeFn` | Set column filters |
-| `globalFilter` | `string` | Current global search string |
-| `setGlobalFilter` | `OnChangeFn` | Set global filter |
-
-### Row Selection
-
-| Value | Type | Description |
-| ------------------------- | ------------------------------- | ----------------------------------------- |
-| `rowSelection` | `RowSelectionState` | `Record` keyed by row id |
-| `setRowSelection` | `OnChangeFn` | Set selection state |
-| `getIsAllRowsSelected()` | `() => boolean` | All current rows are selected |
-| `getIsSomeRowsSelected()` | `() => boolean` | Some (not all) rows are selected |
-| `toggleAllRowsSelected()` | `() => void` | Select all; clear if all already selected |
-
-## Types
-
-```ts
-type SortingState = Array<{ id: string; desc: boolean }>;
-type ColumnFiltersState = Array<{ id: string; value: unknown }>;
-type PaginationState = { pageIndex: number; pageSize: number };
-type RowSelectionState = Record;
-type Updater = T | ((old: T) => T);
-type OnChangeFn = (updaterOrValue: Updater) => void;
-```
diff --git a/packages/swingset/src/stories/use-data-table.stories.tsx b/packages/swingset/src/stories/use-data-table.stories.tsx
deleted file mode 100644
index 9bbf7ef7e15..00000000000
--- a/packages/swingset/src/stories/use-data-table.stories.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import type { StoryMeta } from '@/lib/types';
-
-export const meta: StoryMeta = {
- group: 'Hooks',
- status: 'stable',
- title: 'useDataTable',
- source: 'packages/mosaic/src/primitives/hooks/use-data-table.ts',
-};
diff --git a/packages/swingset/src/stories/use-server-data-table.mdx b/packages/swingset/src/stories/use-server-data-table.mdx
deleted file mode 100644
index ec103f83815..00000000000
--- a/packages/swingset/src/stories/use-server-data-table.mdx
+++ /dev/null
@@ -1,105 +0,0 @@
-import * as UseServerDataTableStories from './use-server-data-table.stories';
-
-# useServerDataTable
-
-Adapter from server query state to the [Table](/components/table) and [Pagination](/components/pagination) components. You own the query (search, sort, 1-based page, page size) and pass in the page of rows it returned; the hook wraps [useDataTable](/hooks/use-data-table) and hands back the table state, the sort props for each header cell, and the props for `Pagination`. Any change to the search, sort, page, or page size clears the row selection.
-
-## Example
-
-Search, sort by a column, select rows, and change pages. The story filters, sorts, and slices the rows itself to stand in for the server.
-
-
-
-## Usage
-
-```tsx
-import { Pagination } from '@clerk/mosaic/components/pagination';
-import { Table } from '@clerk/mosaic/components/table';
-import { useServerDataTable } from '@clerk/mosaic/hooks/use-server-data-table';
-
-const { table, sortHeader, pagination } = useServerDataTable({
- data: response.data,
- totalCount: response.totalCount,
- getRowId: row => row.id,
- sortableColumns: ['name', 'createdAt'],
- sort,
- onSortChange: setSort,
- page,
- pageSize,
- onPageChange: setPage,
- onPageSizeChange: setPageSize,
- searchValue: query,
- onSearchChange: setQuery,
-});
-
-
-
-
-
- Name
- Created
-
-
-
- {table.rows.map(row => (
- …
- ))}
-
-
-{pagination ? : null}
-```
-
-## Options
-
-| Prop | Type | Default | Description |
-| ------------------ | ------------------------------------------------------ | ------------ | ---------------------------------------------------------------------------------------------------------------- |
-| `data` | `TData[]` | — (required) | Current page of rows |
-| `totalCount` | `number` | — (required) | Total matching rows across all pages |
-| `getRowId` | `(row: TData) => string` | — (required) | Stable row identifier |
-| `isRowSelectable` | `(row: TData) => boolean` | every row | Rows that return `false` are skipped by selection |
-| `sortableColumns` | `readonly TColumn[]` | — (required) | Columns the server can sort by |
-| `sort` | `ServerDataTableSort \| null` | unsorted | Active sort |
-| `onSortChange` | `(sort: ServerDataTableSort \| null) => void` | — | Called with the next sort. Without it, header cells are not sortable |
-| `page` | `number` | — (required) | Current page, 1-based |
-| `pageSize` | `number` | — (required) | Rows per page |
-| `onPageChange` | `(page: number) => void` | — (required) | Called with the next 1-based page |
-| `onPageSizeChange` | `(pageSize: number) => void` | — | Called with the next page size, followed by `onPageChange(1)`. Without it, `Pagination` has no page size control |
-| `searchValue` | `string` | — (required) | Current search query |
-| `onSearchChange` | `(value: string) => void` | — (required) | Called with the next search query |
-| `isFetching` | `boolean` | `false` | Whether a page is loading. While `true`, `Pagination` is disabled |
-
-## Return
-
-| Value | Type | Description |
-| ------------ | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
-| `table` | `UseDataTableReturn` | The [useDataTable](/hooks/use-data-table) return: rows, selection, and the search value to bind to `Table.Search` |
-| `sortHeader` | `(column: TColumn) => Pick` | Props to spread on a `Table.HeaderCell`. Clicking cycles ascending, descending, unsorted |
-| `pagination` | `ServerDataTablePagination \| null` | Props to spread on `Pagination`, or `null` when there is nothing to paginate |
-
-`pagination` is non-null when there is more than one page, or when there is at least one row and `onPageSizeChange` is set.
-
-## Types
-
-```ts
-interface ServerDataTableSort {
- column: TColumn;
- direction: 'ascending' | 'descending';
-}
-
-type ServerDataTablePagination = {
- page: number;
- pageSize: number;
- totalItems: number;
- disabled: boolean;
- onChange: (page: number) => void;
- onPageSizeChange?: (pageSize: number) => void;
-};
-```
diff --git a/packages/swingset/src/stories/use-server-data-table.stories.tsx b/packages/swingset/src/stories/use-server-data-table.stories.tsx
deleted file mode 100644
index 97ee7120d75..00000000000
--- a/packages/swingset/src/stories/use-server-data-table.stories.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-import { Pagination } from '@clerk/mosaic/components/pagination';
-import { Table } from '@clerk/mosaic/components/table';
-import type { ServerDataTableSort } from '@clerk/mosaic/hooks/use-server-data-table';
-import { useServerDataTable } from '@clerk/mosaic/hooks/use-server-data-table';
-import * as React from 'react';
-
-import { useChaosFixture } from '@/components/ChaosProvider';
-import { chaosName, chaosRows } from '@/lib/chaos';
-import type { StoryMeta } from '@/lib/types';
-
-export const meta: StoryMeta = {
- group: 'Hooks',
- status: 'stable',
- title: 'useServerDataTable',
- layout: 'wide',
- source: 'packages/mosaic/src/hooks/use-server-data-table.ts',
-};
-
-type Member = { id: string; name: string; role: string };
-type Column = 'name' | 'role';
-
-const members: Member[] = [
- { id: 'kyle', name: 'Kyle Mac', role: 'Member' },
- { id: 'austin', name: 'Austin Calvelage', role: 'Member' },
- { id: 'colin', name: 'Colin Sidoti', role: 'Admin' },
- { id: 'max', name: 'Max Yinger', role: 'Member' },
- { id: 'preston', name: 'Preston Booth', role: 'Member' },
- { id: 'richard', name: 'Richard Hendricks', role: 'Member' },
- { id: 'monica', name: 'Monica Hall', role: 'Admin' },
- { id: 'jared', name: 'Jared Dunn', role: 'Member' },
- { id: 'dinesh', name: 'Dinesh Chugtai', role: 'Member' },
- { id: 'steve', name: 'Steve Hayes', role: 'Admin' },
- { id: 'gavin', name: 'Gavin Belson', role: 'Admin' },
- { id: 'laurie', name: 'Laurie Bream', role: 'Member' },
- { id: 'erlich', name: 'Erlich Bachman', role: 'Member' },
- { id: 'big-head', name: 'Nelson Bighetti', role: 'Member' },
- { id: 'carla', name: 'Carla Walton', role: 'Member' },
- { id: 'russ', name: 'Russ Hanneman', role: 'Admin' },
- { id: 'peter', name: 'Peter Gregory', role: 'Member' },
- { id: 'ron', name: 'Ron LaFlamme', role: 'Member' },
-];
-
-function queryMembers({
- rows,
- query,
- sort,
- page,
- pageSize,
-}: {
- rows: Member[];
- query: string;
- sort: ServerDataTableSort | null;
- page: number;
- pageSize: number;
-}) {
- const needle = query.trim().toLowerCase();
- const matches = rows.filter(member => member.name.toLowerCase().includes(needle));
- const sorted = sort
- ? [...matches].sort(
- (a, b) => a[sort.column].localeCompare(b[sort.column]) * (sort.direction === 'ascending' ? 1 : -1),
- )
- : matches;
- const start = (page - 1) * pageSize;
- return { data: sorted.slice(start, start + pageSize), totalCount: matches.length };
-}
-
-export function Default() {
- const fixture = useChaosFixture(members, rows =>
- chaosRows(rows).map((row, index) => ({ ...row, name: chaosName(index) })),
- );
- const [query, setQuery] = React.useState('');
- const [sort, setSort] = React.useState | null>(null);
- const [page, setPage] = React.useState(1);
- const [pageSize, setPageSize] = React.useState(5);
- const { data, totalCount } = queryMembers({ rows: fixture, query, sort, page, pageSize });
-
- const { table, sortHeader, pagination } = useServerDataTable({
- data,
- totalCount,
- getRowId: row => row.id,
- sortableColumns: ['name', 'role'],
- sort,
- onSortChange: setSort,
- page,
- pageSize,
- onPageChange: setPage,
- onPageSizeChange: setPageSize,
- searchValue: query,
- onSearchChange: next => {
- setQuery(next);
- setPage(1);
- },
- });
-
- return (
-
-
-
-
-
-
-
- table.toggleAllRowsSelected()}
- />
- Name
- Role
-
-
-
- {table.rows.length === 0 ? (
- No members match “{query}”.
- ) : (
- table.rows.map(row => (
-
-
- {row.original.name}
- {row.original.role}
-
- ))
- )}
-
-
- {pagination ? (
-
- ) : null}
-
- );
-}