Conversation
b64fa2c to
a822ea4
Compare
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
a822ea4 to
150a4e4
Compare
| @@ -123,6 +130,14 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> { | |||
| this.history.push({ items: params.items }); | |||
There was a problem hiding this comment.
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().
| 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); |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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.
| 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(); |
There was a problem hiding this comment.
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.
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>
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 oldaddItemappended the element directly and was equally unfiltered.SearchInput.reapplyQuery()re-runs whatever is already in the field, andPopoverDesktopcalls it after either mutation.PopoverMobileignoredchildren.isFlippableMobile renders nested levels into the same panel rather than into a popover of their own, so the
Flippernavigating 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.PopoverDesktophas honoured this since before #40, by passingflippable: item.isChildrenFlippableto 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 throughPopoverStatesHistory, 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-inputfixture.Both were checked against the parent branch's source to confirm they fail without the fix — the
isFlippableone 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