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

The left sidebar lists and search follow the worksheet keyboard flow: each list is one Tab stop with real roving focus, row menus and group add buttons are reachable with ArrowRight, the nets list gains arrow keys and Delete-to-remove, and ArrowDown/ArrowUp move between the search input and its results.
4 changes: 4 additions & 0 deletions libs/@hashintel/petrinaut/docs/drawing-a-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,14 @@ The left sidebar has four tabs for creating and managing entities:

Toggle the sidebar with the button in the top-left corner.

Each list is a single Tab stop. Arrow keys move through the rows and select as they move, Shift+Arrow extends the selection, and Enter or Space selects the focused row. ArrowRight on a row reaches its **β‹―** menu (or a group's **+** button), and ArrowLeft returns to the row. Group headers collapse with ArrowLeft and expand with ArrowRight.

## Search

Press **Cmd+F** / **Ctrl+F** to open a search bar. Type to filter entities by name. Press **Escape** to close.

ArrowDown moves from the search input into the results; arrows then walk the results and select as they move, and ArrowUp from the first result returns to the input.

## Undo / Redo

Use the **Cmd+Z** / **Ctrl+Z** shortcut to undo the last action. Use the **Cmd+Shift+Z** / **Ctrl+Shift+Z** shortcut to redo the last action.
Expand Down
14 changes: 0 additions & 14 deletions libs/@hashintel/petrinaut/src/ui/lib/clamp-index.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
LEFT_SIDEBAR_SUBVIEWS,
LEFT_SIDEBAR_TREE_SUBVIEWS,
} from "../../../../constants/ui-subviews";
import { FocusRoot, FocusStack } from "../../../../worksheet/focus-stack";
import { searchSubView } from "./subviews/search-panel";

const glassPanelBaseStyle = css({
Expand Down Expand Up @@ -170,10 +171,16 @@ export const LeftSideBar: React.FC = () => {
direction: "backward",
})}
>
<VerticalSubViewsContainer
name="left-sidebar-search"
subViews={searchSubViews}
/>
{/* One vertical focus flow joins the search input in the header
with the result list below it. */}
<FocusRoot>
<FocusStack axis="vertical">
<VerticalSubViewsContainer
name="left-sidebar-search"
subViews={searchSubViews}
/>
</FocusStack>
</FocusRoot>
</div>
</div>
</GlassPanel>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* @vitest-environment jsdom
*/
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";

import { EditorContext } from "../../../../../../react/state/editor-context";
import { createFilterableListSubView } from "./filterable-list-sub-view";

import type { EditorContextValue } from "../../../../../../react/state/editor-context";
import type { SelectionItem, SelectionMap } from "@hashintel/petrinaut-core";

afterEach(cleanup);

interface TestItem {
id: string;
name: string;
children?: TestItem[];
emptyGroupMessage?: string;
renderGroupAction?: React.ComponentType;
}

/** A live selection model standing in for the editor's. */
const makeSelectionStub = () => {
let selection: SelectionMap = new Map();
const value = {
isSelected: (id: string) => selection.has(id),
selectItem: (item: SelectionItem) => {
selection = new Map([[item.id, item]]);
},
toggleItem: (item: SelectionItem) => {
const next = new Map(selection);
if (next.has(item.id)) {
next.delete(item.id);
} else {
next.set(item.id, item);
}
selection = next;
},
clearSelection: () => {
selection = new Map();
},
setSelection: (
next: SelectionMap | ((prev: SelectionMap) => SelectionMap),
) => {
selection = typeof next === "function" ? next(selection) : next;
},
setSearchOpen: () => {},
} as unknown as EditorContextValue;
return { value, selectedIds: () => [...selection.keys()].sort() };
};

const renderList = (items: TestItem[]) => {
const stub = makeSelectionStub();
const subView = createFilterableListSubView<TestItem>({
id: "test-list",
title: "Test",
useItems: () => items,
getSelectionItem: (item) => ({ type: "place", id: item.id }),
renderItem: (item) => item.name,
renderRowMenu: () => (
<button type="button" aria-label="Row menu">
…
</button>
),
emptyMessage: "Nothing here",
});
const Component = subView.component;
const view = render(
<EditorContext value={stub.value}>
<Component />
</EditorContext>,
);
return { ...stub, view };
};

const focusRow = (name: string): HTMLElement => {
const target = screen.getByText(name).closest("[role='option']");
if (!(target instanceof HTMLElement)) {
throw new Error(`no option row for ${name}`);
}
act(() => {
target.focus();
});
expect(document.activeElement).toBe(target);
return target;
};

const key = (pressed: string, init?: { shiftKey?: boolean }) => {
fireEvent.keyDown(document.activeElement!, { key: pressed, ...init });
};

const FLAT_ITEMS: TestItem[] = [
{ id: "a", name: "Alpha" },
{ id: "b", name: "Beta" },
{ id: "c", name: "Gamma" },
];

describe("filterable list keyboard flow", () => {
it("is one tab stop with a roving tabindex over real rows", () => {
const { view } = renderList(FLAT_ITEMS);

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

it("selects as arrows move, and extends a range with Shift", () => {
const { selectedIds } = renderList(FLAT_ITEMS);

focusRow("Alpha");
key("Enter");
expect(selectedIds()).toEqual(["a"]);

key("ArrowDown");
expect(document.activeElement?.textContent).toContain("Beta");
expect(selectedIds()).toEqual(["b"]);

key("ArrowDown", { shiftKey: true });
expect(document.activeElement?.textContent).toContain("Gamma");
expect(selectedIds()).toEqual(["b", "c"]);
});

it("reaches the row menu with ArrowRight and returns with ArrowLeft", () => {
renderList(FLAT_ITEMS);

const row = focusRow("Alpha");
key("ArrowRight");
expect(document.activeElement).toBe(
row.querySelector("[aria-label='Row menu']"),
);
key("ArrowLeft");
expect(document.activeElement).toBe(row);
});

it("collapses a group with ArrowLeft and skips its hidden children", () => {
renderList([
{
id: "group",
name: "Group",
children: [{ id: "child", name: "Child" }],
},
{ id: "after", name: "After" },
]);

focusRow("Group");
key("ArrowLeft");
key("ArrowDown");
expect(document.activeElement?.textContent).toContain("After");

key("ArrowUp");
key("ArrowRight");
key("ArrowDown");
expect(document.activeElement?.textContent).toContain("Child");
});
});
Loading
Loading