Skip to content

Keep search results and nested-level keys in sync on mobile - #41

Open
gohabereg wants to merge 4 commits into
feature/popover-a11yfrom
feature/popover-search-and-flippable
Open

gohabereg wants to merge 4 commits into
feature/popover-a11yfrom
feature/popover-search-and-flippable

Conversation

@gohabereg

Copy link
Copy Markdown
Member

Stacked on #40 — review that one first, this diff only makes sense on top of it.

Two gaps I reported while reviewing #40 and deliberately left out of it, since that branch is already large enough that the reviewer asked for it to be split.

Search results went stale when the item list changed

addItem() / removeItemByName() updated the searchable set but never re-ran the query, so an item added while a query was typed joined the results whether it matched or not, and the announced count went with it. Note this predates #40 — the old addItem appended the element directly and was equally unfiltered.

SearchInput.reapplyQuery() re-runs whatever is already in the field, and PopoverDesktop calls it after either mutation.

PopoverMobile ignored children.isFlippable

Mobile renders nested levels into the same panel rather than into a popover of their own, so the Flipper navigating the root list carried on claiming the arrows and Enter after drilling in. An item built around a text input — the link-tool URL field pattern — could never receive them: preventDefault() on the arrows moved focus off the field, and Enter clicked whatever the cursor was on instead of submitting.

PopoverDesktop has honoured this since before #40, by passing flippable: item.isChildrenFlippable to the nested popover it creates. Mobile had no keyboard navigation at all until #40, so nothing regressed — the flag simply had nowhere to apply. It now travels with the level through PopoverStatesHistory, and a level that opts out leaves the Flipper deactivated, its items becoming individual stops of the panel's Tab trap instead.

Tests

184 passing across chromium and webkit. Two new, plus a mobile-nested-input fixture.

Both were checked against the parent branch's source to confirm they fail without the fix — the isFlippable one initially passed regardless, because the nested level had a single item and the Flipper's arrow simply wrapped back to it; the fixture now has a second stop so the focus has somewhere to be stolen to.

🤖 Generated with Claude Code

@gohabereg
gohabereg force-pushed the feature/popover-search-and-flippable branch from b64fa2c to a822ea4 Compare September 21, 2026 22:58
Two gaps reported while reviewing the a11y work, left out of that branch to
keep it reviewable.

Search results went stale whenever the item list changed underneath them:
addItem()/removeItemByName() updated the searchable set but never re-ran the
query, so a newcomer joined the results whether it matched or not and the
announced count went with it. SearchInput.reapplyQuery() re-runs whatever is
already typed, and PopoverDesktop calls it after either mutation.

The mobile popover ignored children.isFlippable. It renders nested levels
into the same panel rather than into a popover of their own, so the Flipper
navigating the root list carried on claiming the arrows and Enter after
drilling in - an item built around a text input could never receive them.
The flag now travels with the level through PopoverStatesHistory, and a
level that opted out leaves the Flipper deactivated, its items becoming
individual stops of the panel's Tab trap instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3JEisQDVD82VYAp55Lhcp
@gohabereg
gohabereg force-pushed the feature/popover-search-and-flippable branch from a822ea4 to 150a4e4 Compare September 21, 2026 23:23
@@ -123,6 +130,14 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
this.history.push({ items: params.items });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The root state is pushed without isFlippable, and isLevelFlippable is hardcoded to true at declaration instead of reading params.flippable !== false. For instances created with flippable: false, isLevelFlippable remains true on mount and after hide() calls history.reset().

Suggested change
this.history.push({ items: params.items });
this.isLevelFlippable = params.flippable !== false;
this.history.push({ items: params.items, isFlippable: this.isLevelFlippable });

protected override showNestedItems(item: PopoverItemDefault): void {
/** Show nested items */
this.updateItemsAndHeader(item.children, item.title);
this.updateItemsAndHeader(item.children, item.title, item.isChildrenFlippable);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

updateItemsAndHeader is called before history.push. If onChildrenOpen calls close() synchronously (or any re-entrant event triggers during rendering), close() calls history.pop(), which pops and deletes the root state because the child state was not pushed yet.

Suggested change
this.updateItemsAndHeader(item.children, item.title, item.isChildrenFlippable);
this.history.push({
title: item.title,
items: item.children,
isFlippable: item.isChildrenFlippable,
});
this.updateItemsAndHeader(item.children, item.title, item.isChildrenFlippable);

* Nested levels opt out of it via children.isFlippable, and since they are rendered into the
* same panel rather than into a popover of their own, the flag has to travel with the level
*/
private isLevelFlippable = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Focusing any element inside a non-flippable level (isFlippable: false) triggers syncFlipperCursor, which calls flipper.activate() and accidentally turns keyboard navigation back on. isFlipperCursorSyncEnabled should return false whenever isLevelFlippable is false.

Suggested change
private isLevelFlippable = true;
protected override get isFlipperCursorSyncEnabled(): boolean {
return this.isLevelFlippable && super.isFlipperCursorSyncEnabled;
}


if (this.search !== undefined) {
this.search.updateItems(this.itemsDefault);
this.search.reapplyQuery();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If an item is added or removed while an item in the search results is actively focused, reapplyQuery() runs onSearch, which deactivates and reactivates the flipper without restoring the cursor. This blurs the active item and drops focus out of the menu to document.body.

gohabereg and others added 3 commits September 23, 2026 18:59
Two ways the history could disagree with the panel it describes:

- The root state was pushed without its flippability, and the flag it
  feeds was initialised to true regardless of the 'flippable' param, so
  a popover that opted out of keyboard navigation still claimed to
  navigate. Nothing acted on it, since such a popover has no Flipper at
  all, but every read of the flag had to be guarded for it.
- A nested level was rendered before its state was pushed, so a close
  arriving synchronously from onChildrenOpen popped the root state
  instead - the only one on the stack at that point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closing resets the level history to the root, but left on screen
whatever level the user was on. Reopening then brought that level back
up with the root's own flippability, so a nested level that opted out of
keyboard navigation had the arrows turned back on over its items - the
ones that need those keys for themselves.

The level is rendered again on the way in rather than on the way out, so
nothing changes under the closing animation.

The cursor sync is refused on such a level as well. It could not have
landed anywhere as it is, since the Flipper is left holding the previous
level's items, but that is a coincidence of the rendering rather than a
rule, and the rule is what this states.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding or removing an item reapplies the search query, which restarts
the Flipper. It was restarted without a cursor, on the grounds that
giving it one would steal the focus from the search field - but the
query is also reapplied while an item, not the field, holds the focus.
The highlight then disappeared and the roving tabindex moved to the
first item, while the real focus stayed where the user had left it, so
the next arrow press silently resumed from the top of the list.

The cursor is now restored, but only when the focus was on an item, so
typing in the search field is left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants