diff --git a/.changeset/mosaic-table-interactive-rows.md b/.changeset/mosaic-table-interactive-rows.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-table-interactive-rows.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/table/table.styles.ts b/packages/mosaic/src/components/table/table.styles.ts index 2aa2c763c54..857f0b3706b 100644 --- a/packages/mosaic/src/components/table/table.styles.ts +++ b/packages/mosaic/src/components/table/table.styles.ts @@ -42,14 +42,25 @@ export const styles = stylex.create({ width: '100%', }, header: { - '--_cl-table-surface': colorVars['--cl-color-border-subtle'], - backgroundColor: colorVars['--cl-color-border-subtle'], + '--_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: { + '--_cl-table-row-tint': { + default: null, + '@media (hover: hover)': { ':hover': colorVars['--cl-color-background-subtle'] }, + }, + backgroundColor: { + default: null, + '@media (hover: hover)': { ':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'], diff --git a/packages/mosaic/src/components/table/table.test.tsx b/packages/mosaic/src/components/table/table.test.tsx index 8371ad8b4ff..03f5b3ca932 100644 --- a/packages/mosaic/src/components/table/table.test.tsx +++ b/packages/mosaic/src/components/table/table.test.tsx @@ -2,6 +2,7 @@ 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'; @@ -313,6 +314,71 @@ describe('Mosaic Table', () => { }); }); + 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( + + + + + + + + + , + ); + await user.click(screen.getByRole('button', { name: 'View' })); + expect(onButtonClick).toHaveBeenCalledTimes(1); + expect(onRowClick).not.toHaveBeenCalled(); + }); + + 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)}
+
+
+
, + ); + await user.click(screen.getByText('Menu content')); + expect(onRowClick).not.toHaveBeenCalled(); + }); + }); + describe('empty state', () => { it('renders the empty state across every column', () => { render( diff --git a/packages/mosaic/src/components/table/table.tsx b/packages/mosaic/src/components/table/table.tsx index 5a6dae9bfeb..6bfe6c13e22 100644 --- a/packages/mosaic/src/components/table/table.tsx +++ b/packages/mosaic/src/components/table/table.tsx @@ -208,23 +208,51 @@ export interface TableRowProps extends MosaicComponentProps<'tr'> { } const Row = React.forwardRef(function MosaicTableRow( - { selected, render, xstyle, ...rest }, + { 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 }), - stylex.props(reset.base, section === 'body' && styles.bodyRow, selected && styles.selectedRow, xstyle), - { 'aria-selected': selected ? true : undefined }, + 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, ), }); }); +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; diff --git a/packages/swingset/src/stories/table.mdx b/packages/swingset/src/stories/table.mdx index ec1ed0cc988..09a23032c47 100644 --- a/packages/swingset/src/stories/table.mdx +++ b/packages/swingset/src/stories/table.mdx @@ -55,7 +55,7 @@ import { Table } from '@clerk/mosaic/components/table'; - `Table.ColumnGroup` renders a ``. Put it first inside `Table.Root`, with one column part per column. - `Table.Column` renders a ``. Pass `width` (any CSS length) to set the column's width. A column without `width` takes the space the others leave, unless the cell in its first row sets its own width, as `Table.SelectAllCell` does. - `Table.Header` and `Table.Body` render `` and ``. Rows inside the body get a top border. -- `Table.Row` renders a ``. Pass `selected` to mark it `aria-selected` and tint it. +- `Table.Row` renders a ``. Pass `selected` to mark it `aria-selected` and tint it. Pass `onClick` to make the whole row clickable: it gets a pointer cursor and a hover tint, and clicks on links, buttons, and other controls inside the row are left to those controls. The row is not focusable, so put a button or link with the same action in its first cell for keyboard users, and have `onClick` click that control so a dialog it opens returns focus to it on close. - `Table.HeaderCell` renders a ``. Pass `onSort` to make it sortable: the label becomes a button with a direction icon, and `sort` (`'ascending'`, `'descending'`, or `'none'`) reflects the current direction as `aria-sort`. `align` moves the content to `'center'` or `'end'`. - `Table.Cell` renders a `` with the same `align` prop. `noWrap` defaults to `false`; enable it to keep dates and other short values on one line. - `Table.HeaderCell` and `Table.Cell` take `sticky` (`'start'` or `'end'`) to pin the cell to that edge while the table scrolls horizontally. Set it on every cell in the column. A pinned cell gets an opaque background that matches its row and a fade on its inner edge. @@ -77,7 +77,7 @@ Each part carries a stable `.cl-*` class, and the parts with state reflect it as | `.cl-table-column` | `Table.Column` | | | `.cl-table-header` | `Table.Header` | | | `.cl-table-body` | `Table.Body` | | -| `.cl-table-row` | `Table.Row` | `data-selected` when `selected` | +| `.cl-table-row` | `Table.Row` | `data-selected` when `selected`, `data-interactive` when `onClick` is set | | `.cl-table-header-cell` | `Table.HeaderCell` | `data-align` (`start` / `center` / `end`), `data-sticky` (`start` / `end`), `data-sortable` when `onSort` is set, `data-sort` (`ascending` / `descending`) | | `.cl-table-cell` | `Table.Cell` | `data-align` (`start` / `center` / `end`), `data-sticky` (`start` / `end`), `data-no-wrap` when `noWrap` | | `.cl-table-select-cell` | `Table.SelectAllCell`, `Table.SelectCell` | | @@ -153,3 +153,12 @@ With `layout='fixed'`, the widths in `Table.ColumnGroup` decide the columns, so name='ColumnWidths' storyModule={TableStories} /> + +### Clickable rows + +Clicking anywhere on a row opens the key's details. The name is a dialog trigger, so keyboard users reach the same dialog, and the row's `onClick` clicks that trigger, so focus returns to it when the dialog closes. The row menu still opens on its own. + + diff --git a/packages/swingset/src/stories/table.stories.tsx b/packages/swingset/src/stories/table.stories.tsx index e3e87f99694..dbd9f610ae0 100644 --- a/packages/swingset/src/stories/table.stories.tsx +++ b/packages/swingset/src/stories/table.stories.tsx @@ -1,5 +1,8 @@ import { Badge } from '@clerk/mosaic/components/badge'; import { Button } from '@clerk/mosaic/components/button'; +import { Card } from '@clerk/mosaic/components/card'; +import { DataList } from '@clerk/mosaic/components/data-list'; +import { Dialog } from '@clerk/mosaic/components/dialog'; import { EmptyState } from '@clerk/mosaic/components/empty-state'; import { Icon } from '@clerk/mosaic/components/icon'; import { Menu } from '@clerk/mosaic/components/menu'; @@ -500,3 +503,113 @@ export function ColumnWidths() { ); } + +const keyDetails = Dialog.createHandle(); + +export function ClickableRows() { + return ( + <> + + + + Name + Environment + Last used + + Actions + + + + + {apiKeys.map(key => ( + + ))} + + + + {({ payload }) => + payload === undefined ? null : ( + + + + {payload.name} + API key + + + + + Environment + {payload.environment} + + + Last used + {payload.lastUsed} + + + Created + {payload.created} + + + + + + } + > + Close + + + + + ) + } + + + ); +} + +function ClickableKeyRow({ apiKey }: { apiKey: ApiKey }) { + const trigger = React.useRef(null); + return ( + trigger.current?.click()}> + + } + > + {apiKey.name} + + + + {apiKey.environment} + + {apiKey.lastUsed} + + + + + + Revoke + + + + + + ); +}