diff --git a/.changeset/mosaic-styled-table-primitive.md b/.changeset/mosaic-styled-table-primitive.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-styled-table-primitive.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index c789a45bd55..0ef6ef8a5da 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -51,7 +51,6 @@ Most problems a feature hits already have a shared answer. Find the row for your | Current time for display labels | `useNow` (`src/hooks/`) | | Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) | | An inline message row that animates open and closed | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` | -| A table whose sorting, paging and search are done by the server | `useServerDataTable` (`src/hooks/`) | | Data read from a Clerk resource that should be cached across mounts | A query hook in `src/queries/`, called from the model (`src/queries/use-user-sessions-query.ts`) | | An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` | | Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) | diff --git a/packages/mosaic/bundlewatch.config.json b/packages/mosaic/bundlewatch.config.json index befa16e25a3..db7fe24c63a 100644 --- a/packages/mosaic/bundlewatch.config.json +++ b/packages/mosaic/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ { "path": "./dist/index.js", "maxSize": "95KB" }, - { "path": "./dist/styles.css", "maxSize": "16KB" } + { "path": "./dist/styles.css", "maxSize": "17KB" } ] } diff --git a/packages/mosaic/src/components/table/index.ts b/packages/mosaic/src/components/table/index.ts index e07d15b3a19..cc27cbfd2c1 100644 --- a/packages/mosaic/src/components/table/index.ts +++ b/packages/mosaic/src/components/table/index.ts @@ -3,17 +3,16 @@ export type { TableAlign, TableBodyProps, TableCellProps, - TableColumnGroupProps, TableColumnProps, - TableEmptyProps, - TableHeaderCellProps, + TableExpandButtonProps, TableHeaderProps, TableLayout, - TableProps, + TableRootProps, TableRowProps, TableSearchProps, - TableSelectAllCellProps, - TableSelectCellProps, + TableSelectionCellProps, + TableSelectionColumnProps, + TableSelectionMode, TableSort, TableSticky, TableToolbarProps, diff --git a/packages/mosaic/src/components/table/table.feature.test.tsx b/packages/mosaic/src/components/table/table.feature.test.tsx index 866f4559afb..a1ce361be19 100644 --- a/packages/mosaic/src/components/table/table.feature.test.tsx +++ b/packages/mosaic/src/components/table/table.feature.test.tsx @@ -13,21 +13,20 @@ function renderNarrowTable(minWidth: string) { layout='fixed' minWidth={minWidth} > - - - - - - Name - Created - + Name + Created - - - Alpha - Today - + item} + > + {() => ( + + Alpha + Today + + )} @@ -47,3 +46,78 @@ describe('Mosaic Table', () => { expect(nameColumnWidth()).toBe(176); }); }); + +describe('Mosaic Table expand button', () => { + it.each([ + ['ltr', 'collapsed', 'none'], + ['ltr', 'expanded', 'matrix(0, 1, -1, 0, 0, 0)'], + ['rtl', 'collapsed', 'matrix(-1, 0, 0, 1, 0, 0)'], + ['rtl', 'expanded', 'matrix(0, 1, -1, 0, 0, 0)'], + ] as const)('points the %s chevron for a %s row', (dir, state, transform) => { + render( + +
+ + + Name + + item.id} + itemToChildren={item => item.children} + > + {item => ( + 0}> + + {item.children.length > 0 && } + {item.id} + + + )} + + +
+
, + ); + const icon = screen.getByRole('button', { name: 'Expand parent' }).querySelector('svg'); + expect(icon).not.toBeNull(); + expect(icon && getComputedStyle(icon).transform).toBe(transform); + }); +}); + +describe('Mosaic Table sticky fade', () => { + it.each([ + ['ltr', 'start', 'to right'], + ['ltr', 'end', 'to left'], + ['rtl', 'start', 'to left'], + ['rtl', 'end', 'to right'], + ] as const)('fades %s %s-pinned cells away from the pinned edge', (dir, side, gradient) => { + render( + +
+ + + Name + + item} + > + {() => ( + + Alpha + + )} + + +
+
, + ); + expect(getComputedStyle(screen.getByRole('cell', { name: 'Alpha' }), '::before').backgroundImage).toContain( + `linear-gradient(${gradient}`, + ); + }); +}); diff --git a/packages/mosaic/src/components/table/table.markers.stylex.ts b/packages/mosaic/src/components/table/table.markers.stylex.ts new file mode 100644 index 00000000000..7ed2950f202 --- /dev/null +++ b/packages/mosaic/src/components/table/table.markers.stylex.ts @@ -0,0 +1,3 @@ +import * as stylex from '@stylexjs/stylex'; + +export const tableStateMarker = stylex.defineMarker(); diff --git a/packages/mosaic/src/components/table/table.styles.ts b/packages/mosaic/src/components/table/table.styles.ts index 857f0b3706b..1d4b3ad55a2 100644 --- a/packages/mosaic/src/components/table/table.styles.ts +++ b/packages/mosaic/src/components/table/table.styles.ts @@ -1,6 +1,16 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, fontWeightVars, radiusVars, scrollFadeVars, space, typeScaleVars } from '../../tokens.stylex'; +import { + colorVars, + durationVars, + focusVars, + fontWeightVars, + radiusVars, + scrollFadeVars, + space, + typeScaleVars, +} from '../../tokens.stylex'; +import { tableStateMarker } from './table.markers.stylex'; const surface = 'var(--_cl-table-surface)'; const tint = 'var(--_cl-table-row-tint)'; @@ -45,43 +55,52 @@ export const styles = stylex.create({ '--_cl-table-surface': colorVars['--cl-color-background-subtle'], backgroundColor: colorVars['--cl-color-background-subtle'], }, - bodyRow: { - borderBlockStartColor: colorVars['--cl-color-border'], - borderBlockStartStyle: 'solid', - borderBlockStartWidth: '1px', - }, - interactiveRow: { + row: { '--_cl-table-row-tint': { default: null, - '@media (hover: hover)': { ':hover': colorVars['--cl-color-background-subtle'] }, + ':where([data-selected])': colorVars['--cl-color-neutral-alpha-100'], + '@media (hover: hover)': { + default: null, + ':where([data-actionable]:not([data-selected]):hover)': colorVars['--cl-color-background-subtle'], + }, }, backgroundColor: { default: null, - '@media (hover: hover)': { ':hover': colorVars['--cl-color-background-subtle'] }, + ':where([data-selected])': colorVars['--cl-color-neutral-alpha-100'], + '@media (hover: hover)': { + default: null, + ':where([data-actionable]:not([data-selected]):hover)': colorVars['--cl-color-background-subtle'], + }, }, - cursor: 'pointer', - }, - selectedRow: { - '--_cl-table-row-tint': colorVars['--cl-color-neutral-alpha-100'], - backgroundColor: colorVars['--cl-color-neutral-alpha-100'], + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', + cursor: { default: null, ':where([data-actionable])': 'pointer' }, + position: 'relative', }, - headerCell: { + column: { paddingBlock: space['2.5'], paddingInline: space['4'], color: colorVars['--cl-color-foreground-secondary'], fontSize: typeScaleVars['--cl-text-xs-size'], fontWeight: fontWeightVars['--cl-font-medium'], lineHeight: typeScaleVars['--cl-text-xs-leading'], + position: 'relative', verticalAlign: 'middle', whiteSpace: 'nowrap', }, - sortableHeaderCell: { - paddingBlock: space['1.5'], - paddingInline: space['2'], + sortableColumn: { + color: { + default: colorVars['--cl-color-foreground-secondary'], + '@media (hover: hover)': { default: null, ':hover': colorVars['--cl-color-foreground'] }, + }, + cursor: 'pointer', + userSelect: 'none', }, - sortButton: { - color: 'inherit', - fontWeight: 'inherit', + sortLabel: { + gap: space['1'], + alignItems: 'center', + display: 'inline-flex', }, cell: { paddingBlock: space['3'], @@ -89,6 +108,7 @@ export const styles = stylex.create({ color: colorVars['--cl-color-foreground'], fontSize: typeScaleVars['--cl-text-sm-size'], lineHeight: typeScaleVars['--cl-text-sm-leading'], + position: 'relative', verticalAlign: 'middle', }, selectCell: { @@ -103,7 +123,7 @@ export const styles = stylex.create({ backgroundColor: surface, backgroundImage: `linear-gradient(${tint}, ${tint})`, position: 'sticky', - zIndex: 1, + zIndex: { default: 1, ':focus-visible': 2 }, '::before': { insetBlock: 0, content: '""', @@ -112,9 +132,53 @@ export const styles = stylex.create({ width: scrollFadeVars['--cl-scroll-fade-size'], }, }, - emptyCell: { + focusRing: { + outlineOffset: { default: null, ':focus-visible': `calc(-1 * ${focusVars['--cl-focus-outline-width']})` }, + zIndex: { default: null, ':focus-visible': 2 }, + }, + empty: { textAlign: 'center', }, + expandButton: { + borderRadius: radiusVars['--cl-radius-sm'], + alignItems: 'center', + color: colorVars['--cl-color-foreground-secondary'], + cursor: 'pointer', + display: 'inline-flex', + justifyContent: 'center', + marginInlineEnd: space['1'], + verticalAlign: 'middle', + }, + expandIcon: { + transform: { + default: null, + ':is([dir="rtl"] *):not([data-expanded] *)': 'scaleX(-1)', + [stylex.when.ancestor('[data-expanded]', tableStateMarker)]: 'rotate(90deg)', + }, + transitionDuration: durationVars['--cl-duration-fast'], + transitionProperty: 'transform', + }, +}); + +export const sortIcons = stylex.create({ + none: { + display: { + default: 'block', + [stylex.when.ancestor('[data-sort-direction]', tableStateMarker)]: 'none', + }, + }, + ascending: { + display: { + default: 'none', + [stylex.when.ancestor('[data-sort-direction="ascending"]', tableStateMarker)]: 'block', + }, + }, + descending: { + display: { + default: 'none', + [stylex.when.ancestor('[data-sort-direction="descending"]', tableStateMarker)]: 'block', + }, + }, }); export const layouts = stylex.create({ diff --git a/packages/mosaic/src/components/table/table.test.tsx b/packages/mosaic/src/components/table/table.test.tsx index 03f5b3ca932..79792ae89a4 100644 --- a/packages/mosaic/src/components/table/table.test.tsx +++ b/packages/mosaic/src/components/table/table.test.tsx @@ -2,11 +2,8 @@ import * as stylex from '@stylexjs/stylex'; import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { createPortal } from 'react-dom'; import { describe, expect, it, vi } from 'vitest'; -import { MagnifyingGlass } from '../../icons/glyphs/magnifying-glass'; -import { EmptyState } from '../empty-state'; import { Table } from './table'; import { stickySides, styles } from './table.styles'; @@ -14,20 +11,63 @@ const atoms = stylex.create({ spaced: { marginTop: '8px' }, }); +interface Key { + id: string; + name: string; + created: string; + children?: Key[]; +} + +const keys: Key[] = [ + { id: 'alpha', name: 'Alpha', created: 'Today' }, + { id: 'beta', name: 'Beta', created: 'Yesterday' }, +]; + function renderTable(props: Partial> = {}) { return render( - + - - Name - Created - + Name + Created - - - Alpha - Today - + key.id} + > + {key => ( + + {key.name} + {key.created} + + )} + + , + ); +} + +function renderSelectableTable() { + return render( + + + + Name + + key.id} + > + {key => ( + + + {key.name} + + )} , ); @@ -79,11 +119,16 @@ describe('Mosaic Table', () => { it('opts cells into no-wrap styling without changing neighboring cells', () => { render( - - - September 23, 2026 - A longer description can wrap - + key.id} + > + {() => ( + + September 23, 2026 + A longer description can wrap + + )} , ); @@ -95,20 +140,23 @@ describe('Mosaic Table', () => { expect(description).not.toHaveClass(stylex.props(styles.noWrap).className ?? ''); }); - it.each(['start', 'end'] as const)('pins header and body cells to the %s edge', side => { + it.each(['start', 'end'] as const)('pins columns and cells to the %s edge', side => { render( - - Name - Actions - + Name + Actions - - - Alpha - Edit - + key.id} + > + {() => ( + + Alpha + Edit + + )} , ); @@ -144,8 +192,8 @@ describe('Mosaic Table', () => { const [header, body] = screen.getAllByRole('rowgroup'); expect(header).toHaveClass('cl-table-header'); expect(body).toHaveClass('cl-table-body'); - expect(screen.getAllByRole('row')[0]).toHaveClass('cl-table-row'); - expect(screen.getByRole('columnheader', { name: 'Name' })).toHaveClass('cl-table-header-cell'); + expect(screen.getByRole('row', { name: /Alpha/ })).toHaveClass('cl-table-row'); + expect(screen.getByRole('columnheader', { name: 'Name' })).toHaveClass('cl-table-column'); expect(screen.getByRole('cell', { name: 'Alpha' })).toHaveClass('cl-table-cell'); }); @@ -156,270 +204,211 @@ describe('Mosaic Table', () => { expect(ref.current).toHaveClass('cl-table', stylex.props(atoms.spaced).className ?? ''); }); + it('forwards the body ref to the rendered tbody', () => { + const ref = React.createRef(); + const { container } = render( + + + Name + + key.id} + > + {key => ( + + {key.name} + + )} + + , + ); + expect(ref.current).toBe(container.querySelector('tbody')); + }); + + it('renders row header cells with cell styling', () => { + render( + + + Name + + key.id} + > + {key => ( + + {key.name} + + )} + + , + ); + const cell = screen.getByRole('rowheader', { name: 'Alpha' }); + expect(cell).toHaveClass('cl-table-cell', ...(stylex.props(styles.cell).className ?? '').split(' ')); + }); + describe('sorting', () => { - it('renders a plain header cell without a sort handler', () => { + it('leaves a column without sortable as plain text', () => { renderTable(); - const cell = screen.getByRole('columnheader', { name: 'Name' }); - expect(within(cell).queryByRole('button')).toBeNull(); - expect(cell).not.toHaveAttribute('aria-sort'); + const column = screen.getByRole('columnheader', { name: 'Name' }); + expect(column).not.toHaveAttribute('aria-sort'); + expect(column).not.toHaveAttribute('data-sortable'); }); - it('renders a sort button and reflects the direction when sortable', async () => { - const onSort = vi.fn(); - const { rerender } = render( - - - - { + const user = userEvent.setup(); + const onSortChange = vi.fn(); + function Example({ direction }: { direction: 'ascending' | 'descending' | null }) { + return ( + + + Name - - - - , - ); - const cell = screen.getByRole('columnheader', { name: 'Name' }); - expect(cell).not.toHaveAttribute('aria-sort'); - expect(cell).toHaveAttribute('data-sortable', ''); + + + Created + + + + ); + } + const { rerender } = render(); + const name = screen.getByRole('columnheader', { name: 'Name' }); + expect(name).toHaveAttribute('aria-sort', 'none'); + expect(name).toHaveAttribute('data-sortable', ''); - await userEvent.click(within(cell).getByRole('button', { name: 'Name' })); - expect(onSort).toHaveBeenCalledTimes(1); + await user.click(name); + expect(onSortChange).toHaveBeenLastCalledWith({ column: 'name', direction: 'ascending' }); - rerender( - - - - - Name - - - - , - ); - expect(cell).toHaveAttribute('aria-sort', 'ascending'); - expect(cell).toHaveAttribute('data-sort', 'ascending'); + rerender(); + expect(name).toHaveAttribute('aria-sort', 'ascending'); + expect(name).toHaveAttribute('data-sort-direction', 'ascending'); + expect(screen.getByRole('columnheader', { name: 'Created' })).toHaveAttribute('aria-sort', 'none'); - rerender( - - - - - Name - - - - , - ); - expect(cell).toHaveAttribute('aria-sort', 'descending'); + await user.click(name); + expect(onSortChange).toHaveBeenLastCalledWith({ column: 'name', direction: 'descending' }); }); }); describe('selection', () => { - it('renders selection cells with checkboxes', async () => { - const onSelectAll = vi.fn(); - const onSelectRow = vi.fn(); - render( - - - - - Name - - - - - - Alpha - - - , - ); - - const selectAll = screen.getByRole('checkbox', { name: 'Select all rows' }); - expect(selectAll.indeterminate).toBe(true); + it('renders Mosaic checkboxes named by their labels and the row header', () => { + renderSelectableTable(); + const selectAll = screen.getByRole('checkbox', { name: 'Select all keys' }); + expect(selectAll).toHaveClass('cl-checkbox-input'); expect(selectAll.closest('th')).toHaveClass('cl-table-select-cell'); - await userEvent.click(selectAll); - expect(onSelectAll).toHaveBeenCalledTimes(1); - const selectRow = screen.getByRole('checkbox', { name: 'Select Alpha' }); - expect(selectRow).toBeChecked(); + expect(selectRow).toHaveClass('cl-checkbox-input'); expect(selectRow.closest('td')).toHaveClass('cl-table-select-cell'); - await userEvent.click(selectRow); - expect(onSelectRow).toHaveBeenCalledTimes(1); }); - it('reports whether a row toggle extends a range', async () => { - const onToggleSelected = vi.fn(); - render( - - - - - Alpha - - - , - ); - + it('selects rows and reflects partial and full selection on the header checkbox', async () => { const user = userEvent.setup(); - const selectRow = screen.getByRole('checkbox', { name: 'Select Alpha' }); - await user.click(selectRow); - expect(onToggleSelected).toHaveBeenLastCalledWith({ range: false }); + renderSelectableTable(); + const selectAll = screen.getByRole('checkbox', { name: 'Select all keys' }); - await user.keyboard('{Shift>}'); - await user.click(selectRow); - await user.keyboard('{/Shift}'); - expect(onToggleSelected).toHaveBeenLastCalledWith({ range: true }); - }); + await user.click(screen.getByRole('checkbox', { name: 'Select Alpha' })); + expect(screen.getByRole('row', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('row', { name: 'Alpha' })).toHaveAttribute('data-selected', ''); + expect(selectAll.indeterminate).toBe(true); - it('reflects the selected row state', () => { - render( - - - - Alpha - - - Beta - - - , - ); - const [selected, unselected] = screen.getAllByRole('row'); - expect(selected).toHaveAttribute('aria-selected', 'true'); - expect(selected).toHaveAttribute('data-selected', ''); - expect(unselected).not.toHaveAttribute('aria-selected'); - expect(unselected).not.toHaveAttribute('data-selected'); + await user.click(selectAll); + expect(screen.getByRole('checkbox', { name: 'Select Beta' })).toBeChecked(); + expect(selectAll).toBeChecked(); + expect(selectAll.indeterminate).toBe(false); }); }); - describe('interactive rows', () => { - it('marks rows with a click handler as interactive', async () => { - const user = userEvent.setup(); - const onClick = vi.fn(); - render( - - - - Alpha - - - Beta - - - , - ); - const [interactive, plain] = screen.getAllByRole('row'); - expect(interactive).toHaveAttribute('data-interactive', ''); - expect(plain).not.toHaveAttribute('data-interactive'); - await user.click(screen.getByText('Alpha')); - expect(onClick).toHaveBeenCalledTimes(1); - }); - - it('does not run the row action when a control inside the row is clicked', async () => { - const user = userEvent.setup(); - const onRowClick = vi.fn(); - const onButtonClick = vi.fn(); - render( - - - - - - + it('marks rows with an action as actionable', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + + + Name + + key.id} + > + {key => ( + + {key.name} - - , - ); - await user.click(screen.getByRole('button', { name: 'View' })); - expect(onButtonClick).toHaveBeenCalledTimes(1); - expect(onRowClick).not.toHaveBeenCalled(); - }); + )} + + , + ); + expect(screen.getByRole('row', { name: 'Alpha' })).toHaveAttribute('data-actionable', ''); + expect(screen.getByRole('row', { name: 'Beta' })).not.toHaveAttribute('data-actionable'); + await user.click(screen.getByText('Alpha')); + expect(onClick).toHaveBeenCalledTimes(1); + }); - it('does not run the row action for clicks inside content portaled out of the row', async () => { - const user = userEvent.setup(); - const onRowClick = vi.fn(); - render( - - - - {createPortal(

Menu content

, document.body)}
+ it('renders the empty state across every column', () => { + render( + + + Name + Created + + key.id} + renderEmptyState={() => 'No results'} + > + {key => ( + + {key.name} + {key.created} - - , - ); - await user.click(screen.getByText('Menu content')); - expect(onRowClick).not.toHaveBeenCalled(); - }); + )} +
+
, + ); + const cell = screen.getByRole('cell'); + expect(cell).toHaveAttribute('colspan', '2'); + expect(within(cell).getByText('No results')).toHaveClass('cl-table-empty'); }); - describe('empty state', () => { - it('renders the empty state across every column', () => { - render( - - + it('expands tree rows from a button named by its label and the row header', async () => { + const user = userEvent.setup(); + render( + + + Name + + key.id} + itemToChildren={key => key.children} + > + {key => ( - Name - Created + + + {key.name} + - - - - - - No results - Try a different search. - - - - , - ); - const cell = screen.getByRole('cell'); - expect(cell).toHaveClass('cl-table-empty'); - expect(cell).toHaveAttribute('colspan', '2'); - expect(within(cell).getByText('No results')).toHaveClass('cl-empty-state-label'); - expect(within(cell).getByText('Try a different search.')).toBeInTheDocument(); - }); - - it('spans every column when colSpan is omitted', () => { - render( - - - - - No results - - - - , - ); - expect(screen.getByRole('cell')).toHaveAttribute('colspan', '1000'); - }); + )} +
+ , + ); + const expand = screen.getByRole('button', { name: 'Expand Alpha' }); + expect(expand).toHaveClass('cl-table-expand-button'); + expect(screen.queryByRole('row', { name: 'Beta' })).toBeNull(); + await user.click(expand); + expect(expand).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('row', { name: 'Beta' })).toBeInTheDocument(); }); }); diff --git a/packages/mosaic/src/components/table/table.tsx b/packages/mosaic/src/components/table/table.tsx index 6bfe6c13e22..1b20803f4c0 100644 --- a/packages/mosaic/src/components/table/table.tsx +++ b/packages/mosaic/src/components/table/table.tsx @@ -3,40 +3,82 @@ import * as stylex from '@stylexjs/stylex'; import React from 'react'; import { ChevronDown } from '../../icons/glyphs/chevron-down'; +import { ChevronRight } from '../../icons/glyphs/chevron-right'; import { ChevronUp } from '../../icons/glyphs/chevron-up'; import { ChevronUpDown } from '../../icons/glyphs/chevron-up-down'; import { MagnifyingGlass } from '../../icons/glyphs/magnifying-glass'; import { X } from '../../icons/glyphs/x'; +import type { + TableBodyProps as HeadlessTableBodyProps, + TableCellProps as HeadlessTableCellProps, + TableColumnProps as HeadlessTableColumnProps, + TableExpandButtonProps as HeadlessTableExpandButtonProps, + TableHeaderProps as HeadlessTableHeaderProps, + TableRootProps as HeadlessTableRootProps, + TableRowProps as HeadlessTableRowProps, + TableSelectionCheckboxProps as HeadlessTableSelectionCheckboxProps, +} from '../../primitives/table'; +import { Table as Primitive } from '../../primitives/table'; import { useRender } from '../../primitives/utils'; -import type { MosaicComponentProps, MosaicElementProps } from '../../props'; +import type { MosaicComponentProps, MosaicStyleProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; +import { focusOutline } from '../../styles/focus-outline.styles'; import { reset } from '../../styles/reset.styles'; 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, columnWidth, layouts, stickySides, styles, tableMinWidth } from './table.styles'; +import { tableStateMarker } from './table.markers.stylex'; +import { aligns, columnWidth, layouts, sortIcons, stickySides, styles, tableMinWidth } from './table.styles'; -type TableSection = 'header' | 'body'; +export type { TableSelectionMode, TableSort } from '../../primitives/table'; -const TableSectionContext = React.createContext('body'); +type StyledProps = Omit & MosaicStyleProps; export type TableAlign = keyof typeof aligns; -export type TableSort = 'ascending' | 'descending' | 'none'; - export type TableSticky = keyof typeof stickySides; export type TableLayout = keyof typeof layouts; -export interface TableProps extends MosaicElementProps<'table'> { +export interface TableRootProps extends StyledProps { layout?: TableLayout; minWidth?: string; } +export type TableHeaderProps = StyledProps; + +export interface TableColumnProps extends StyledProps> { + align?: TableAlign; + sticky?: TableSticky; + width?: string; +} + +export type TableBodyProps = StyledProps>; + +export type TableRowProps = StyledProps; + +export interface TableCellProps extends StyledProps> { + align?: TableAlign; + sticky?: TableSticky; + noWrap?: boolean; +} + +export interface TableSelectionColumnProps extends Omit< + HeadlessTableSelectionCheckboxProps, + 'className' | 'style' | 'render' | 'aria-label' +> { + 'aria-label': string; +} + +export type TableSelectionCellProps = TableSelectionColumnProps; + +export interface TableExpandButtonProps extends StyledProps> { + 'aria-label': string; +} + export type TableToolbarProps = MosaicComponentProps<'div'>; const Toolbar = React.forwardRef(function MosaicTableToolbar( @@ -106,7 +148,7 @@ const Search = React.forwardRef(function Mos ); }); -const Root = React.forwardRef(function MosaicTable( +const Root = React.forwardRef(function MosaicTable( { layout = 'auto', minWidth, xstyle, ...rest }, ref, ) { @@ -120,7 +162,7 @@ const Root = React.forwardRef(function MosaicTable stylex.props(reset.base, scrollAreaViewport('auto', 'inline'), styles.viewport), )} > - (function MosaicTable ); }); -export type TableColumnGroupProps = MosaicComponentProps<'colgroup'>; - -const ColumnGroup = React.forwardRef(function MosaicTableColumnGroup( - { render, xstyle, ...rest }, - ref, -) { - return useRender({ - defaultTagName: 'colgroup', - render, - ref, - props: mergeStyleProps(themeProps('table-column-group'), stylex.props(reset.base, xstyle), rest), - }); -}); - -export interface TableColumnProps extends MosaicComponentProps<'col'> { - width?: string; -} - -const Column = React.forwardRef(function MosaicTableColumn( - { width, render, xstyle, ...rest }, - ref, -) { - return useRender({ - defaultTagName: 'col', - render, - ref, - props: mergeStyleProps( - themeProps('table-column'), - stylex.props(reset.base, width !== undefined && columnWidth.fixed(width), xstyle), - rest, - ), - }); -}); - -export type TableHeaderProps = MosaicComponentProps<'thead'>; - const Header = React.forwardRef(function MosaicTableHeader( - { render, xstyle, ...rest }, - ref, -) { - const element = useRender({ - defaultTagName: 'thead', - render, - ref, - props: mergeStyleProps(themeProps('table-header'), stylex.props(reset.base, styles.header, xstyle), rest), - }); - return {element}; -}); - -export type TableBodyProps = MosaicComponentProps<'tbody'>; - -const Body = React.forwardRef(function MosaicTableBody( - { render, xstyle, ...rest }, + { xstyle, ...rest }, ref, ) { - const element = useRender({ - defaultTagName: 'tbody', - render, - ref, - props: mergeStyleProps(themeProps('table-body'), stylex.props(reset.base, xstyle), rest), - }); - return {element}; -}); - -export interface TableRowProps extends MosaicComponentProps<'tr'> { - selected?: boolean; -} - -const Row = React.forwardRef(function MosaicTableRow( - { selected, onClick, render, xstyle, ...rest }, - ref, -) { - const section = React.useContext(TableSectionContext); - const interactive = onClick !== undefined; - return useRender({ - defaultTagName: 'tr', - render, - ref, - props: mergeStyleProps( - themeProps('table-row', { selected, interactive }), - stylex.props( - reset.base, - section === 'body' && styles.bodyRow, - interactive && styles.interactiveRow, - selected && styles.selectedRow, - xstyle, - ), - { - 'aria-selected': selected ? true : undefined, - onClick: interactive - ? (event: React.MouseEvent) => { - if (isRowOwnClick(event)) { - onClick(event); - } - } - : undefined, - }, - rest, - ), - }); + return ( + + ); }); -const nestedControlSelector = - 'a[href], button, input, select, textarea, label, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"]'; - -const isRowOwnClick = (event: React.MouseEvent) => { - const { target, currentTarget } = event; - if (!(target instanceof Element) || !currentTarget.contains(target)) { - return false; - } - const control = target.closest(nestedControlSelector); - return control === null || !currentTarget.contains(control); -}; - -export interface TableHeaderCellProps extends Omit, 'align'> { - align?: TableAlign; - sticky?: TableSticky; - sort?: TableSort; - onSort?: () => void; -} - -const sortIcons = { - ascending: ChevronUp, - descending: ChevronDown, - none: ChevronUpDown, -} as const; - -const HeaderCell = React.forwardRef(function MosaicTableHeaderCell( - { align = 'start', sticky, sort = 'none', onSort, children, render, xstyle, ...rest }, +const Column = React.forwardRef(function MosaicTableColumn( + { align = 'start', sticky, width, sortable = false, children, xstyle, ...rest }, ref, ) { - const sortable = onSort !== undefined; - return useRender({ - defaultTagName: 'th', - render, - ref, - props: { - scope: 'col', - ...mergeStyleProps( - themeProps('table-header-cell', { - align, - sticky, - sortable, - sort: sortable && sort !== 'none' ? sort : undefined, - }), + return ( + + )} + > + {sortable ? ( + {children} + - + + ) : ( children - ), - }, - }); + )} + + ); }); -export interface TableCellProps extends Omit, 'align'> { - align?: TableAlign; - sticky?: TableSticky; - noWrap?: boolean; +type TableBodyComponent = ( + props: TableBodyProps & React.RefAttributes, +) => React.ReactElement; + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- forwardRef drops the Item type parameter; React 18 support rules out a generic ref prop. +const Body = React.forwardRef(BodyImpl) as TableBodyComponent; + +function BodyImpl( + { xstyle, renderEmptyState, ...rest }: TableBodyProps, + ref: React.ForwardedRef, +) { + return ( + ( +
+ {renderEmptyState()} +
+ ) + : undefined + } + {...mergeStyleProps(themeProps('table-body'), stylex.props(reset.base, xstyle), rest)} + /> + ); } +const Row = React.forwardRef(function MosaicTableRow({ xstyle, ...rest }, ref) { + return ( + + ); +}); + const Cell = React.forwardRef(function MosaicTableCell( - { align = 'start', sticky, noWrap = false, render, xstyle, ...rest }, + { align = 'start', sticky, noWrap = false, xstyle, ...rest }, ref, ) { - return useRender({ - defaultTagName: 'td', - render, - ref, - props: mergeStyleProps( - themeProps('table-cell', { align, sticky, noWrap }), - stylex.props( - reset.base, - styles.cell, - aligns[align], - noWrap && styles.noWrap, - sticky && [styles.sticky, stickySides[sticky]], - xstyle, - ), - rest, - ), - }); + return ( + + ); }); -export type TableSelectAllCellProps = CheckboxProps; - -export interface TableSelectCellProps extends CheckboxProps { - onToggleSelected?: (options: { range: boolean }) => void; -} - -const selectCellProps = (slot: string) => - mergeStyleProps(themeProps(slot), stylex.props(reset.base, styles.cell, styles.selectCell)); +const selectCellProps = () => + mergeStyleProps(themeProps('table-select-cell'), stylex.props(reset.base, styles.cell, styles.selectCell)); -const isShiftClick = (event: Event) => 'shiftKey' in event && event.shiftKey === true; - -const SelectAllCell = React.forwardRef( - function MosaicTableSelectAllCell(props, ref) { +const SelectionColumn = React.forwardRef( + function MosaicTableSelectionColumn(props, ref) { return ( -
+ ); }, ); -const SelectCell = React.forwardRef(function MosaicTableSelectCell( - { onToggleSelected, onChange, ...rest }, - ref, -) { - return ( - - ); -}); - -export type TableEmptyProps = MosaicElementProps<'td'>; +const SelectionCell = React.forwardRef( + function MosaicTableSelectionCell(props, ref) { + return ( + + } + {...props} + /> + + ); + }, +); -const Empty = React.forwardRef(function MosaicTableEmpty( - { colSpan = 1000, xstyle, ...rest }, +const ExpandButton = React.forwardRef(function MosaicTableExpandButton( + { xstyle, ...rest }, ref, ) { return ( - -
- + } {...props} /> - - { - onChange?.(event); - onToggleSelected?.({ range: isShiftClick(event.nativeEvent) }); - }} - {...rest} - /> - + - + ); }); @@ -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 ? (