Skip to content
Open
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/spreadsheet-focus-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Token spreadsheets now follow the worksheet keyboard flow: each grid is one Tab stop with roving focus, arrows walk cells and the row-number lane (and flow between the scenario form's per-place grids), a click on the selected cell opens its editor, and Tab is no longer trapped inside the grid.
2 changes: 1 addition & 1 deletion libs/@hashintel/petrinaut/docs/scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ You see a row per place. Which places appear depends on the **Show all places**
What you enter per place depends on whether it has a type:

- **Uncoloured places**: a single-line TypeScript expression that evaluates to the token count. The result is rounded down and clamped to `>= 0`. You can reference `parameters.<variable_name>` and `scenario.<identifier>`, plus the `range` helper described under [Code mode](#code-mode-define-as-code). Empty/missing means zero tokens.
- **Coloured places**: a small spreadsheet, one row per token, one column per element of the place's type. Cell values are literal values matching each column's type β€” numbers for Real/Integer, true/false for Boolean, free text for String, and identifiers for UUID; expressions are not supported in the spreadsheet. UUID columns accept any text: a UUID string is used as-is, and any other text (e.g. `order-1`) is converted deterministically to a UUID, so the same text always produces the same identifier. If you later edit the type itself, existing rows follow along: added elements get a default column, removed elements' columns are dropped, reordered elements keep their values, and changing an element's type converts each stored value (falling back to the new type's default when a value can't be converted).
- **Coloured places**: a small spreadsheet, one row per token, one column per element of the place's type. Cell values are literal values matching each column's type β€” numbers for Real/Integer, true/false for Boolean, free text for String, and identifiers for UUID; expressions are not supported in the spreadsheet. UUID columns accept any text: a UUID string is used as-is, and any other text (e.g. `order-1`) is converted deterministically to a UUID, so the same text always produces the same identifier. If you later edit the type itself, existing rows follow along: added elements get a default column, removed elements' columns are dropped, reordered elements keep their values, and changing an element's type converts each stored value (falling back to the new type's default when a value can't be converted). Each spreadsheet is a single Tab stop with arrow-key movement (arrows also flow from one place's spreadsheet into the next), click-to-select then click-to-edit cells, and a row-number column where Delete removes the row.

### Code mode (Define as code)

Expand Down
2 changes: 2 additions & 0 deletions libs/@hashintel/petrinaut/docs/simulation.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Select a place and open the **State** sub-view in its properties:
- **Untyped places** -- set a token count (integer).
- **Typed places** -- define individual tokens with values for each dimension in a spreadsheet editor. Add a row to create a new token. UUID dimensions show a shortened identifier (hover for the full value); when editing, enter a UUID string or any free text -- non-UUID text is converted deterministically to a UUID.

The spreadsheet works like a data grid: it is a single Tab stop, and the arrow keys move between cells. Click a cell to select it and click again (or press Enter, or just start typing) to edit it; Enter commits and moves to the next cell. The row-number column on the left selects whole rows -- press Delete there to remove the row, or fill in the empty bottom row to add a token.

<img width="581" height="228" alt="initial-states" src="https://github.com/user-attachments/assets/6ecfad1c-f6cf-47e9-94fc-f068d534307c" />

If no initial marking is set, a place starts empty (zero tokens).
Expand Down
273 changes: 273 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/components/spreadsheet.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
/**
* @vitest-environment jsdom
*/
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { FocusRoot, FocusStack } from "../worksheet/focus-stack";
import { Spreadsheet } from "./spreadsheet";

import type { SpreadsheetCellValue, SpreadsheetColumn } from "./spreadsheet";

afterEach(cleanup);

const COLUMNS: SpreadsheetColumn[] = [
{ id: "x", name: "x" },
{ id: "y", name: "y" },
];

/** Controlled wrapper: `onData` observes every change the grid commits. */
const Harness: React.FC<{
columns?: SpreadsheetColumn[];
initial: SpreadsheetCellValue[][];
onData?: (data: SpreadsheetCellValue[][]) => void;
}> = ({ columns = COLUMNS, initial, onData }) => {
const [data, setData] = useState(initial);
return (
<Spreadsheet
columns={columns}
data={data}
onChange={(next) => {
setData(next);
onData?.(next);
}}
/>
);
};

const focusPart = (target: HTMLElement): HTMLElement => {
act(() => {
target.focus();
});
expect(document.activeElement).toBe(target);
return target;
};

const focusCellShowing = (text: string): HTMLElement =>
focusPart(screen.getByText(text));

const arrow = (key: "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight") => {
const active = document.activeElement;
if (!active) {
throw new Error("nothing holds focus");
}
fireEvent.keyDown(active, { key });
};

const expectFocusedText = (text: string) => {
expect(document.activeElement).toBe(screen.getByText(text));
};

const editorInput = (): HTMLInputElement | null =>
document.querySelector("input[type='number'], input[type='text']");

describe("Spreadsheet focus flow", () => {
it("is one tab stop: a roving tabindex marks a single tabbable position", () => {
const { container } = render(
<Harness
initial={[
[10, 20],
[30, 40],
]}
/>,
);

expect(container.querySelectorAll("[tabindex='0']")).toHaveLength(1);

focusCellShowing("40");
expect(container.querySelectorAll("[tabindex='0']")).toHaveLength(1);
expect(screen.getByText("40")).toHaveProperty("tabIndex", 0);
});

it("moves between cells with arrows, and between the gutter and the cells", () => {
render(
<Harness
initial={[
[10, 20],
[30, 40],
]}
/>,
);

focusCellShowing("10");
arrow("ArrowRight");
expectFocusedText("20");
arrow("ArrowDown");
expectFocusedText("40");
arrow("ArrowLeft");
expectFocusedText("30");

// ArrowLeft from column 0 enters the gutter; the lane walks rows.
arrow("ArrowLeft");
const gutters = screen.getAllByRole("rowheader");
expect(document.activeElement).toBe(gutters[1]);
arrow("ArrowUp");
expect(document.activeElement).toBe(gutters[0]);
arrow("ArrowRight");
expectFocusedText("10");
});

it("does not intercept Tab", () => {
render(<Harness initial={[[10, 20]]} />);

const cell = focusCellShowing("10");
const notPrevented = fireEvent.keyDown(cell, { key: "Tab" });
expect(notPrevented).toBe(true);
});

it("opens the editor on Enter, commits on Enter, and advances to the next cell", () => {
const onData = vi.fn();
render(
<Harness
initial={[
[10, 20],
[30, 40],
]}
onData={onData}
/>,
);

focusCellShowing("10");
fireEvent.keyDown(document.activeElement!, { key: "Enter" });
const input = editorInput();
expect(input).not.toBeNull();
expect(input!.value).toBe("10");

fireEvent.change(input!, { target: { value: "77" } });
fireEvent.keyDown(input!, { key: "Enter" });

expect(onData).toHaveBeenLastCalledWith([
[77, 20],
[30, 40],
]);
expectFocusedText("20");
});

it("cancels the editor on Escape and returns focus to the cell", () => {
const onData = vi.fn();
render(<Harness initial={[[10, 20]]} onData={onData} />);

focusCellShowing("10");
fireEvent.keyDown(document.activeElement!, { key: "Enter" });
fireEvent.change(editorInput()!, { target: { value: "99" } });
fireEvent.keyDown(editorInput()!, { key: "Escape" });

expect(onData).not.toHaveBeenCalled();
expectFocusedText("10");
});

it("materializes a phantom-row edit as a new row", () => {
const onData = vi.fn();
render(<Harness initial={[[10, 20]]} onData={onData} />);

// The phantom row's cells render empty; typing opens the editor seeded
// with the pressed key.
const cells = screen.getAllByRole("button");
const phantomFirstCell = cells[cells.length - 2]!;
focusPart(phantomFirstCell);
fireEvent.keyDown(phantomFirstCell, { key: "5" });
fireEvent.keyDown(editorInput()!, { key: "Enter" });

expect(onData).toHaveBeenLastCalledWith([
[10, 20],
[5, 0],
]);
});

it("removes a row on Delete in the gutter and keeps focus in the lane", () => {
const onData = vi.fn();
render(
<Harness
initial={[
[10, 20],
[30, 40],
]}
onData={onData}
/>,
);

const gutter = screen.getAllByRole("rowheader")[0]!;
focusPart(gutter);
fireEvent.keyDown(gutter, { key: "Delete" });

expect(onData).toHaveBeenLastCalledWith([[30, 40]]);
// Index-keyed rows: the same gutter element now heads the next row.
expect(document.activeElement).toBe(gutter);
});

it("ignores Delete on the phantom row's gutter", () => {
const onData = vi.fn();
render(<Harness initial={[[10, 20]]} onData={onData} />);

const phantomGutter = screen.getAllByRole("rowheader")[1]!;
focusPart(phantomGutter);
fireEvent.keyDown(phantomGutter, { key: "Delete" });

expect(onData).not.toHaveBeenCalled();
});

it("toggles boolean cells from the keyboard without opening an editor", () => {
const onData = vi.fn();
render(
<Harness
columns={[{ id: "active", name: "active", type: "boolean" }]}
initial={[[true]]}
onData={onData}
/>,
);

const cell = screen.getAllByRole("checkbox")[0]!;
focusPart(cell);
fireEvent.keyDown(cell, { key: " " });
expect(onData).toHaveBeenLastCalledWith([[false]]);

fireEvent.keyDown(cell, { key: "t" });
expect(onData).toHaveBeenLastCalledWith([[true]]);

fireEvent.keyDown(cell, { key: "x" });
expect(editorInput()).toBeNull();
});

it("selects on the first click and opens the editor on a click on the selected cell", () => {
render(<Harness initial={[[10, 20]]} />);

const cell = screen.getByText("10");
fireEvent.pointerDown(cell);
focusPart(cell);
fireEvent.click(cell, { detail: 1 });
expect(editorInput()).toBeNull();

fireEvent.pointerDown(cell);
fireEvent.click(cell, { detail: 1 });
expect(editorInput()).not.toBeNull();
expect(editorInput()!.value).toBe("10");
});

it("hands an edge move to the enclosing stack, flowing into a sibling grid", () => {
render(
<FocusRoot>
<FocusStack axis="vertical">
<Harness initial={[[10, 20]]} />
<Harness initial={[[50, 60]]} />
</FocusStack>
</FocusRoot>,
);

// From the first grid's phantom row, ArrowDown crosses into the second
// grid; ArrowUp from there returns.
focusCellShowing("10");
arrow("ArrowDown"); // phantom row
arrow("ArrowDown"); // crosses grids
expectFocusedText("50");
arrow("ArrowUp");
arrow("ArrowUp");
expectFocusedText("10");
});
});
Loading
Loading