diff --git a/.changeset/tidy-tables-search.md b/.changeset/tidy-tables-search.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/tidy-tables-search.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/table/index.ts b/packages/mosaic/src/components/table/index.ts index 26b143caa70..695aaf521c4 100644 --- a/packages/mosaic/src/components/table/index.ts +++ b/packages/mosaic/src/components/table/index.ts @@ -8,7 +8,9 @@ export type { TableHeaderProps, TableProps, TableRowProps, + TableSearchProps, TableSelectAllCellProps, TableSelectCellProps, TableSort, + TableToolbarProps, } from './table'; diff --git a/packages/mosaic/src/components/table/table.styles.ts b/packages/mosaic/src/components/table/table.styles.ts index 6a87e53cd02..ed80339534b 100644 --- a/packages/mosaic/src/components/table/table.styles.ts +++ b/packages/mosaic/src/components/table/table.styles.ts @@ -3,6 +3,15 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; export const styles = stylex.create({ + toolbar: { + gap: space['4'], + alignItems: 'center', + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, + search: { maxWidth: '100%', width: '17rem' }, + noWrap: { whiteSpace: 'nowrap' }, shell: { borderColor: colorVars['--cl-color-border'], borderRadius: radiusVars['--cl-radius-xl'], diff --git a/packages/mosaic/src/components/table/table.test.tsx b/packages/mosaic/src/components/table/table.test.tsx index 19fb8f17f61..04a40af9ca5 100644 --- a/packages/mosaic/src/components/table/table.test.tsx +++ b/packages/mosaic/src/components/table/table.test.tsx @@ -32,6 +32,40 @@ function renderTable(props: Partial> = { } describe('Mosaic Table', () => { + it('reports search changes and returns focus to the input when cleared', async () => { + const user = userEvent.setup(); + const ref = React.createRef(); + const onValueChange = vi.fn(); + function Example() { + const [value, setValue] = React.useState(''); + return ( + + { + onValueChange(next); + setValue(next); + }} + /> + + ); + } + render(); + const input = screen.getByRole('searchbox', { name: 'Search members' }); + expect(ref.current).toBe(input); + expect(screen.queryByRole('button', { name: 'Clear member search' })).not.toBeInTheDocument(); + await user.type(input, 'Ada'); + expect(onValueChange).toHaveBeenLastCalledWith('Ada'); + await user.click(screen.getByRole('button', { name: 'Clear member search' })); + expect(onValueChange).toHaveBeenLastCalledWith(''); + expect(input).toHaveValue(''); + expect(input).toHaveFocus(); + expect(screen.queryByRole('button', { name: 'Clear member search' })).not.toBeInTheDocument(); + }); + it('renders a table inside a scrolling shell', () => { renderTable(); const table = screen.getByRole('table'); @@ -40,6 +74,25 @@ describe('Mosaic Table', () => { expect(table.parentElement?.parentElement).toHaveClass('cl-table-shell'); }); + it('opts cells into no-wrap styling without changing neighboring cells', () => { + render( + + + + September 23, 2026 + A longer description can wrap + + + , + ); + const date = screen.getByRole('cell', { name: 'September 23, 2026' }); + const description = screen.getByRole('cell', { name: 'A longer description can wrap' }); + expect(date).toHaveAttribute('data-no-wrap', ''); + expect(date).toHaveClass(stylex.props(styles.noWrap).className ?? ''); + expect(description).not.toHaveAttribute('data-no-wrap'); + expect(description).not.toHaveClass(stylex.props(styles.noWrap).className ?? ''); + }); + it('lets the keyboard reach the scrolling viewport', () => { renderTable(); expect(screen.getByRole('table').parentElement).toHaveAttribute('tabindex', '0'); diff --git a/packages/mosaic/src/components/table/table.tsx b/packages/mosaic/src/components/table/table.tsx index 61f23b1c05e..e067cef5d08 100644 --- a/packages/mosaic/src/components/table/table.tsx +++ b/packages/mosaic/src/components/table/table.tsx @@ -1,3 +1,4 @@ +import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -9,6 +10,8 @@ import { Button } from '../button'; import type { CheckboxProps } from '../checkbox'; import { Checkbox } from '../checkbox'; import { Icon } from '../icon'; +import type { InputProps } from '../input'; +import { InputGroup } from '../input-group'; import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; import { aligns, styles } from './table.styles'; @@ -22,6 +25,74 @@ export type TableSort = 'ascending' | 'descending' | 'none'; export type TableProps = MosaicElementProps<'table'>; +export type TableToolbarProps = MosaicComponentProps<'div'>; + +const Toolbar = React.forwardRef(function MosaicTableToolbar( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: mergeStyleProps(themeProps('table-toolbar'), stylex.props(reset.base, styles.toolbar, xstyle), rest), + }); +}); + +export interface TableSearchProps extends Omit< + InputProps, + 'value' | 'defaultValue' | 'onChange' | 'type' | 'children' | 'size' | 'variant' +> { + label: string; + clearLabel: string; + value: string; + onValueChange: (value: string) => void; +} + +const Search = React.forwardRef(function MosaicTableSearch( + { label, clearLabel, value, onValueChange, disabled, readOnly, ...rest }, + ref, +) { + const inputRef = React.useRef(null); + const mergedRef = useMergeRefs([ref, inputRef]); + return ( + + + + + onValueChange(event.currentTarget.value)} + /> + {value ? ( + + + + ) : null} + + ); +}); + const Root = React.forwardRef(function MosaicTable({ xstyle, ...rest }, ref) { return (
@@ -147,10 +218,11 @@ const HeaderCell = React.forwardRef( export interface TableCellProps extends Omit, 'align'> { align?: TableAlign; + noWrap?: boolean; } const Cell = React.forwardRef(function MosaicTableCell( - { align = 'start', render, xstyle, ...rest }, + { align = 'start', noWrap = false, render, xstyle, ...rest }, ref, ) { return useRender({ @@ -158,8 +230,8 @@ const Cell = React.forwardRef(function Mos render, ref, props: mergeStyleProps( - themeProps('table-cell', { align }), - stylex.props(reset.base, styles.cell, aligns[align], xstyle), + themeProps('table-cell', { align, noWrap }), + stylex.props(reset.base, styles.cell, aligns[align], noWrap && styles.noWrap, xstyle), rest, ), }); @@ -227,4 +299,4 @@ const Empty = React.forwardRef(function M ); }); -export const Table = { Root, Header, Body, Row, HeaderCell, Cell, SelectAllCell, SelectCell, Empty }; +export const Table = { Root, Toolbar, Search, Header, Body, Row, HeaderCell, Cell, SelectAllCell, SelectCell, Empty }; diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.styles.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.styles.ts deleted file mode 100644 index d406ad8ac95..00000000000 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.styles.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; - -import { space } from '../../tokens.stylex'; - -export const styles = stylex.create({ - toolbar: { - gap: space['4'], - alignItems: 'center', - display: 'flex', - flexWrap: 'wrap', - justifyContent: 'space-between', - }, - search: { maxWidth: '100%', width: '17rem' }, - dateCell: { whiteSpace: 'nowrap' }, -}); diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx index 25f9f292d65..4e9258bf4ed 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx @@ -6,8 +6,6 @@ import { ActionMenu } from '../../components/action-menu'; import { Avatar } from '../../components/avatar'; import { Button } from '../../components/button'; import { EmptyState } from '../../components/empty-state'; -import { Icon } from '../../components/icon'; -import { InputGroup } from '../../components/input-group'; import { Item } from '../../components/item'; import { Pagination } from '../../components/pagination'; import { panelStyles } from '../../components/profile'; @@ -18,7 +16,6 @@ import { useListRemovalFocus } from '../../hooks/useListRemovalFocus'; import { fill, useMessages } from '../../localization'; import { useDataTable } from '../../primitives/hooks'; import { mergeStyleProps, themeProps } from '../../props'; -import { styles } from './invitations-table-tab.styles'; import type { InvitationsTableSort, InvitationsTableTabViewProps, @@ -107,37 +104,14 @@ export function InvitationsTableTabView({ return ( <>
-
- - - - - table.setGlobalFilter(event.currentTarget.value)} - /> - {table.globalFilter ? ( - - - - ) : null} - + + {onInvite ? (
+ - {row.original.invitedAtLabel} + {row.original.invitedAtLabel} {row.original.roleLabel} {onRevoke ? ( diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts b/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts index c6dc19c44d5..6b3ee08455e 100644 --- a/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts @@ -3,14 +3,5 @@ import * as stylex from '@stylexjs/stylex'; import { space } from '../../tokens.stylex'; export const styles = stylex.create({ - toolbar: { - gap: space['4'], - alignItems: 'center', - display: 'flex', - flexWrap: 'wrap', - justifyContent: 'space-between', - }, - search: { maxWidth: '100%', width: '17rem' }, name: { gap: space['2'], alignItems: 'center', display: 'flex' }, - dateCell: { whiteSpace: 'nowrap' }, }); diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx index 7c06d620dcb..d3075e396cf 100644 --- a/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx @@ -7,8 +7,6 @@ import { Avatar } from '../../components/avatar'; import { Badge } from '../../components/badge'; import { Button } from '../../components/button'; import { EmptyState } from '../../components/empty-state'; -import { Icon } from '../../components/icon'; -import { InputGroup } from '../../components/input-group'; import { Item } from '../../components/item'; import { Pagination } from '../../components/pagination'; import { panelStyles } from '../../components/profile'; @@ -116,37 +114,14 @@ export function MembersTableTabView({ return ( <>
-
- - - - - table.setGlobalFilter(event.currentTarget.value)} - /> - {table.globalFilter ? ( - - - - ) : null} - + + {onInvite ? (
+ - {member.joinedAtLabel} + {member.joinedAtLabel} {onChangeRole && !member.isDeprovisioned ? (
{m.title} -
- - - - - table.setGlobalFilter(event.currentTarget.value)} - /> - {table.globalFilter ? ( - - - - ) : null} - + + {onCreate ? (
+
- + {row.original.createdAtLabel} - + {row.original.lastUsedAtLabel ?? m.neverUsed} {onRevoke ? ( diff --git a/packages/mosaic/src/features/organization-profile/requests-table-tab.styles.ts b/packages/mosaic/src/features/organization-profile/requests-table-tab.styles.ts index 528266c7e6e..6e171ad565f 100644 --- a/packages/mosaic/src/features/organization-profile/requests-table-tab.styles.ts +++ b/packages/mosaic/src/features/organization-profile/requests-table-tab.styles.ts @@ -3,14 +3,5 @@ import * as stylex from '@stylexjs/stylex'; import { space } from '../../tokens.stylex'; export const styles = stylex.create({ - toolbar: { - gap: space['4'], - alignItems: 'center', - display: 'flex', - flexWrap: 'wrap', - justifyContent: 'space-between', - }, - search: { maxWidth: '100%', width: '17rem' }, actions: { gap: space['2'], display: 'flex', justifyContent: 'flex-end' }, - dateCell: { whiteSpace: 'nowrap' }, }); diff --git a/packages/mosaic/src/features/organization-profile/requests-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/requests-table-tab.view.tsx index af32f34990e..f6aaccd47e3 100644 --- a/packages/mosaic/src/features/organization-profile/requests-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/requests-table-tab.view.tsx @@ -2,10 +2,8 @@ import * as stylex from '@stylexjs/stylex'; import { useRef, useState } from 'react'; import { Avatar } from '../../components/avatar'; -import { Button, SubmitButton } from '../../components/button'; +import { SubmitButton } from '../../components/button'; import { EmptyState } from '../../components/empty-state'; -import { Icon } from '../../components/icon'; -import { InputGroup } from '../../components/input-group'; import { Item } from '../../components/item'; import { Pagination } from '../../components/pagination'; import { panelStyles } from '../../components/profile'; @@ -109,38 +107,15 @@ export function RequestsTableTabView({ }; return (
-
- - - - - table.setGlobalFilter(event.currentTarget.value)} - /> - {table.globalFilter ? ( - - - - ) : null} - -
+ + + - {request.requestedAtLabel} + {request.requestedAtLabel} {hasActions ? (
{m.title} -
- - - - - table.setGlobalFilter(event.currentTarget.value)} - /> - {table.globalFilter ? ( - - - - ) : null} - + + {onCreate ? (
+
- + {row.original.createdAtLabel} - + {row.original.lastUsedAtLabel ?? m.neverUsed} {onRevoke ? ( diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 7376ebed605..07baabf6a6e 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -254,6 +254,7 @@ import { Empty as TableEmpty, meta as tableMeta, Overflow as TableOverflow, + Search as TableSearch, Selection as TableSelection, Sorting as TableSorting, } from '../stories/table.stories'; @@ -701,6 +702,7 @@ const tableModule: StoryModule = { meta: tableMeta, Default: TableDefault, Sorting: TableSorting, + Search: TableSearch, Selection: TableSelection, Empty: TableEmpty, Overflow: TableOverflow, diff --git a/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx b/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx index b818b9a38a3..417daf3ada2 100644 --- a/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx +++ b/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx @@ -13,12 +13,10 @@ A controlled organization API keys table with search, page navigation, and confi { name: 'Table', href: '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/components/table', layer: 'Components' }, { name: 'EmptyState', href: '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/components/empty-state', layer: 'Components' }, { name: 'useDataTable', href: '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/hooks/use-data-table', layer: 'Hooks' }, - { name: 'InputGroup', href: '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/components/input-group', layer: 'Components' }, { name: 'Pagination', href: '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/components/pagination', layer: 'Components' }, { name: 'Menu', href: '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/components/menu', layer: 'Components' }, { name: 'Button', href: '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/components/button', layer: 'Components' }, { name: 'Text', href: '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/components/text', layer: 'Components' }, - { name: 'Icon', href: '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/components/icon', layer: 'Components' }, { name: 'Spinner', href: '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/components/spinner', layer: 'Components' }, { name: 'Profile', href: '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/components/profile', layer: 'Components' }, { name: 'Dialog', href: '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/components/dialog', layer: 'Components' }, diff --git a/packages/swingset/src/stories/organization-profile-members-panel.mdx b/packages/swingset/src/stories/organization-profile-members-panel.mdx index c561d494b3f..b1121a6acbf 100644 --- a/packages/swingset/src/stories/organization-profile-members-panel.mdx +++ b/packages/swingset/src/stories/organization-profile-members-panel.mdx @@ -2,7 +2,7 @@ import * as Stories from './organization-profile-members-panel.stories'; # OrganizationProfileMembersPanel -The organization profile’s Members panel owns the page title and tab navigation. These examples show its Members, Invitations, and Requests tabs with controlled tables. +The organization profile’s Members panel owns the page title and tab navigation. These examples show its Members, Invitations, and Requests tabs with controlled tables. Each table uses the shared Table toolbar and search field. Removing a member opens a confirmation dialog. The dialog awaits the action callback and keeps failures visible. After a successful removal, focus moves to a surviving row action or a toolbar control. @@ -21,7 +21,6 @@ The Members table shows the user, joined date, role, and optional row actions. T { name: 'Tabs', href: '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/components/tabs', layer: 'Components' }, { name: 'Confirmation', href: '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/blocks/confirmation', layer: 'Blocks' }, { name: 'Table', href: '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/components/table', layer: 'Components' }, - { name: 'InputGroup', href: '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/components/input-group', layer: 'Components' }, { name: 'Pagination', href: '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/components/pagination', layer: 'Components' }, { name: 'Select', href: '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/components/select', layer: 'Components' }, { name: 'Menu', href: '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/components/menu', layer: 'Components' }, diff --git a/packages/swingset/src/stories/table.mdx b/packages/swingset/src/stories/table.mdx index 1397fcb085a..38dbdae184c 100644 --- a/packages/swingset/src/stories/table.mdx +++ b/packages/swingset/src/stories/table.mdx @@ -18,31 +18,44 @@ Sorting, selection, and the empty state are opt-in parts rather than state the t ```tsx import { Table } from '@clerk/mosaic/components/table'; - - - - Name - Created - - - - {rows.map(row => ( - - {row.name} - {row.created} +<> + + + + + + + + Name + Created - ))} - -; + + + {rows.map(row => ( + + {row.name} + {row.created} + + ))} + +
+; ``` ## Parts +- `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. - `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. - `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. +- `Table.Cell` renders a `` with the same `align` prop. `noWrap` defaults to `false`; enable it to keep dates and other short values on one line. - `Table.SelectAllCell` and `Table.SelectCell` render a narrow header or body cell holding a [Checkbox](/components/checkbox). They take Checkbox props, so pass `checked`, `indeterminate`, and an `aria-label`. `Table.SelectCell` also takes `onToggleSelected`, called with `{ range: true }` when the checkbox was shift-clicked, which fits the row's `toggleSelected` from `useDataTable` directly. - `Table.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. @@ -55,11 +68,13 @@ Each part carries a stable `.cl-*` class, and the parts with state reflect it as | `.cl-table-shell` | The bordered wrapper around the table | | | `.cl-table-viewport` | The horizontally scrolling viewport | | | `.cl-table` | `Table.Root` | | +| `.cl-table-toolbar` | `Table.Toolbar` | | +| `.cl-table-search` | `Table.Search` input group | | | `.cl-table-header` | `Table.Header` | | | `.cl-table-body` | `Table.Body` | | | `.cl-table-row` | `Table.Row` | `data-selected` when `selected` | | `.cl-table-header-cell` | `Table.HeaderCell` | `data-align` (`start` / `center` / `end`), `data-sortable` when `onSort` is set, `data-sort` (`ascending` / `descending`) | -| `.cl-table-cell` | `Table.Cell` | `data-align` (`start` / `center` / `end`) | +| `.cl-table-cell` | `Table.Cell` | `data-align` (`start` / `center` / `end`), `data-no-wrap` when `noWrap` | | `.cl-table-select-cell` | `Table.SelectAllCell`, `Table.SelectCell` | | | `.cl-table-empty` | `Table.Empty` | | @@ -73,6 +88,15 @@ Each part carries a stable `.cl-*` class, and the parts with state reflect it as ## Examples +### Search + +The toolbar places the search field beside optional actions and wraps on narrow screens. This example filters locally; server-backed views can pass their controlled search value and change handler instead. + + + ### Sorting Each sortable header cycles through ascending, descending, and unsorted. The story keeps sorting in `useDataTable` and sorts the rows itself before rendering. diff --git a/packages/swingset/src/stories/table.stories.tsx b/packages/swingset/src/stories/table.stories.tsx index 230ab1bde23..0a45584dccb 100644 --- a/packages/swingset/src/stories/table.stories.tsx +++ b/packages/swingset/src/stories/table.stories.tsx @@ -201,6 +201,48 @@ export function Default() { ); } +export function Search() { + const [search, setSearch] = React.useState(''); + const filtered = apiKeys.filter(key => key.name.toLowerCase().includes(search.trim().toLowerCase())); + return ( +
+ + + + + + + + Name + Created + + + + {filtered.length ? ( + filtered.map(key => ( + + {key.name} + {key.created} + + )) + ) : ( + + + No API keys found + + + )} + + +
+ ); +} + export function Sorting() { const { sorting, setSorting, sorted } = useSortedApiKeys(apiKeys); diff --git a/packages/swingset/src/stories/user-profile-api-keys-panel.mdx b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx index fbb72176b47..77352fc8d51 100644 --- a/packages/swingset/src/stories/user-profile-api-keys-panel.mdx +++ b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx @@ -13,12 +13,10 @@ A controlled API keys table with search, page navigation, and confirmation by ty { name: 'Table', href: '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/components/table', layer: 'Components' }, { name: 'EmptyState', href: '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/components/empty-state', layer: 'Components' }, { name: 'useDataTable', href: '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/hooks/use-data-table', layer: 'Hooks' }, - { name: 'InputGroup', href: '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/components/input-group', layer: 'Components' }, { name: 'Pagination', href: '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/components/pagination', layer: 'Components' }, { name: 'Menu', href: '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/components/menu', layer: 'Components' }, { name: 'Button', href: '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/components/button', layer: 'Components' }, { name: 'Text', href: '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/components/text', layer: 'Components' }, - { name: 'Icon', href: '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/components/icon', layer: 'Components' }, { name: 'Spinner', href: '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/components/spinner', layer: 'Components' }, { name: 'Profile', href: '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/components/profile', layer: 'Components' }, { name: 'Dialog', href: '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/components/dialog', layer: 'Components' },