Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/simulate-tables-focus-layer.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion libs/@hashintel/petrinaut/docs/experiments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion libs/@hashintel/petrinaut/docs/scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
130 changes: 130 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/components/table.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Row>[] = [
{ 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(
<Table
columns={COLUMNS}
rows={ROWS}
getRowId={(row) => 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(
<Table
columns={COLUMNS}
rows={ROWS}
getRowId={(row) => 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(
<Table
columns={COLUMNS}
rows={ROWS}
getRowId={(row) => 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(
<Table
columns={COLUMNS}
rows={ROWS}
getRowId={(row) => row.id}
emptyLabel="Empty"
/>,
);

expect(container.querySelectorAll("[tabindex]")).toHaveLength(0);
});
});
156 changes: 80 additions & 76 deletions libs/@hashintel/petrinaut/src/ui/components/table.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -25,7 +27,6 @@ type TableProps<Row> = {
getRowId: (row: Row) => string;
rows: readonly Row[];
onRowSelect?: (row: Row) => void;
renderActions?: (row: Row) => ReactNode;
selectedRowId?: string | null;
};

Expand Down Expand Up @@ -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}]",
},
});
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -165,59 +161,46 @@ const renderCellContent = (
return content;
};

function handleSelectableRowKeyDown<Row>(
event: KeyboardEvent<HTMLDivElement>,
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<Row>(
event: MouseEvent<HTMLDivElement>,
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<Row>({
columns,
emptyLabel,
getRowId,
rows,
onRowSelect,
renderActions,
selectedRowId,
}: TableProps<Row>) {
const targets = useRef<Map<string, HTMLElement>>(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 <div className={tableEmptyStateStyle}>{emptyLabel}</div>;
}

const columnCount = columns.length + (renderActions ? 1 : 0);
const actionColumnIndex = columns.length + 1;

return (
<div
aria-colcount={columnCount}
ref={onRowSelect ? attach : undefined}
aria-colcount={columns.length}
aria-rowcount={rows.length + 1}
className={tableStyle}
role="table"
Expand All @@ -235,14 +218,6 @@ export function Table<Row>({
{column.header}
</span>
))}
{renderActions ? (
<span
aria-colindex={actionColumnIndex}
aria-label="Actions"
className={tableActionCellStyle}
role="columnheader"
/>
) : null}
</div>
</div>

Expand All @@ -265,6 +240,17 @@ export function Table<Row>({
return (
<div
key={rowId}
ref={
onRowSelect
? (element) => {
if (element) {
targets.current.set(rowId, element);
} else {
targets.current.delete(rowId);
}
}
: undefined
}
aria-rowindex={rowIndex + 2}
aria-selected={onRowSelect ? isSelected : undefined}
className={cx(
Expand All @@ -273,30 +259,48 @@ export function Table<Row>({
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 ? (
<div
aria-colindex={actionColumnIndex}
className={tableActionCellStyle}
data-table-action-cell=""
role="cell"
>
{renderActions(row)}
</div>
) : null}
</div>
);
})}
Expand Down
Loading