Skip to content

FE-1545: Adopt the worksheet focus layer in the left sidebar lists and search - #9420

Open
kube wants to merge 1 commit into
claude/fe-1544-spreadsheet-focus-layerfrom
claude/fe-1545-sidebar-focus-layer
Open

FE-1545: Adopt the worksheet focus layer in the left sidebar lists and search#9420
kube wants to merge 1 commit into
claude/fe-1544-spreadsheet-focus-layerfrom
claude/fe-1545-sidebar-focus-layer

Conversation

@kube

@kube kube commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

🌟 What is the purpose of this PR?

Stacked on #9418. Adopt the worksheet keyboard-flow layer from #9411 across the left sidebar. The sidebar carried three hand-rolled focus models: the filterable lists tracked a virtual focus index in React state over unfocusable rows with hover-only row menus, the nets list made every row and its invisible delete button a tab stop with no arrow keys, and search hopped focus between input and results through DOM queries while remounting the results on every keystroke.

🔗 Related links

🔍 What does this change?

  • The filterable lists (nodes, types, differential equations, parameters, entities tree) move to real DOM focus through useFocusStops: each list is one Tab stop, arrows move and select as they move, Shift+Arrow extends the range, and group headers collapse/expand with ArrowLeft/ArrowRight. The virtual focusedIndex state, the row-ref bookkeeping, its scroll effect, and a dead per-row key handler are deleted.
  • Row menus and group add buttons become keyboard-reachable: a new RowActionSlot registers the row's trailing button as its second column, so ArrowRight reaches it and ArrowLeft returns. The slots reveal on focus as well as hover.
  • The nets list folds onto the same model: one Tab stop, arrow keys, Enter/Space activates a net, Delete removes a subnet, and the delete button is reachable with ArrowRight — it was previously invisible-but-tabbable on every row.
  • Search joins the input and its results in one vertical FocusStack at the panel level: ArrowDown in the input hands focus to the list and ArrowUp from the first result hands it back, replacing the closest("[data-panel]")/querySelector focus hops. The key={query} remount of the results is gone, so the list no longer resets on every keystroke.
  • useFocusStops.tabIndexFor gains a fallback: when the remembered position's stop disappears (a row deleted or filtered away), the table's tab stop falls back to the first stop instead of vanishing from the tab order. Covered by a new layer test.
  • The orphaned clampIndex helper is deleted.
  • Intentional behaviour changes: focus highlight is now real :focus styling; Escape clears the selection via the list container (as before) and the editor's global Escape; the nets list's row highlight follows focus; list collapse state and click semantics (plain/Shift/Cmd click) are unchanged.

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

This PR:

  • modifies an npm-publishable library and I have added a changeset file(s)

📜 Does this require a change to the docs?

The changes in this PR:

  • require changes to docs which are made as part of this PR

🕸️ Does this require a change to the Turbo Graph?

The changes in this PR:

  • do not affect the execution graph

⚠️ Known issues

  • Single-letter editor shortcuts (v/h/n/t) still fire while a list row holds focus, as they did before this PR. Settling that globally is a follow-up to the focus-layer adoption.
  • The section headers between the lists are not part of the arrow flow yet; that is the shared-chrome adoption (headers, tabs, toolbars) proposed separately.

🐾 Next steps

  • Shared chrome (SubViewHeader, tab bars, toolbars) is the next adoption target after this and FE-1544 (internal).

🛡 What tests cover this?

  • New filterable-list-sub-view.test.tsx: roving tabindex, select-as-you-move with Shift ranges, ArrowRight to the row menu and back, group collapse/expand with hidden children skipped.
  • New layer test in focus-flow.test.tsx for the tabIndexFor fallback after the remembered stop unmounts.
  • Existing focus-flow.test.tsx covers the movement contract.

❓ How to test this?

  1. yarn dev (or the website demo), open the left sidebar.
  2. Tab into a list (one stop), walk rows with arrows and watch the canvas selection follow; Shift+ArrowDown to range-select; ArrowRight to a row's menu and Enter to open it.
  3. In the entities tree, ArrowLeft/ArrowRight on the group headers, ArrowRight to a group's + button.
  4. In the Nets list (subnets enabled): arrows between nets, Enter to switch, Delete on a subnet row, ArrowRight to its delete button, double-click to rename.
  5. Cmd+F, type a query, ArrowDown into the results, walk them (selection follows), ArrowUp from the first result back into the input — and note the highlight no longer resets while typing.

The sidebar's visuals are unchanged apart from focus highlighting; existing docs screenshots stay valid.

🤖 Generated with Claude Code

@kube kube self-assigned this Aug 28, 2026
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hash Ready Ready Preview Aug 30, 2026 12:27am
petrinaut Ready Ready Preview Aug 30, 2026 12:27am
petrinaut-docs Ready Ready Preview Aug 30, 2026 12:27am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hashdotdesign-tokens Ignored Ignored Preview Aug 30, 2026 12:27am

Request Review

@github-actions github-actions Bot added area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team labels Aug 28, 2026
@kube
kube marked this pull request as ready for review August 28, 2026 20:47
@cursor

cursor Bot commented Aug 28, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Broad keyboard-focus and selection behavior changes across sidebar lists and search, including Delete-to-remove subnets; mitigated by tests and no changes to persistence or simulation logic.

Overview
The left sidebar replaces three hand-rolled focus models with the shared worksheet focus layer (useFocusStops, FocusRoot / FocusStack, useFocusMember).

Filterable lists (nodes, types, equations, parameters, entities tree) now use one Tab stop per list with roving tabindex on real row elements. Arrow keys move focus and update selection as you move; Shift+Arrow extends ranges. Group headers collapse/expand with ArrowLeft/ArrowRight; collapsed children stay out of the flow. The virtual focusedIndex state, clampIndex, and container-level keyboard handling are removed.

Row menus and group “+” actions are reachable via a new RowActionSlot (second focus column): ArrowRight to the action, ArrowLeft back; slots show on focus-within as well as hover.

The nets list follows the same pattern: arrows between Root and subnets, Enter/Space to activate, Delete/Backspace to remove a subnet, and ArrowRight to the delete button (no longer a hidden tab stop on every row).

Search wraps the input and results in a vertical FocusStack so ArrowDown/ArrowUp move between the field and results without DOM queries; removing key={query} stops remounting results on every keystroke.

useFocusStops.tabIndexFor falls back to the first stop when the remembered row is removed or filtered away (new test in focus-flow.test.tsx). filterable-list-sub-view.test.tsx covers roving tabindex, selection, menus, and groups.

User docs in drawing-a-net.md describe the new sidebar and search keyboard behavior.

Reviewed by Cursor Bugbot for commit f4a887d. Bugbot is set up for automated code reviews on this repo. Configure here.

<RowActionSlot
registerButton={registerTarget(actionTarget)}
onArrowKeyDown={onStopsKeyDown(actionTarget)}
onButtonFocus={() => onFocusTarget(actionTarget)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action focus drops list tab stop

Medium Severity

tabIndexFor is only applied to the row (column 0), but ArrowRight into a row menu or delete button records column 1 as the remembered tab stop. After that, every row gets tabIndex={-1}, and the action control is a native button that is display: none once the list loses :focus-within. Tabbing away and back therefore skips the list entirely until a click restores row focus.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 75fa218. Configure here.

row: { item: T; isGroup: boolean },
) => {
event.stopPropagation();
setFocusedIndex(index);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Background click steals list keyboard focus

Medium Severity

The listbox container is still click-focusable via tabIndex={-1}, but arrow handling now lives only on the rows. Clicking empty space to clear the selection focuses the container, after which ArrowUp/ArrowDown do nothing until a row is focused again.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 75fa218. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f4a887d. Configure here.

}}
>
{children}
</span>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action slots break list tab order

Medium Severity

RowActionSlot registers the trailing button as column 1 for arrow movement, but that button never receives tabIndexFor and stays a native tab stop whenever :focus-within reveals it. After ArrowRight, useFocusStops remembers column 1, so every row is tabIndex={-1} while the action is display: none once focus leaves — the list then drops out of the tab order. Tab from a focused row also lands on the menu or delete button, so the list is no longer one Tab stop.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f4a887d. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

1 participant