From ec66afb03e1d9b66e99ab46886b0dfff61b37fae Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Thu, 22 Jan 2026 12:23:11 +0100 Subject: [PATCH] docs(table): rewrite Table component documentation Comprehensive rewrite of the Table component documentation including: - Updated prop definitions with JSX descriptions - New interactive examples for sorting, search, pagination, selection - Expanded code snippets covering all major use cases - Added custom row and primitives examples - Improved MDX structure and styling Signed-off-by: Johan Persson --- .../src/app/components/table/components.tsx | 554 +++++++++++++++--- docs-ui/src/app/components/table/page.mdx | 345 +++++++---- .../app/components/table/props-definition.ts | 268 --------- .../app/components/table/props-definition.tsx | 462 +++++++++++++++ docs-ui/src/app/components/table/snippets.ts | 437 +++++++++++--- docs-ui/src/css/mdx.module.css | 38 +- docs-ui/src/mdx-components.tsx | 7 +- 7 files changed, 1546 insertions(+), 565 deletions(-) delete mode 100644 docs-ui/src/app/components/table/props-definition.ts create mode 100644 docs-ui/src/app/components/table/props-definition.tsx diff --git a/docs-ui/src/app/components/table/components.tsx b/docs-ui/src/app/components/table/components.tsx index cb158579a1..d26deeb5d3 100644 --- a/docs-ui/src/app/components/table/components.tsx +++ b/docs-ui/src/app/components/table/components.tsx @@ -1,31 +1,46 @@ 'use client'; +import { useState } from 'react'; import { Table, + TableRoot, + TableHeader, + TableBody, + Column, + Row, CellProfile, CellText, - type ColumnConfig, useTable, + type ColumnConfig, } from '../../../../../packages/ui/src/components/Table'; import { Flex } from '../../../../../packages/ui/src/components/Flex/Flex'; import { Text } from '../../../../../packages/ui/src/components/Text/Text'; -import { RadioGroup } from '../../../../../packages/ui/src/components/RadioGroup/RadioGroup'; -import { Radio } from '../../../../../packages/ui/src/components/RadioGroup'; -import { data as data4 } from '../../../../../packages/ui/src/components/Table/stories/mocked-data4'; -import { useState } from 'react'; +import { SearchField } from '../../../../../packages/ui/src/components/SearchField/SearchField'; import { - selectionData, - selectionColumns, -} from '../../../../../packages/ui/src/components/Table/stories/utils'; + RadioGroup, + Radio, +} from '../../../../../packages/ui/src/components/RadioGroup'; +import { data as rockBandData } from '../../../../../packages/ui/src/components/Table/stories/mocked-data4'; +import { data as catalogData } from '../../../../../packages/ui/src/components/Table/stories/mocked-data1'; import { MemoryRouter } from 'react-router-dom'; -type Data4Item = (typeof data4)[0]; +// ============================================================================= +// Types +// ============================================================================= -const columns: ColumnConfig[] = [ +type RockBandItem = (typeof rockBandData)[0]; +type CatalogItem = (typeof catalogData)[0]; + +// ============================================================================= +// Hero Example +// ============================================================================= + +const heroColumns: ColumnConfig[] = [ { id: 'name', label: 'Band name', isRowHeader: true, + defaultWidth: '3fr', cell: item => ( ), @@ -33,112 +48,183 @@ const columns: ColumnConfig[] = [ { id: 'genre', label: 'Genre', + defaultWidth: '3fr', cell: item => , }, { id: 'yearFormed', label: 'Year formed', + defaultWidth: '1fr', cell: item => , }, { id: 'albums', label: 'Albums', + defaultWidth: '1fr', cell: item => , }, ]; -export const TableRockBand = () => { +export function HeroExample() { const { tableProps } = useTable({ mode: 'complete', - getData: () => data4, + getData: () => rockBandData, paginationOptions: { pageSize: 5 }, }); return ( - +
); -}; +} -export const SelectionToggleWithActions = () => { - const [selected, setSelected] = useState | 'all'>( - new Set(), - ); +// ============================================================================= +// Sorting Example +// ============================================================================= +const sortingColumns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + isSortable: true, + cell: item => , + }, + { + id: 'owner', + label: 'Owner', + isSortable: true, + cell: item => , + }, + { + id: 'type', + label: 'Type', + isSortable: true, + cell: item => , + }, + { + id: 'lifecycle', + label: 'Lifecycle', + isSortable: true, + cell: item => , + }, +]; + +export function SortingExample() { const { tableProps } = useTable({ mode: 'complete', - getData: () => selectionData, - paginationOptions: { pageSize: 10 }, + getData: () => catalogData, + paginationOptions: { pageSize: 5 }, + initialSort: { column: 'name', direction: 'ascending' }, + sortFn: (items, { column, direction }) => { + return [...items].sort((a, b) => { + let aVal: string; + let bVal: string; + if (column === 'owner') { + aVal = a.owner.name; + bVal = b.owner.name; + } else { + aVal = String(a[column as keyof CatalogItem]); + bVal = String(b[column as keyof CatalogItem]); + } + const cmp = aVal.localeCompare(bVal); + return direction === 'descending' ? -cmp : cmp; + }); + }, }); return ( -
alert(`Clicked: ${item.name}`) }} - /> +
); -}; +} -export const SelectionModePlayground = () => { - const [selectionMode, setSelectionMode] = useState<'single' | 'multiple'>( - 'multiple', - ); - const [selected, setSelected] = useState | 'all'>( - new Set(), - ); +// ============================================================================= +// Search Example +// ============================================================================= - const { tableProps } = useTable({ +const searchColumns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { id: 'type', label: 'Type', cell: item => }, +]; + +export function SearchExample() { + const { tableProps, search } = useTable({ mode: 'complete', - getData: () => selectionData, - paginationOptions: { pageSize: 10 }, + getData: () => catalogData, + paginationOptions: { pageSize: 5 }, + searchFn: (items, query) => { + const lowerQuery = query.toLocaleLowerCase('en-US'); + return items.filter( + item => + item.name.toLocaleLowerCase('en-US').includes(lowerQuery) || + item.owner.name.toLocaleLowerCase('en-US').includes(lowerQuery) || + item.type.toLocaleLowerCase('en-US').includes(lowerQuery), + ); + }, }); return ( - -
+ +
No results found for "{search.value}" + ) : ( + No data available + ) + } + {...tableProps} /> -
- - Selection mode: - - { - setSelectionMode(value as 'single' | 'multiple'); - setSelected(new Set()); - }} - > - single - multiple - -
); -}; +} -export const SelectionBehaviorPlayground = () => { +// ============================================================================= +// Selection Example +// ============================================================================= + +const selectionColumns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { id: 'type', label: 'Type', cell: item => }, +]; + +export function SelectionExample() { + const [selectionMode, setSelectionMode] = useState<'single' | 'multiple'>( + 'multiple', + ); const [selectionBehavior, setSelectionBehavior] = useState< 'toggle' | 'replace' >('toggle'); @@ -148,41 +234,321 @@ export const SelectionBehaviorPlayground = () => { const { tableProps } = useTable({ mode: 'complete', - getData: () => selectionData, - paginationOptions: { pageSize: 10 }, + getData: () => catalogData.slice(0, 5), }); return ( - +
-
- - Selection behavior: - - { - setSelectionBehavior(value as 'toggle' | 'replace'); - setSelected(new Set()); - }} - > - toggle - replace - -
+ +
+ + Selection mode: + + { + setSelectionMode(value as 'single' | 'multiple'); + setSelected(new Set()); + }} + > + single + multiple + +
+
+ + Selection behavior: + + { + setSelectionBehavior(value as 'toggle' | 'replace'); + setSelected(new Set()); + }} + > + toggle + replace + +
+
); -}; +} + +// ============================================================================= +// Row Actions Example +// ============================================================================= + +export function RowActionsExample() { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => catalogData.slice(0, 5), + }); + + return ( + +
alert(`Clicked: ${item.name}`), + }} + selection={{ + mode: 'multiple', + behavior: 'toggle', + selected, + onSelectionChange: setSelected, + }} + {...tableProps} + /> + + ); +} + +// ============================================================================= +// Empty State Example +// ============================================================================= + +const emptyStateColumns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { id: 'type', label: 'Type', cell: item => }, +]; + +export function EmptyStateExample() { + const emptyData: CatalogItem[] = []; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => emptyData, + }); + + return ( + +
No items yet. Create one to get started.} + {...tableProps} + /> + + ); +} + +// ============================================================================= +// Combined Example +// ============================================================================= + +const combinedColumns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + isSortable: true, + cell: item => , + }, + { + id: 'owner', + label: 'Owner', + isSortable: true, + cell: item => , + }, + { + id: 'type', + label: 'Type', + isSortable: true, + cell: item => , + }, +]; + +export function CombinedExample() { + const [selected, setSelected] = useState | 'all'>( + new Set(), + ); + + const { tableProps, search } = useTable({ + mode: 'offset', + getData: async ({ offset, pageSize, search: query, sort }) => { + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Filter data + let filtered = catalogData; + if (query) { + const lowerQuery = query.toLocaleLowerCase('en-US'); + filtered = filtered.filter( + item => + item.name.toLocaleLowerCase('en-US').includes(lowerQuery) || + item.owner.name.toLocaleLowerCase('en-US').includes(lowerQuery) || + item.type.toLocaleLowerCase('en-US').includes(lowerQuery), + ); + } + + // Sort data + if (sort?.column) { + filtered = [...filtered].sort((a, b) => { + let aVal: string; + let bVal: string; + if (sort.column === 'owner') { + aVal = a.owner.name; + bVal = b.owner.name; + } else { + aVal = String(a[sort.column as keyof CatalogItem]); + bVal = String(b[sort.column as keyof CatalogItem]); + } + const cmp = aVal.localeCompare(bVal); + return sort.direction === 'descending' ? -cmp : cmp; + }); + } + + // Paginate + const data = filtered.slice(offset, offset + pageSize); + + return { data, totalCount: filtered.length }; + }, + paginationOptions: { pageSize: 5, pageSizeOptions: [5, 10, 20] }, + initialSort: { column: 'name', direction: 'ascending' }, + }); + + return ( + + + +
alert(`Clicked: ${item.name}`), + }} + selection={{ + mode: 'multiple', + behavior: 'toggle', + selected, + onSelectionChange: setSelected, + }} + emptyState={ + search.value ? ( + No results match your search + ) : ( + No items available + ) + } + {...tableProps} + /> + + + ); +} + +// ============================================================================= +// Custom Row Example +// ============================================================================= + +const customRowColumns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { + id: 'lifecycle', + label: 'Lifecycle', + cell: item => , + }, +]; + +export function CustomRowExample() { + const { tableProps } = useTable({ + mode: 'complete', + getData: () => catalogData.slice(0, 5), + }); + + return ( + +
( + + {column => column.cell(item)} + + )} + {...tableProps} + /> + + ); +} + +// ============================================================================= +// Primitives Example +// ============================================================================= + +export function PrimitivesExample() { + const items = catalogData.slice(0, 5); + + return ( + + + + Name + Owner + Type + + + {items.map(item => ( + + + + + + ))} + + + + ); +} diff --git a/docs-ui/src/app/components/table/page.mdx b/docs-ui/src/app/components/table/page.mdx index f9b27cd8d3..e39f89f5e9 100644 --- a/docs-ui/src/app/components/table/page.mdx +++ b/docs-ui/src/app/components/table/page.mdx @@ -2,161 +2,294 @@ import { PropsTable } from '@/components/PropsTable'; import { Snippet } from '@/components/Snippet'; import { CodeBlock } from '@/components/CodeBlock'; import { - TableRockBand, - SelectionModePlayground, - SelectionBehaviorPlayground, - SelectionToggleWithActions, + HeroExample, + SortingExample, + SearchExample, + SelectionExample, + RowActionsExample, + EmptyStateExample, + CombinedExample, + CustomRowExample, + PrimitivesExample, } from './components'; import { + tablePropsColumns, + tableReturnColumns, + useTableOptionsPropDefs, + useTableReturnPropDefs, tablePropDefs, - tableHeaderPropDefs, - tableBodyPropDefs, tablePaginationPropDefs, + columnConfigPropDefs, + cellTextPropDefs, + cellProfilePropDefs, + tableRootPropDefs, columnPropDefs, rowPropDefs, - cellPropDefs, } from './props-definition'; import { tableUsageSnippet, - tableBasicSnippet, - tableSelectionActionsSnippet, - tableSelectionModeSnippet, - tableSelectionBehaviorSnippet, + tableHeroSnippet, + tableConceptsSnippet, + tableSortingSnippet, + tablePaginationSnippet, + tableSearchSnippet, + tableSelectionSnippet, + tableRowActionsHrefSnippet, + tableRowActionsClickSnippet, + tableRowActionsDisabledSnippet, + tableEmptyStateSnippet, + tableOffsetPaginationSnippet, + tableCursorPaginationSnippet, + tableCombinedSnippet, + tableCustomRowSnippet, + tablePrimitivesSnippet, } from './snippets'; import { ChangelogComponent } from '@/components/ChangelogComponent'; import { PageTitle } from '@/components/PageTitle'; import { Theming } from '@/components/Theming'; +import { ReactAriaLink } from '@/components/ReactAriaLink'; import { TableDefinition } from '../../../utils/definitions'; +export const reactAriaUrls = { + table: 'https://react-aria.adobe.com/Table#table', + tableHeader: 'https://react-aria.adobe.com/Table#tableheader', + tableBody: 'https://react-aria.adobe.com/Table#tablebody', + column: 'https://react-aria.adobe.com/Table#column', + row: 'https://react-aria.adobe.com/Table#row', + cell: 'https://react-aria.adobe.com/Table#cell', +}; + -} code={tableBasicSnippet} /> +} code={tableHeroSnippet} /> ## Usage -## API reference +## Core Concepts -### Table +The Table component is designed around a hook + component pattern: -The main table component that renders data in a structured grid format. +- **`useTable`** manages data fetching, pagination state, sorting, and filtering. It returns `tableProps` that you spread onto the Table component. +- **`Table`** handles rendering - columns, rows, cells, and interactions. - +The hook supports three modes for different data scenarios: -### TableHeader +- **`complete`** - You have all data available upfront (client-side) +- **`offset`** - Your API uses offset/limit pagination +- **`cursor`** - Your API uses cursor-based pagination -The header section of the table that contains the column definitions. + - +**Controlled vs uncontrolled state** -### Column +By default, `useTable` manages sort, search, and filter state internally. Pass `initialSort`, `initialSearch`, or `initialFilter` to set starting values. -A column definition that describes how a column should be rendered. +For full control over state, use the controlled props instead: - +- `sort` / `onSortChange` +- `search` / `onSearchChange` +- `filter` / `onFilterChange` -### TableBody - -The body section of the table that contains the rows. - - - -### Row - -A row definition that describes how a row should be rendered. - - - -### Cell - -A cell definition that describes how a cell should be rendered. - - - -### TablePagination - -A pagination component designed to work with the Table component. - - - -## Examples - -### Basic Table - -Coming soon. - -### Row selection - -Tables support row selection with two configuration options: selection mode and selection behavior. - -#### Selection mode - -Use `selectionMode` to control how many rows can be selected. With `single`, only one row can be selected at a time. With `multiple`, any number of rows can be selected, and a header checkbox provides select-all functionality. - -} - code={tableSelectionModeSnippet} -/> - -#### Selection behavior - -Use `selectionBehavior` to control how selection is indicated and interacted with. With `toggle`, checkboxes appear for selection. With `replace`, selection is indicated by row background color—click to select, Cmd/Ctrl+click for multiple. - -} - code={tableSelectionBehaviorSnippet} -/> - -#### With row actions - -With toggle behavior, clicking a row triggers its action when nothing is selected. Once any row is selected, clicking toggles selection instead. - -With replace behavior, clicking selects the row and double-clicking triggers the action. - -} - code={tableSelectionActionsSnippet} -/> - -### Row Clicks - -Coming soon. - -### Pagination - -Coming soon. +## Common Patterns ### Sorting -Coming soon. +Columns can be made sortable by adding `isSortable: true` to the column configuration. When sortable, clicking a column header cycles through ascending, descending, and unsorted states. A visual indicator shows the current sort direction. -### Asynchronous loading +With `mode: 'complete'`, sorting happens client-side. Provide a `sortFn` that receives the full dataset and the current sort descriptor, and returns the sorted array. You can also set an `initialSort` to define the default sort when the table first renders. For server-side sorting with `offset` or `cursor` modes, see [Server-Side Data](#server-side-data). -Coming soon. +} code={tableSortingSnippet} /> -### Empty state +### Pagination -Coming soon. +Configure page size and available options through `paginationOptions`. The table displays navigation controls automatically. -### Column resizing + -This feature is not available yet — let us know if you'd like us to explore it! +### Search -### Column reordering +The `useTable` hook returns a `search` object with `value` and `onChange` properties, ready to connect to a search input. With `mode: 'complete'`, provide a `searchFn` that filters the dataset based on the search query. -This feature is not available yet — let us know if you'd like us to explore it! +The search state is debounced internally, so rapid typing doesn't trigger excessive re-filtering. When the search query changes, pagination resets to the first page automatically. -### Column pinning +For server-side search with `offset` or `cursor` modes, the search query is passed to your `getData` function. See [Server-Side Data](#server-side-data). -This feature is not available yet — let us know if you'd like us to explore it! +} code={tableSearchSnippet} /> -### Column visibility +### Row Selection -This feature is not available yet — let us know if you'd like us to explore it! +Tables support row selection with two configuration options: `mode` and `behavior`. + +Selection mode controls how many rows can be selected: + +- `single` - Only one row can be selected at a time +- `multiple` - Any number of rows can be selected, with a header checkbox for select-all + +Selection behavior controls the interaction style: + +- `toggle` - Checkboxes appear for selection. Click a checkbox to select/deselect. +- `replace` - No checkboxes. Click a row to select it (replacing previous selection). Use Cmd/Ctrl+click to select multiple rows. + +Selection state can be managed in two ways: + +- **Controlled** - Pass both `selected` and `onSelectionChange` to fully manage state externally +- **Uncontrolled** - Pass only `onSelectionChange` to let the Table manage state while receiving change notifications + +} code={tableSelectionSnippet} /> + +### Row Actions + +Rows can respond to user interaction in two ways: navigating to a URL or triggering a callback. + +Use `getHref` when rows should link to detail pages. The entire row becomes clickable and navigates on click. Links within cells remain independently clickable. + + + +Use `onClick` for custom actions like opening a panel or triggering a dialog. + + + +When combining row actions with selection, the interaction depends on selection behavior: + +- **`toggle`**: Clicking a row triggers its action when nothing is selected. Once any row is selected, clicking anywhere on the row toggles selection instead. +- **`replace`**: Single click selects the row. Double-click triggers the row action. + +You can also disable specific rows from being clicked using `getIsDisabled`: + + + +} /> + +### Empty State + +When the table has no data to display, provide an `emptyState` to show a helpful message instead of an empty table body. Consider providing different messages depending on context - an empty search result is different from a table with no data at all. + +} code={tableEmptyStateSnippet} /> + +## Server-Side Data + +When your data comes from an API with server-side pagination, use `offset` or `cursor` mode instead of `complete`. The key difference: instead of providing all data upfront, you provide a `getData` function that fetches data for the current page. + +### Offset Pagination + +Use `mode: 'offset'` when your API accepts `offset` and `limit` (or `skip` and `take`) parameters. + + + +The `signal` parameter is an `AbortSignal` - pass it to `fetch` so in-flight requests are cancelled when the user navigates away or triggers a new query. + +### Cursor Pagination + +Use `mode: 'cursor'` when your API uses cursor-based pagination (common with GraphQL or APIs that return `nextCursor`/`prevCursor` tokens). + + + +### Loading States + +When fetching data, the table shows a loading state. If the user triggers a new query (by paginating, sorting, or searching) while previous data is displayed, the table enters a "stale" state where it continues showing the previous data until new data arrives. This prevents jarring layout shifts. + +You can access these states via `tableProps.loading` and `tableProps.isStale` if you need to render additional loading indicators. + +## Combining Features + +Real-world tables often combine multiple features. Here's an example with search, sorting, pagination, and row selection working together. + +} code={tableCombinedSnippet} /> + +## Custom Tables + +For most use cases, `useTable` + `Table` provides everything you need. When you need more control, there are several ways you can customize your table. + +### Custom Row and Header Rendering + +Use a render function for `rowConfig` when you need to customize individual rows based on their data - for example, highlighting rows that require attention. Use `header` in column config to customize header cell content. + +} code={tableCustomRowSnippet} /> + +### Using Primitives Directly + +When the `Table` component doesn't support your use case, you can compose with the lower-level primitives: `TableRoot`, `TableHeader`, `TableBody`, `Column`, and `Row`. + +} code={tablePrimitivesSnippet} /> + +## API Reference + +### useTable + +The `useTable` hook manages data fetching, pagination, sorting, and filtering. + +**Options** + + + +**Return Value** + + + +### Table + +The main table component. + + + +### ColumnConfig + + + +### CellText + + + + + +### CellProfile + + + + + +### TablePagination + + + +### Primitives + +Low-level components for building custom table layouts. + +#### TableRoot + + + + + +#### TableHeader + + + +#### TableBody + + + +#### Column + + + + + +#### Row + + + + + +#### Cell + + diff --git a/docs-ui/src/app/components/table/props-definition.ts b/docs-ui/src/app/components/table/props-definition.ts deleted file mode 100644 index e4ef0ffe8b..0000000000 --- a/docs-ui/src/app/components/table/props-definition.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { - classNamePropDefs, - stylePropDefs, - type PropDef, -} from '@/utils/propDefs'; - -export const tablePropDefs: Record = { - selectionBehavior: { - type: 'enum', - values: ['toggle', 'replace'], - default: 'toggle', - description: 'How multiple selection should behave in the collection.', - }, - disabledBehavior: { - type: 'enum', - values: ['selection', 'all'], - default: 'selection', - description: - 'Whether disabledKeys applies to all interactions, or only selection.', - }, - disabledKeys: { - type: 'enum', - values: ['Iterable'], - description: 'A list of row keys to disable.', - }, - selectionMode: { - type: 'enum', - values: ['single', 'multiple'], - description: 'The type of selection that is allowed in the collection.', - }, - selectedKeys: { - type: 'enum', - values: ['all', 'Iterable'], - description: 'The currently selected keys in the collection (controlled).', - }, - defaultSelectedKeys: { - type: 'enum', - values: ['all', 'Iterable'], - description: 'The initial selected keys in the collection (uncontrolled).', - }, - onRowAction: { - type: 'enum', - values: ['(key: Key) => void'], - description: - 'Handler that is called when a user performs an action on the row.', - }, - onSelectionChange: { - type: 'enum', - values: ['(keys: Selection) => void'], - description: 'Handler that is called when the selection changes.', - }, - onSortChange: { - type: 'enum', - values: ['(descriptor: SortDescriptor) => any'], - description: - 'Handler that is called when the sorted column or direction changes.', - }, - ...classNamePropDefs, - ...stylePropDefs, -}; - -export const tableHeaderPropDefs: Record = { - onHoverStart: { - type: 'enum', - values: ['(e: HoverEvent) => void'], - description: 'Handler that is called when a hover interaction starts.', - }, - onHoverEnd: { - type: 'enum', - values: ['(e: HoverEvent) => void'], - description: 'Handler that is called when a hover interaction ends.', - }, - onHoverChange: { - type: 'enum', - values: ['(isHovering: boolean) => void'], - description: 'Handler that is called when the hover state changes.', - }, - ...classNamePropDefs, - ...stylePropDefs, -}; - -export const columnPropDefs: Record = { - id: { - type: 'enum', - values: ['Key'], - description: 'The unique id of the column.', - }, - allowsSorting: { - type: 'boolean', - description: 'Whether the column allows sorting.', - }, - isRowHeader: { - type: 'boolean', - description: - 'Whether a column is a row header and should be announced by assistive technology during row navigation.', - }, - textValue: { - type: 'string', - description: - "A string representation of the column's contents, used for accessibility announcements.", - }, - ...classNamePropDefs, - ...stylePropDefs, -}; - -export const tableBodyPropDefs: Record = { - renderEmptyState: { - type: 'enum', - values: ['(props) => ReactNode'], - description: - 'Provides content to display when there are no rows in the table.', - }, - ...classNamePropDefs, - ...stylePropDefs, -}; - -export const rowPropDefs: Record = { - textValue: { - type: 'string', - description: - "A string representation of the row's contents, used for accessibility announcements.", - }, - isDisabled: { - type: 'boolean', - description: 'Whether the row is disabled.', - }, - id: { - type: 'enum', - values: ['Key'], - description: 'The unique id of the row.', - }, - href: { - type: 'string', - description: 'The URL to navigate to when the row is clicked.', - }, - hrefLang: { - type: 'string', - description: - 'The language of the URL to navigate to when the row is clicked.', - }, - target: { - type: 'string', - description: - 'The target of the URL to navigate to when the row is clicked.', - }, - rel: { - type: 'string', - description: - 'The relationship of the URL to navigate to when the row is clicked.', - }, - onAction: { - type: 'enum', - values: ['() => void'], - description: - "Handler that is called when a user performs an action on the row. The exact user event depends on the collection's selectionBehavior prop and the interaction modality.", - }, - onHoverStart: { - type: 'enum', - values: ['(e: HoverEvent) => void'], - description: 'Handler that is called when a hover interaction starts.', - }, - onHoverEnd: { - type: 'enum', - values: ['(e: HoverEvent) => void'], - description: 'Handler that is called when a hover interaction ends.', - }, - onHoverChange: { - type: 'enum', - values: ['(isHovering: boolean) => void'], - description: 'Handler that is called when the hover state changes.', - }, - onPress: { - type: 'enum', - values: ['(e: PressEvent) => void'], - description: - 'Handler that is called when the press is released over the target.', - }, - onPressStart: { - type: 'enum', - values: ['(e: PressEvent) => void'], - description: 'Handler that is called when a press interaction starts.', - }, - onPressEnd: { - type: 'enum', - values: ['(e: PressEvent) => void'], - description: - 'Handler that is called when a press interaction ends, either over the target or when the pointer leaves the target.', - }, - onPressChange: { - type: 'enum', - values: ['(isPressed: boolean) => void'], - description: 'Handler that is called when the press state changes.', - }, - onPressUp: { - type: 'enum', - values: ['(e: PressEvent) => void'], - description: - 'Handler that is called when a press is released over the target, regardless of whether it started on the target or not.', - }, - ...classNamePropDefs, - ...stylePropDefs, -}; - -export const cellPropDefs: Record = { - id: { - type: 'enum', - values: ['Key'], - description: 'The unique id of the cell.', - }, - textValue: { - type: 'string', - description: - "A string representation of the cell's contents, used for features like typeahead.", - }, - leadingIcon: { - type: 'enum', - values: ['ReactNode'], - description: 'Optional icon to display before the cell content.', - }, - ...classNamePropDefs, - ...stylePropDefs, -}; - -export const tablePaginationPropDefs: Record = { - offset: { - type: 'number', - description: 'The current offset (starting index) for pagination.', - }, - pageSize: { - type: 'number', - description: 'The number of items per page.', - }, - setOffset: { - type: 'enum', - values: ['(offset: number) => void'], - description: 'Handler that is called when the offset changes.', - }, - setPageSize: { - type: 'enum', - values: ['(pageSize: number) => void'], - description: 'Handler that is called when the page size changes.', - }, - rowCount: { - type: 'number', - description: 'The total number of rows in the table.', - }, - onNextPage: { - type: 'enum', - values: ['() => void'], - description: 'Handler that is called when the next page is requested.', - }, - onPreviousPage: { - type: 'enum', - values: ['() => void'], - description: 'Handler that is called when the previous page is requested.', - }, - onPageSizeChange: { - type: 'enum', - values: ['(pageSize: number) => void'], - description: 'Handler that is called when the page size changes.', - }, - showPageSizeOptions: { - type: 'boolean', - description: 'Whether to show the page size options.', - }, - ...classNamePropDefs, - ...stylePropDefs, -}; diff --git a/docs-ui/src/app/components/table/props-definition.tsx b/docs-ui/src/app/components/table/props-definition.tsx new file mode 100644 index 0000000000..58c8f197a7 --- /dev/null +++ b/docs-ui/src/app/components/table/props-definition.tsx @@ -0,0 +1,462 @@ +import { + classNamePropDefs, + stylePropDefs, + type PropDef, +} from '@/utils/propDefs'; +import { Chip } from '@/components/Chip'; + +// ============================================================================= +// PropsTable Column Configuration (Table docs use description instead of responsive) +// ============================================================================= + +export const tablePropsColumns = [ + { key: 'prop' as const, width: '15%' }, + { key: 'type' as const, width: '25%' }, + { key: 'default' as const, width: '15%' }, + { key: 'description' as const, width: '45%' }, +]; + +// For return values (no default column) +export const tableReturnColumns = [ + { key: 'prop' as const, width: '15%' }, + { key: 'type' as const, width: '25%' }, + { key: 'description' as const, width: '60%' }, +]; + +// ============================================================================= +// useTable Hook +// ============================================================================= + +export const useTableOptionsPropDefs: Record = { + mode: { + type: 'enum', + values: ['complete', 'offset', 'cursor'], + description: ( + <> + Data fetching strategy (required). Use complete for + client-side data, offset for offset/limit APIs,{' '} + cursor for cursor-based pagination. + + ), + }, + getData: { + type: 'enum', + values: ['function'], + description: + 'Function that returns or fetches data (required). Signature varies by mode.', + }, + paginationOptions: { + type: 'enum', + values: ['object'], + description: ( + <> + Pagination configuration including pageSize,{' '} + pageSizeOptions, and initialOffset. + + ), + }, + // Uncontrolled state + initialSort: { + type: 'enum', + values: ['SortDescriptor'], + description: 'Default sort configuration on first render (uncontrolled).', + }, + initialSearch: { + type: 'string', + description: 'Default search value on first render (uncontrolled).', + }, + initialFilter: { + type: 'enum', + values: ['TFilter'], + description: 'Default filter value on first render (uncontrolled).', + }, + // Controlled state + sort: { + type: 'enum', + values: ['SortDescriptor'], + description: 'Current sort state (controlled).', + }, + onSortChange: { + type: 'enum', + values: ['(sort: SortDescriptor) => void'], + description: 'Sort change handler (controlled).', + }, + search: { + type: 'string', + description: 'Current search value (controlled).', + }, + onSearchChange: { + type: 'enum', + values: ['(search: string) => void'], + description: 'Search change handler (controlled).', + }, + filter: { + type: 'enum', + values: ['TFilter'], + description: 'Current filter value (controlled).', + }, + onFilterChange: { + type: 'enum', + values: ['(filter: TFilter) => void'], + description: 'Filter change handler (controlled).', + }, + // Client-side functions + sortFn: { + type: 'enum', + values: ['(data, sort) => data'], + description: ( + <> + Client-side sort function. Only used with complete mode. + + ), + }, + searchFn: { + type: 'enum', + values: ['(data, query) => data'], + description: ( + <> + Client-side search function. Only used with complete mode. + + ), + }, + filterFn: { + type: 'enum', + values: ['(data, filter) => data'], + description: ( + <> + Client-side filter function. Only used with complete mode. + + ), + }, +}; + +export const useTableReturnPropDefs: Record = { + tableProps: { + type: 'enum', + values: ['object'], + description: ( + <> + Props to spread onto the Table component. Includes data, + loading, error, pagination, and sort state. + + ), + }, + reload: { + type: 'enum', + values: ['() => void'], + description: 'Function to trigger a data refetch.', + }, + search: { + type: 'enum', + values: ['{ value, onChange }'], + description: ( + <> + Search state object for binding to a SearchField component. + + ), + }, + filter: { + type: 'enum', + values: ['{ value, onChange }'], + description: 'Filter state object for binding to filter controls.', + }, +}; + +// ============================================================================= +// Table Component +// ============================================================================= + +export const tablePropDefs: Record = { + columnConfig: { + type: 'enum', + values: ['ColumnConfig[]'], + description: + 'Array of column configurations defining how each column renders.', + }, + data: { + type: 'enum', + values: ['T[]'], + description: 'Array of data items to display in the table.', + }, + loading: { + type: 'boolean', + default: 'false', + description: 'Whether the table is in a loading state.', + }, + isStale: { + type: 'boolean', + default: 'false', + description: + 'Whether the displayed data is stale while new data is loading.', + }, + error: { + type: 'enum', + values: ['Error'], + description: 'Error object if data fetching failed.', + }, + pagination: { + type: 'enum', + values: ['TablePaginationType'], + description: ( + <> + Pagination configuration (required). Use{' '} + {'{ type: "none" }'} to disable or{' '} + {'{ type: "page", ...props }'} for pagination. + + ), + }, + sort: { + type: 'enum', + values: ['SortState'], + description: 'Sort state including current descriptor and change handler.', + }, + rowConfig: { + type: 'enum', + values: ['RowConfig | RowRenderFn'], + description: ( + <> + Row configuration object with getHref, onClick + , getIsDisabled, or a render function for custom rows. + + ), + }, + selection: { + type: 'enum', + values: ['TableSelection'], + description: + 'Selection configuration including mode, behavior, selected keys, and change handler.', + }, + emptyState: { + type: 'enum', + values: ['ReactNode'], + description: 'Content to display when the table has no data.', + }, + ...classNamePropDefs, + ...stylePropDefs, +}; + +// ============================================================================= +// ColumnConfig +// ============================================================================= + +export const columnConfigPropDefs: Record = { + id: { + type: 'string', + description: 'Unique identifier for the column.', + }, + label: { + type: 'string', + description: 'Display label for the column header.', + }, + cell: { + type: 'enum', + values: ['(item) => ReactNode'], + description: 'Render function for cell content.', + }, + header: { + type: 'enum', + values: ['() => ReactNode'], + description: 'Optional custom render function for the header cell.', + }, + isSortable: { + type: 'boolean', + default: 'false', + description: 'Whether the column supports sorting.', + }, + isHidden: { + type: 'boolean', + default: 'false', + description: 'Whether the column is hidden.', + }, + isRowHeader: { + type: 'boolean', + default: 'false', + description: 'Whether this column is the row header for accessibility.', + }, + width: { + type: 'enum', + values: ['ColumnSize'], + description: 'Current width of the column.', + }, + defaultWidth: { + type: 'enum', + values: ['ColumnSize'], + description: ( + <> + Default width of the column (e.g., 1fr, 200px + ). + + ), + }, + minWidth: { + type: 'enum', + values: ['ColumnStaticSize'], + description: 'Minimum width of the column.', + }, + maxWidth: { + type: 'enum', + values: ['ColumnStaticSize'], + description: 'Maximum width of the column.', + }, +}; + +// ============================================================================= +// CellText +// Note: Extends React Aria Cell props +// ============================================================================= + +export const cellTextPropDefs: Record = { + title: { + type: 'string', + description: 'Primary text content of the cell.', + }, + description: { + type: 'string', + description: 'Secondary description text displayed below the title.', + }, + color: { + type: 'enum', + values: ['TextColors'], + description: 'Text color variant.', + }, + leadingIcon: { + type: 'enum', + values: ['ReactNode'], + description: 'Icon displayed before the text content.', + }, + href: { + type: 'string', + description: 'URL to navigate to when the cell is clicked.', + }, +}; + +// ============================================================================= +// CellProfile +// Note: Extends React Aria Cell props +// ============================================================================= + +export const cellProfilePropDefs: Record = { + name: { + type: 'string', + description: 'Name to display.', + }, + src: { + type: 'string', + description: 'URL of the avatar image.', + }, + description: { + type: 'string', + description: 'Secondary text displayed below the name.', + }, + href: { + type: 'string', + description: 'URL to navigate to when clicked.', + }, + color: { + type: 'enum', + values: ['TextColors'], + description: 'Text color variant.', + }, +}; + +// ============================================================================= +// TablePagination +// ============================================================================= + +export const tablePaginationPropDefs: Record = { + pageSize: { + type: 'number', + description: 'Number of items per page.', + }, + pageSizeOptions: { + type: 'enum', + values: ['number[]'], + description: 'Available page size options for the dropdown.', + }, + offset: { + type: 'number', + description: 'Current offset (starting index) in the data.', + }, + totalCount: { + type: 'number', + description: 'Total number of items across all pages.', + }, + hasNextPage: { + type: 'boolean', + description: 'Whether there is a next page available.', + }, + hasPreviousPage: { + type: 'boolean', + description: 'Whether there is a previous page available.', + }, + onNextPage: { + type: 'enum', + values: ['() => void'], + description: 'Handler called when navigating to the next page.', + }, + onPreviousPage: { + type: 'enum', + values: ['() => void'], + description: 'Handler called when navigating to the previous page.', + }, + onPageSizeChange: { + type: 'enum', + values: ['(size: number) => void'], + description: 'Handler called when the page size changes.', + }, + showPageSizeOptions: { + type: 'boolean', + default: 'true', + description: 'Whether to show the page size dropdown.', + }, + getLabel: { + type: 'enum', + values: ['(props) => string'], + description: 'Custom function to generate the pagination label text.', + }, +}; + +// ============================================================================= +// Primitives +// ============================================================================= + +export const tableRootPropDefs: Record = { + stale: { + type: 'boolean', + default: 'false', + description: ( + <> + Whether the table data is stale (e.g., while fetching new data). Adds{' '} + aria-busy attribute. + + ), + }, +}; + +export const columnPropDefs: Record = { + isRowHeader: { + type: 'boolean', + description: 'Whether this column is a row header for accessibility.', + }, + children: { + type: 'enum', + values: ['ReactNode'], + description: 'Column header content.', + }, +}; + +export const rowPropDefs: Record = { + id: { + type: 'enum', + values: ['string | number'], + description: 'Unique identifier for the row.', + }, + children: { + type: 'enum', + values: ['ReactNode | ((column) => ReactNode)'], + description: + 'Row content. Can be a render function receiving column config.', + }, + ...classNamePropDefs, + ...stylePropDefs, +}; diff --git a/docs-ui/src/app/components/table/snippets.ts b/docs-ui/src/app/components/table/snippets.ts index 0c8e029330..cba479b59f 100644 --- a/docs-ui/src/app/components/table/snippets.ts +++ b/docs-ui/src/app/components/table/snippets.ts @@ -1,108 +1,359 @@ -export const tableUsageSnippet = `import { Cell, CellText, ..., TableHeader, TablePagination } from '@backstage/ui'; +// ============================================================================= +// Usage +// ============================================================================= -
- - - - - - - - - -
-`; +export const tableUsageSnippet = `import { Table, useTable, CellText, type ColumnConfig } from '@backstage/ui'; -export const tableBasicSnippet = `import { Table, TableHeader, Column, TableBody, Row, CellText, CellProfile, TablePagination, useTable } from '@backstage/ui'; - -const data = [ - { - name: 'The Beatles', - image: 'https://upload.wikimedia.org/wikipedia/en/thumb/4/42/Beatles_-...jpg', - genre: 'Rock, Pop, Psychedelic Rock', - yearFormed: 1960, - albums: 13 - }, - // ... more data +const columns: ColumnConfig[] = [ + { id: 'name', label: 'Name', isRowHeader: true, cell: item => }, + { id: 'owner', label: 'Owner', cell: item => }, ]; -// Uncontrolled pagination (easiest) -const { data: paginatedData, paginationProps } = useTable({ - data, - pagination: { - defaultPageSize: 5, +function MyTable() { + const { tableProps } = useTable({ + mode: 'complete', + getData: () => data, + }); + + return ; +}`; + +// ============================================================================= +// Hero / Quick Start +// ============================================================================= + +export const tableHeroSnippet = `import { Table, CellText, useTable, type ColumnConfig } from '@backstage/ui'; + +const data = [ + { id: 1, name: 'Service A', owner: 'Team Alpha', type: 'service' }, + { id: 2, name: 'Service B', owner: 'Team Beta', type: 'website' }, + { id: 3, name: 'Library C', owner: 'Team Gamma', type: 'library' }, +]; + +const columns: ColumnConfig[] = [ + { id: 'name', label: 'Name', isRowHeader: true, cell: item => }, + { id: 'owner', label: 'Owner', cell: item => }, + { id: 'type', label: 'Type', cell: item => }, +]; + +function MyTable() { + const { tableProps } = useTable({ + mode: 'complete', + getData: () => data, + }); + + return
; +}`; + +// ============================================================================= +// Core Concepts +// ============================================================================= + +export const tableConceptsSnippet = `// What useTable returns +const { + tableProps, // Spread onto
+ reload, // Trigger data refetch + search, // { value, onChange } for search input + filter, // { value, onChange } for filters +} = useTable({ ... });`; + +// ============================================================================= +// Common Patterns +// ============================================================================= + +export const tableSortingSnippet = `const columns: ColumnConfig[] = [ + { id: 'name', label: 'Name', isSortable: true, cell: item => }, + { id: 'owner', label: 'Owner', isSortable: true, cell: item => }, + { id: 'type', label: 'Type', cell: item => }, +]; + +const { tableProps } = useTable({ + mode: 'complete', + getData: () => data, + initialSort: { column: 'name', direction: 'ascending' }, + sortFn: (items, { column, direction }) => { + return [...items].sort((a, b) => { + const aVal = String(a[column]); + const bVal = String(b[column]); + const cmp = aVal.localeCompare(bVal); + return direction === 'descending' ? -cmp : cmp; + }); }, }); -
- - Band name - Genre - Year formed - Albums - - - {paginatedData?.map(item => ( - - - - - - - ))} - -
-`; +return ;`; -export const tableSelectionActionsSnippet = `import { Table, TableHeader, TableBody, Column, Row, CellText } from '@backstage/ui'; +export const tablePaginationSnippet = `const { tableProps } = useTable({ + mode: 'complete', + getData: () => data, + paginationOptions: { + pageSize: 10, + pageSizeOptions: [10, 25, 50], + }, +});`; -function MyTable() { - const [selectedKeys, setSelectedKeys] = React.useState(new Set([])); +export const tableSearchSnippet = `const { tableProps, search } = useTable({ + mode: 'complete', + getData: () => data, + searchFn: (items, query) => { + const lowerQuery = query.toLowerCase(); + return items.filter(item => + item.name.toLowerCase().includes(lowerQuery) || + item.owner.toLowerCase().includes(lowerQuery) + ); + }, +}); + +return ( + <> + +
+ +);`; + +export const tableSelectionSnippet = `const [selected, setSelected] = useState | 'all'>(new Set()); + +const { tableProps } = useTable({ + mode: 'complete', + getData: () => data, +}); + +return ( +
+);`; + +export const tableRowActionsHrefSnippet = `
\`/catalog/\${item.namespace}/\${item.name}\` + }} + {...tableProps} +/>`; + +export const tableRowActionsClickSnippet = `
openDetailPanel(item) + }} + {...tableProps} +/>`; + +export const tableRowActionsDisabledSnippet = `
openDetailPanel(item), + getIsDisabled: item => item.status === 'archived', + }} + {...tableProps} +/>`; + +export const tableEmptyStateSnippet = `const { tableProps, search } = useTable({ + mode: 'complete', + getData: () => data, + searchFn: (items, query) => { /* ... */ }, +}); + +return ( +
No results match "{search.value}" + : No items yet. Create one to get started. + } + {...tableProps} + /> +);`; + +// ============================================================================= +// Server-Side Data +// ============================================================================= + +export const tableOffsetPaginationSnippet = `const { tableProps } = useTable({ + mode: 'offset', + getData: async ({ offset, pageSize, sort, search, filter, signal }) => { + const response = await fetch( + \`/api/items?offset=\${offset}&limit=\${pageSize}&q=\${search}\`, + { signal } + ); + const { items, totalCount } = await response.json(); + + return { + data: items, + totalCount, + }; + }, + paginationOptions: { + pageSize: 20, + pageSizeOptions: [20, 50, 100], + }, +});`; + +export const tableCursorPaginationSnippet = `const { tableProps } = useTable({ + mode: 'cursor', + getData: async ({ cursor, pageSize, sort, search, signal }) => { + const response = await fetch( + \`/api/items?cursor=\${cursor ?? ''}&limit=\${pageSize}&q=\${search}\`, + { signal } + ); + const { items, nextCursor, prevCursor, totalCount } = await response.json(); + + return { + data: items, + nextCursor, + prevCursor, + totalCount, // optional - enables "X of Y" display + }; + }, + paginationOptions: { + pageSize: 20, + pageSizeOptions: [20, 50, 100], + }, +});`; + +// ============================================================================= +// Combining Features +// ============================================================================= + +export const tableCombinedSnippet = `interface TypeFilter { + type: string | null; +} + +const columns: ColumnConfig[] = [ + { id: 'name', label: 'Name', isRowHeader: true, isSortable: true, + cell: item => }, + { id: 'owner', label: 'Owner', isSortable: true, + cell: item => }, + { id: 'type', label: 'Type', isSortable: true, + cell: item => }, +]; + +function ItemsTable() { + const [selected, setSelected] = useState | 'all'>(new Set()); + + const { tableProps, search, filter } = useTable({ + mode: 'offset', + initialSort: { column: 'name', direction: 'ascending' }, + getData: async ({ offset, pageSize, sort, search, filter, signal }) => { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(pageSize), + q: search, + ...(sort && { sortBy: sort.column, sortDir: sort.direction }), + ...(filter?.type && { type: filter.type }), + }); + + const response = await fetch(\`/api/items?\${params}\`, { signal }); + const { items, totalCount } = await response.json(); + + return { data: items, totalCount }; + }, + }); return ( -
console.log('Opening', key)} - > - - Name - Status - - - - - - - - - - - -
+ + + +