Skip to content

Commit e264249

Browse files
authored
Merge branch 'main' into rob/token-cache-clock-skew
2 parents 42608de + 84c4780 commit e264249

6 files changed

Lines changed: 237 additions & 8 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/src/components/table/table.styles.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,25 @@ export const styles = stylex.create({
4242
width: '100%',
4343
},
4444
header: {
45-
'--_cl-table-surface': colorVars['--cl-color-border-subtle'],
46-
backgroundColor: colorVars['--cl-color-border-subtle'],
45+
'--_cl-table-surface': colorVars['--cl-color-background-subtle'],
46+
backgroundColor: colorVars['--cl-color-background-subtle'],
4747
},
4848
bodyRow: {
4949
borderBlockStartColor: colorVars['--cl-color-border'],
5050
borderBlockStartStyle: 'solid',
5151
borderBlockStartWidth: '1px',
5252
},
53+
interactiveRow: {
54+
'--_cl-table-row-tint': {
55+
default: null,
56+
'@media (hover: hover)': { ':hover': colorVars['--cl-color-background-subtle'] },
57+
},
58+
backgroundColor: {
59+
default: null,
60+
'@media (hover: hover)': { ':hover': colorVars['--cl-color-background-subtle'] },
61+
},
62+
cursor: 'pointer',
63+
},
5364
selectedRow: {
5465
'--_cl-table-row-tint': colorVars['--cl-color-neutral-alpha-100'],
5566
backgroundColor: colorVars['--cl-color-neutral-alpha-100'],

‎packages/mosaic/src/components/table/table.test.tsx‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as stylex from '@stylexjs/stylex';
22
import { render, screen, within } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
44
import React from 'react';
5+
import { createPortal } from 'react-dom';
56
import { describe, expect, it, vi } from 'vitest';
67

78
import { MagnifyingGlass } from '../../icons/glyphs/magnifying-glass';
@@ -313,6 +314,71 @@ describe('Mosaic Table', () => {
313314
});
314315
});
315316

317+
describe('interactive rows', () => {
318+
it('marks rows with a click handler as interactive', async () => {
319+
const user = userEvent.setup();
320+
const onClick = vi.fn();
321+
render(
322+
<Table.Root>
323+
<Table.Body>
324+
<Table.Row onClick={onClick}>
325+
<Table.Cell>Alpha</Table.Cell>
326+
</Table.Row>
327+
<Table.Row>
328+
<Table.Cell>Beta</Table.Cell>
329+
</Table.Row>
330+
</Table.Body>
331+
</Table.Root>,
332+
);
333+
const [interactive, plain] = screen.getAllByRole('row');
334+
expect(interactive).toHaveAttribute('data-interactive', '');
335+
expect(plain).not.toHaveAttribute('data-interactive');
336+
await user.click(screen.getByText('Alpha'));
337+
expect(onClick).toHaveBeenCalledTimes(1);
338+
});
339+
340+
it('does not run the row action when a control inside the row is clicked', async () => {
341+
const user = userEvent.setup();
342+
const onRowClick = vi.fn();
343+
const onButtonClick = vi.fn();
344+
render(
345+
<Table.Root>
346+
<Table.Body>
347+
<Table.Row onClick={onRowClick}>
348+
<Table.Cell>
349+
<button
350+
type='button'
351+
onClick={onButtonClick}
352+
>
353+
View
354+
</button>
355+
</Table.Cell>
356+
</Table.Row>
357+
</Table.Body>
358+
</Table.Root>,
359+
);
360+
await user.click(screen.getByRole('button', { name: 'View' }));
361+
expect(onButtonClick).toHaveBeenCalledTimes(1);
362+
expect(onRowClick).not.toHaveBeenCalled();
363+
});
364+
365+
it('does not run the row action for clicks inside content portaled out of the row', async () => {
366+
const user = userEvent.setup();
367+
const onRowClick = vi.fn();
368+
render(
369+
<Table.Root>
370+
<Table.Body>
371+
<Table.Row onClick={onRowClick}>
372+
<Table.Cell>{createPortal(<p>Menu content</p>, document.body)}</Table.Cell>
373+
</Table.Row>
374+
</Table.Body>
375+
</Table.Root>,
376+
);
377+
await user.click(screen.getByText('Menu content'));
378+
expect(onRowClick).not.toHaveBeenCalled();
379+
});
380+
});
381+
316382
describe('empty state', () => {
317383
it('renders the empty state across every column', () => {
318384
render(

‎packages/mosaic/src/components/table/table.tsx‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,23 +208,51 @@ export interface TableRowProps extends MosaicComponentProps<'tr'> {
208208
}
209209

210210
const Row = React.forwardRef<HTMLTableRowElement, TableRowProps>(function MosaicTableRow(
211-
{ selected, render, xstyle, ...rest },
211+
{ selected, onClick, render, xstyle, ...rest },
212212
ref,
213213
) {
214214
const section = React.useContext(TableSectionContext);
215+
const interactive = onClick !== undefined;
215216
return useRender({
216217
defaultTagName: 'tr',
217218
render,
218219
ref,
219220
props: mergeStyleProps(
220-
themeProps('table-row', { selected }),
221-
stylex.props(reset.base, section === 'body' && styles.bodyRow, selected && styles.selectedRow, xstyle),
222-
{ 'aria-selected': selected ? true : undefined },
221+
themeProps('table-row', { selected, interactive }),
222+
stylex.props(
223+
reset.base,
224+
section === 'body' && styles.bodyRow,
225+
interactive && styles.interactiveRow,
226+
selected && styles.selectedRow,
227+
xstyle,
228+
),
229+
{
230+
'aria-selected': selected ? true : undefined,
231+
onClick: interactive
232+
? (event: React.MouseEvent<HTMLTableRowElement>) => {
233+
if (isRowOwnClick(event)) {
234+
onClick(event);
235+
}
236+
}
237+
: undefined,
238+
},
223239
rest,
224240
),
225241
});
226242
});
227243

244+
const nestedControlSelector =
245+
'a[href], button, input, select, textarea, label, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"]';
246+
247+
const isRowOwnClick = (event: React.MouseEvent<HTMLElement>) => {
248+
const { target, currentTarget } = event;
249+
if (!(target instanceof Element) || !currentTarget.contains(target)) {
250+
return false;
251+
}
252+
const control = target.closest(nestedControlSelector);
253+
return control === null || !currentTarget.contains(control);
254+
};
255+
228256
export interface TableHeaderCellProps extends Omit<MosaicComponentProps<'th'>, 'align'> {
229257
align?: TableAlign;
230258
sticky?: TableSticky;

‎packages/swingset/src/stories/table.mdx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ import { Table } from '@clerk/mosaic/components/table';
5555
- `Table.ColumnGroup` renders a `<colgroup>`. Put it first inside `Table.Root`, with one column part per column.
5656
- `Table.Column` renders a `<col>`. 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.
5757
- `Table.Header` and `Table.Body` render `<thead>` and `<tbody>`. Rows inside the body get a top border.
58-
- `Table.Row` renders a `<tr>`. Pass `selected` to mark it `aria-selected` and tint it.
58+
- `Table.Row` renders a `<tr>`. 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.
5959
- `Table.HeaderCell` renders a `<th scope="col">`. 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'`.
6060
- `Table.Cell` renders a `<td>` with the same `align` prop. `noWrap` defaults to `false`; enable it to keep dates and other short values on one line.
6161
- `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
7777
| `.cl-table-column` | `Table.Column` | |
7878
| `.cl-table-header` | `Table.Header` | |
7979
| `.cl-table-body` | `Table.Body` | |
80-
| `.cl-table-row` | `Table.Row` | `data-selected` when `selected` |
80+
| `.cl-table-row` | `Table.Row` | `data-selected` when `selected`, `data-interactive` when `onClick` is set |
8181
| `.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`) |
8282
| `.cl-table-cell` | `Table.Cell` | `data-align` (`start` / `center` / `end`), `data-sticky` (`start` / `end`), `data-no-wrap` when `noWrap` |
8383
| `.cl-table-select-cell` | `Table.SelectAllCell`, `Table.SelectCell` | |
@@ -153,3 +153,12 @@ With `layout='fixed'`, the widths in `Table.ColumnGroup` decide the columns, so
153153
name='ColumnWidths'
154154
storyModule={TableStories}
155155
/>
156+
157+
### Clickable rows
158+
159+
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.
160+
161+
<Story
162+
name='ClickableRows'
163+
storyModule={TableStories}
164+
/>

‎packages/swingset/src/stories/table.stories.tsx‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { Badge } from '@clerk/mosaic/components/badge';
22
import { Button } from '@clerk/mosaic/components/button';
3+
import { Card } from '@clerk/mosaic/components/card';
4+
import { DataList } from '@clerk/mosaic/components/data-list';
5+
import { Dialog } from '@clerk/mosaic/components/dialog';
36
import { EmptyState } from '@clerk/mosaic/components/empty-state';
47
import { Icon } from '@clerk/mosaic/components/icon';
58
import { Menu } from '@clerk/mosaic/components/menu';
@@ -500,3 +503,113 @@ export function ColumnWidths() {
500503
</Table.Root>
501504
);
502505
}
506+
507+
const keyDetails = Dialog.createHandle<ApiKey>();
508+
509+
export function ClickableRows() {
510+
return (
511+
<>
512+
<Table.Root>
513+
<Table.Header>
514+
<Table.Row>
515+
<Table.HeaderCell>Name</Table.HeaderCell>
516+
<Table.HeaderCell>Environment</Table.HeaderCell>
517+
<Table.HeaderCell>Last used</Table.HeaderCell>
518+
<Table.HeaderCell align='end'>
519+
<VisuallyHidden>Actions</VisuallyHidden>
520+
</Table.HeaderCell>
521+
</Table.Row>
522+
</Table.Header>
523+
<Table.Body>
524+
{apiKeys.map(key => (
525+
<ClickableKeyRow
526+
key={key.id}
527+
apiKey={key}
528+
/>
529+
))}
530+
</Table.Body>
531+
</Table.Root>
532+
<Dialog.Root handle={keyDetails}>
533+
{({ payload }) =>
534+
payload === undefined ? null : (
535+
<Dialog.Popup
536+
variant='card'
537+
compactPlacement='center'
538+
>
539+
<Card.Root
540+
elevation='overlay'
541+
renderBranding={false}
542+
>
543+
<Card.Header>
544+
<Card.Title>{payload.name}</Card.Title>
545+
<Card.Description>API key</Card.Description>
546+
</Card.Header>
547+
<Card.Content>
548+
<DataList.Root>
549+
<DataList.Item>
550+
<DataList.Label>Environment</DataList.Label>
551+
<DataList.Value>{payload.environment}</DataList.Value>
552+
</DataList.Item>
553+
<DataList.Item>
554+
<DataList.Label>Last used</DataList.Label>
555+
<DataList.Value>{payload.lastUsed}</DataList.Value>
556+
</DataList.Item>
557+
<DataList.Item>
558+
<DataList.Label>Created</DataList.Label>
559+
<DataList.Value>{payload.created}</DataList.Value>
560+
</DataList.Item>
561+
</DataList.Root>
562+
</Card.Content>
563+
<Card.Footer>
564+
<Dialog.Close
565+
render={
566+
<Button
567+
color='neutral'
568+
fullWidth
569+
variant='outline'
570+
/>
571+
}
572+
>
573+
Close
574+
</Dialog.Close>
575+
</Card.Footer>
576+
</Card.Root>
577+
</Dialog.Popup>
578+
)
579+
}
580+
</Dialog.Root>
581+
</>
582+
);
583+
}
584+
585+
function ClickableKeyRow({ apiKey }: { apiKey: ApiKey }) {
586+
const trigger = React.useRef<HTMLButtonElement>(null);
587+
return (
588+
<Table.Row onClick={() => trigger.current?.click()}>
589+
<Table.Cell>
590+
<Dialog.Trigger
591+
ref={trigger}
592+
handle={keyDetails}
593+
payload={apiKey}
594+
render={<Button variant='link' />}
595+
>
596+
{apiKey.name}
597+
</Dialog.Trigger>
598+
</Table.Cell>
599+
<Table.Cell>
600+
<Badge color={apiKey.environment === 'Production' ? 'positive' : 'neutral'}>{apiKey.environment}</Badge>
601+
</Table.Cell>
602+
<Table.Cell>{apiKey.lastUsed}</Table.Cell>
603+
<Table.Cell align='end'>
604+
<Menu.Root placement='bottom-end'>
605+
<Menu.Trigger aria-label={`Manage ${apiKey.name}`} />
606+
<Menu.Popup>
607+
<Menu.Item label='Revoke'>
608+
<Menu.Label>Revoke</Menu.Label>
609+
</Menu.Item>
610+
</Menu.Popup>
611+
</Menu.Root>
612+
</Table.Cell>
613+
</Table.Row>
614+
);
615+
}

0 commit comments

Comments
 (0)