From 182e002eed06b670f0991cb7e089af8834434796 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sun, 30 Aug 2026 02:22:01 +0200 Subject: [PATCH] Adopt the worksheet focus layer in the Simulate view tables --- .changeset/simulate-tables-focus-layer.md | 5 + libs/@hashintel/petrinaut/docs/experiments.md | 2 +- libs/@hashintel/petrinaut/docs/scenarios.md | 2 +- .../src/ui/components/table.test.tsx | 130 +++++++++++++++ .../petrinaut/src/ui/components/table.tsx | 156 +++++++++--------- 5 files changed, 217 insertions(+), 78 deletions(-) create mode 100644 .changeset/simulate-tables-focus-layer.md create mode 100644 libs/@hashintel/petrinaut/src/ui/components/table.test.tsx diff --git a/.changeset/simulate-tables-focus-layer.md b/.changeset/simulate-tables-focus-layer.md new file mode 100644 index 00000000000..2443d58d114 --- /dev/null +++ b/.changeset/simulate-tables-focus-layer.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +The Simulate-mode lists (scenarios, experiments, optimizations, metrics) follow the worksheet keyboard flow: each table is one Tab stop, ArrowUp/ArrowDown walk the rows, and opening a drawer is select-first — the first click selects a row, a click on the selected row (or Enter/Space) opens it. diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index c074cb7baa5..245a0f99e58 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -53,7 +53,7 @@ Two consequences worth knowing: ### Actions -In the experiment's view drawer (open it by clicking a row in the list, or any experiment in the top-bar **Active experiments** popover): +In the experiment's view drawer (open it from the list -- the first click selects a row, a click on the selected row or Enter opens it -- or via any experiment in the top-bar **Active experiments** popover): - **Cancel** -- stops the experiment. Only available while it is initializing or running. - **Remove** -- deletes the record and disposes the experiment's workers. Available after completion, cancellation, or error. diff --git a/libs/@hashintel/petrinaut/docs/scenarios.md b/libs/@hashintel/petrinaut/docs/scenarios.md index 3f95e8ff4b6..dedd813d7a0 100644 --- a/libs/@hashintel/petrinaut/docs/scenarios.md +++ b/libs/@hashintel/petrinaut/docs/scenarios.md @@ -35,7 +35,7 @@ You will need scenarios when you want to: 6. Configure **Initial state** for each place that should start with tokens. 7. Click **Create**. Save is blocked while the form has validation or LSP errors -- hover the disabled button to see why. -The view drawer (opened by clicking a row in the Scenarios list) is the same form populated with the existing values. It has **Close** and **Save** buttons. +The view drawer opens from the Scenarios list, which works like the other Simulate-mode lists: the first click selects a row, and a click on the selected row (or Enter) opens it. The list is a single Tab stop whose rows the arrow keys walk. The drawer shows the same form populated with the existing values, with **Close** and **Save** buttons. ## Initial state: per-place vs code diff --git a/libs/@hashintel/petrinaut/src/ui/components/table.test.tsx b/libs/@hashintel/petrinaut/src/ui/components/table.test.tsx new file mode 100644 index 00000000000..aaa50a9f6fa --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/table.test.tsx @@ -0,0 +1,130 @@ +/** + * @vitest-environment jsdom + */ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { Table } from "./table"; + +import type { TableColumn } from "./table"; + +afterEach(cleanup); + +interface Row { + id: string; + name: string; +} + +const COLUMNS: TableColumn[] = [ + { id: "name", header: "Name", render: (row) => row.name }, +]; + +const ROWS: Row[] = [ + { id: "one", name: "First" }, + { id: "two", name: "Second" }, + { id: "three", name: "Third" }, +]; + +const rowShowing = (text: string): HTMLElement => { + const target = screen.getByText(text).closest("[role='row']"); + if (!(target instanceof HTMLElement)) { + throw new Error(`no row for ${text}`); + } + return target; +}; + +const focusRow = (text: string): HTMLElement => { + const target = rowShowing(text); + act(() => { + target.focus(); + }); + expect(document.activeElement).toBe(target); + return target; +}; + +describe("Table keyboard flow", () => { + it("is one tab stop whose rows the arrows walk", () => { + const { container } = render( + row.id} + emptyLabel="Empty" + onRowSelect={() => {}} + />, + ); + + expect(container.querySelectorAll("[tabindex='0']")).toHaveLength(1); + + focusRow("First"); + fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" }); + expect(document.activeElement).toBe(rowShowing("Second")); + fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" }); + expect(document.activeElement).toBe(rowShowing("Third")); + fireEvent.keyDown(document.activeElement!, { key: "ArrowUp" }); + expect(document.activeElement).toBe(rowShowing("Second")); + + expect(container.querySelectorAll("[tabindex='0']")).toHaveLength(1); + }); + + it("activates select-first: the first click selects, the second opens", () => { + const onRowSelect = vi.fn(); + render( +
row.id} + emptyLabel="Empty" + onRowSelect={onRowSelect} + />, + ); + + const row = rowShowing("Second"); + fireEvent.pointerDown(row); + focusRow("Second"); + fireEvent.click(row, { detail: 1 }); + expect(onRowSelect).not.toHaveBeenCalled(); + + fireEvent.pointerDown(row); + fireEvent.click(row, { detail: 1 }); + expect(onRowSelect).toHaveBeenCalledWith(ROWS[1]); + }); + + it("activates on Enter and Space", () => { + const onRowSelect = vi.fn(); + render( +
row.id} + emptyLabel="Empty" + onRowSelect={onRowSelect} + />, + ); + + focusRow("First"); + fireEvent.keyDown(document.activeElement!, { key: "Enter" }); + expect(onRowSelect).toHaveBeenLastCalledWith(ROWS[0]); + fireEvent.keyDown(document.activeElement!, { key: " " }); + expect(onRowSelect).toHaveBeenCalledTimes(2); + }); + + it("renders inert rows without onRowSelect", () => { + const { container } = render( +
row.id} + emptyLabel="Empty" + />, + ); + + expect(container.querySelectorAll("[tabindex]")).toHaveLength(0); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/components/table.tsx b/libs/@hashintel/petrinaut/src/ui/components/table.tsx index 9ce10935acf..e7a9077058d 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/table.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/table.tsx @@ -1,11 +1,13 @@ +import { useRef } from "react"; + import { css, cx } from "@hashintel/ds-helpers/css"; -import type { - CSSProperties, - KeyboardEvent, - MouseEvent, - ReactNode, -} from "react"; +import { focusLands } from "../worksheet/focus-flow"; +import { useFocusStops } from "../worksheet/use-focus-stops"; +import { useSelectFirstActivation } from "../worksheet/use-select-first"; + +import type { FocusStop } from "../worksheet/use-focus-stops"; +import type { CSSProperties, ReactNode } from "react"; type TableCellTone = "emphasis" | "subtle"; @@ -25,7 +27,6 @@ type TableProps = { getRowId: (row: Row) => string; rows: readonly Row[]; onRowSelect?: (row: Row) => void; - renderActions?: (row: Row) => ReactNode; selectedRowId?: string | null; }; @@ -92,7 +93,9 @@ const selectedRowStyle = css({ const selectableTableRowStyle = css({ cursor: "pointer", outline: "none", - _focusVisible: { + // The select-first grammar needs the focused row visible for pointer users + // too, so this shows on any focus, not only :focus-visible. + _focus: { boxShadow: "[inset 0 0 0 2px {colors.neutral.a25}]", }, }); @@ -121,13 +124,6 @@ const tableCellTextSubtleStyle = css({ color: "neutral.s80", }); -const tableActionCellStyle = css({ - width: "[28px]", - flexShrink: 0, - display: "flex", - justifyContent: "flex-end", -}); - const tableEmptyStateStyle = css({ flex: "1", display: "flex", @@ -165,59 +161,46 @@ const renderCellContent = ( return content; }; -function handleSelectableRowKeyDown( - event: KeyboardEvent, - row: Row, - onRowSelect: (row: Row) => void, -) { - if (event.target !== event.currentTarget) { - return; - } - - if (event.key !== "Enter" && event.key !== " ") { - return; - } - - event.preventDefault(); - onRowSelect(row); -} - -function handleSelectableRowClick( - event: MouseEvent, - row: Row, - onRowSelect: (row: Row) => void, -) { - const target = event.target; - - if ( - target instanceof Element && - target.closest("[data-table-action-cell]") !== null - ) { - return; - } - - onRowSelect(row); -} - +/** + * A read-only data table whose selectable rows follow the worksheet keyboard + * flow: the table is one Tab stop (roving tabindex), ArrowUp/ArrowDown walk + * the rows, and activation is select-first — the first click focuses a row, + * a click on the focused row (or Enter/Space) calls `onRowSelect`. Without + * `onRowSelect` the rows are inert. + */ export function Table({ columns, emptyLabel, getRowId, rows, onRowSelect, - renderActions, selectedRowId, }: TableProps) { + const targets = useRef>(new Map()); + + const stops: FocusStop[] = onRowSelect + ? rows.map((row) => ({ id: getRowId(row), kind: "row" })) + : []; + const { + onKeyDown: onStopsKeyDown, + onFocusTarget, + tabIndexFor, + attach, + } = useFocusStops({ + stops, + columnCount: 1, + focusTarget: (target) => focusLands(targets.current.get(target.stopId)), + }); + const { onPointerDown, shouldActivate } = useSelectFirstActivation(); + if (rows.length === 0) { return
{emptyLabel}
; } - const columnCount = columns.length + (renderActions ? 1 : 0); - const actionColumnIndex = columns.length + 1; - return (
({ {column.header} ))} - {renderActions ? ( - - ) : null}
@@ -265,6 +240,17 @@ export function Table({ return (
{ + if (element) { + targets.current.set(rowId, element); + } else { + targets.current.delete(rowId); + } + } + : undefined + } aria-rowindex={rowIndex + 2} aria-selected={onRowSelect ? isSelected : undefined} className={cx( @@ -273,30 +259,48 @@ export function Table({ isSelected ? selectedRowStyle : undefined, )} role="row" - tabIndex={onRowSelect ? 0 : undefined} + tabIndex={ + onRowSelect + ? tabIndexFor({ stopId: rowId, column: 0 }) + : undefined + } + onFocus={ + onRowSelect + ? (event) => { + if (event.target === event.currentTarget) { + onFocusTarget({ stopId: rowId, column: 0 }); + } + } + : undefined + } + onPointerDown={onRowSelect ? onPointerDown : undefined} onClick={ onRowSelect - ? (event) => handleSelectableRowClick(event, row, onRowSelect) + ? (event) => { + if (shouldActivate(event)) { + onRowSelect(row); + } + } : undefined } onKeyDown={ onRowSelect - ? (event) => - handleSelectableRowKeyDown(event, row, onRowSelect) + ? (event) => { + if (event.target !== event.currentTarget) { + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); + onRowSelect(row); + return; + } + onStopsKeyDown({ stopId: rowId, column: 0 })(event); + } : undefined } > {cells} - {renderActions ? ( -
- {renderActions(row)} -
- ) : null}
); })}