Skip to content

Fix popover focus-management gaps and add active-descendant support - #40

Open
gohabereg wants to merge 11 commits into
mainfrom
feature/popover-a11y
Open

Fix popover focus-management gaps and add active-descendant support#40
gohabereg wants to merge 11 commits into
mainfrom
feature/popover-a11y

Conversation

@gohabereg

Copy link
Copy Markdown
Member

Summary

Makes the popover (@editorjs/ui-kit) usable with a screen reader and keyboard-only: proper ARIA semantics, real keyboard focus management with roving tabindex, a modal focus trap on mobile, and live-region announcements for dynamic changes. Also fixes two focus-management gaps found while reviewing the branch, and adds a hook for popovers that can't move real focus.

A11y features added

  • ARIA roles for items (menuitem/menuitemcheckbox/menuitemradio/option/button), derived from toggle and overridable via role/ariaLabel
  • aria-checked/aria-pressed/aria-selected, aria-disabled, aria-haspopup/aria-expanded kept in sync with item state
  • Radio-group items (shared string toggle key) wrapped in role="group"
  • Real DOM focus + roving tabindex during keyboard navigation (desktop menus), so screen readers announce the item as it's reached
  • Keyboard support even without a Flipper (flippable: false): every item is its own Tab stop, Enter/Space activate
  • Mobile popover exposed as role="dialog" + aria-modal, with a real Tab focus trap, Escape to close, and focus returned to the trigger on close
  • Nested submenu keyboard nav matching the WAI-ARIA menu pattern: Escape backs out one level (or closes the popover), ArrowLeft/ArrowRight mirror that
  • Live region (aria-live="polite") announces search result counts (debounced, singular/plural), "nothing found", and confirmation-mode prompts
  • Search input is a labeled type="search" box, only tabbable while open
  • Hints are referenced via aria-describedby and now shown on keyboard focus, not just hover
  • Back button and nested-popover/dialog get accessible names (backButtonLabel, item title, or messages.label)
  • Separators exposed as role="separator", excluded from tab order
  • hidden attribute mirrors visual hiding so assistive tech doesn't see filtered/hidden items
  • Fixed in this PR: typing in the search box no longer strips every item's tabindex without restoring it — Tab from the search field now reaches the filtered results
  • Fixed in this PR: a custom HTML item's real control (button/input), not its non-interactive wrapper, is now what the mobile dialog's focus trap makes tabbable
  • New in this PR: PopoverEvent.ActiveDescendantChanged — inline popovers keep real focus on the text selection, so there was no way for a screen reader to know which toolbar button is highlighted; a consumer can now mirror this onto aria-activedescendant of whichever element holds focus

Breaking UX/UI changes

  • Keyboard navigation moves real DOM focus (roving tabindex) instead of only a visual highlight class, on desktop menus
  • Radio-group items are now wrapped in an extra <div role="group"> (display: contents — no layout impact, but changes DOM structure/:last-of-type scoping for custom CSS)
  • Filtered/hidden items now carry a hidden attribute in addition to the existing CSS class
  • Popover closing now restores focus to whatever opened it (previously mobile-only behavior)
  • Search input is now type="search" with native search decorations suppressed
  • A nested popover's onOpen callback now fires after it's attached and shown, not before (fixes focusing a custom element from that callback, which was a no-op on a detached node)

(@editorjs/ui-kit was already bumped 1.2.0 → 2.0.0 for the above in a prior commit on this branch.)

API changes

  • PopoverItemDefaultBaseParams.ariaLabel?: string — explicit accessible name override
  • PopoverItemDefaultBaseParams.role?: PopoverItemRole — explicit ARIA role override
  • PopoverMessages.back, .result, .results, .label — new i18n message keys
  • PopoverEvent.ActiveDescendantChanged — new event, payload string | null (highlighted item's element id, or null)

Test plan

  • Full Playwright e2e suite (132 tests, chromium + webkit) passes
  • yarn build (type-check + bundle) passes
  • yarn test (vitest, other packages) passes
  • New regression tests confirmed to fail without the fixes, pass with them (search→Tab reachability, mobile html-item tabbability)
  • New test for ActiveDescendantChanged covers highlight tracking and clearing on close

🤖 Generated with Claude Code

gohabereg and others added 11 commits July 26, 2026 01:29
Adds @playwright/test with chromium and webkit projects running against the
existing vite dev server on port 3300, plus dedicated fixture pages for the
desktop menu, inline toolbar and mobile popovers. The preview playground is
left untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Popover items now carry a role derived from their toggle param (menuitem,
menuitemcheckbox, menuitemradio, or button for the inline popover), an
accessible name taken from the title with a fallback to the hint title, and
aria-disabled. Icons and chevrons are marked decorative.

toggleActive() keeps aria-checked/aria-pressed in sync with the active class,
and neighbouring items sharing a toggle key are wrapped into a role="group"
element that generates no box, so layout is unchanged.

The items container is exposed as a menu (toolbar for the inline popover) and
can be named via the new optional messages.label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flipper gets an opt-in 'focusItems' mode that moves DOM focus to the current
item and maintains a roving tabindex over the navigated elements. It is off by
default, so other Flipper users are unaffected.

The desktop popover enables it: items are focusable but not tabbable, the
opened popover makes its first item tabbable as the keyboard entry point, and
a closed popover makes all of them untabbable again so Tab pressed outside
does not land on a hidden item. The focused modifier keeps providing the
visual highlight, and the UA focus ring is suppressed to keep the appearance
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The popover header back button renders an icon only and had no accessible
name, so a screen reader user could not leave a nested popover on mobile. It
now takes its name from the new 'back' message and points the popover at the
header text via aria-labelledby, so entering a submenu announces which one.
Nested desktop popovers are named after the item they were opened from.

The search input gets an aria-label and type="search" (its native decorations
are suppressed to keep the appearance unchanged). Instead of being permanently
untabbable, it becomes tabbable while the popover is opened and untabbable
again once it closes, which is what the tabIndex workaround was really after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a polite live region owned by the popover. Filtering the items now
reports the number of results or that nothing was found, and entering
confirmation mode announces what is being confirmed — previously the item
silently turned into a different control with a different action.

Confirmation mode also re-applies the item's role, name and state, since it
replaces the item content in place.

Items with children advertise aria-haspopup and keep aria-expanded in sync
with the nested popover, whether it was opened by hover or by click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mobile popover is a modal dialog: it gets role="dialog" and aria-modal,
traps Tab inside itself while opened, closes on Escape and returns focus to
whatever it was opened from.

Separators are exposed as separators, the html item wrapper takes role="none"
so its content participates in the menu directly, and filtered out items get
the hidden attribute alongside the class.

Hints hold shortcut descriptions and were hover-only, hence unreachable from
the keyboard. They now have role="tooltip", are referenced from their item via
aria-describedby and are shown on focus as well as on hover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds optional ariaLabel and role params to the default popover item for the
cases the derived semantics do not fit. Both fall back to the derived values,
so existing call sites are unaffected.

Excludes the Playwright specs from the vitest run, they have their own runner.

@editorjs/ui-kit 1.1.5 -> 1.2.0 and @editorjs/dom 1.1.0 -> 1.2.0: additive
API plus the changed popover focus behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two regressions found while comparing the rendered geometry against main.

The radio group wrapper generates no box, but it does scope :last-of-type to
itself, so the item closing a group lost its 1px bottom margin and everything
below it moved up by a pixel. The margin is restored unless the group closes
the list.

More importantly, WebKit drops the text selection once focus moves to a
button, so arrow navigation in the inline popover destroyed the selection the
inline tools operate on — the very problem the button wrapper tag works
around. The inline popover now keeps focus in the text and only moves the
highlight, while menus keep the roving focus model. Covered by an e2e test
asserting the selection survives keyboard navigation.

Focusing the first element of the mobile dialog no longer scrolls the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Manual keyboard/AT-tree testing against the previous commits turned up several
dead ends the automated e2e suite didn't cover:

- Nested desktop submenus had no way out via keyboard: Escape now backs out
  of the deepest open submenu (or closes the popover when there is none), and
  ArrowLeft/ArrowRight mirror that plus open a submenu, per the WAI-ARIA menu
  pattern. A single listener on the root popover resolves the deepest open
  level itself, so it stays correct regardless of nesting depth.
- `flippable: false` menus were a total keyboard dead end: only the first item
  ever got a tabindex, Enter/Space did nothing, and the items container itself
  (a scrollable region) was sequentially focusable, blocking forward Tab past
  it. Every item is now its own Tab stop when there's no roving-tabindex
  Flipper to move focus for it, Enter/Space clicks the focused item directly,
  and the items container opts out of scroll-driven focusability.
- Desktop popovers now restore focus to whatever triggered them on close,
  matching what the mobile dialog already did, but only when focus is still
  inside the popover (a click on another trigger already moved focus there
  first and shouldn't be clobbered).
- The mobile dialog's separator was reachable by Tab; separators are excluded
  from its focus trap now.
- A nested popover's `onOpen` callback now fires after the nested popover is
  attached and shown rather than before, since a consumer focusing one of its
  elements from that callback was a no-op on a detached node.
- Search-result announcements are debounced so a burst of keystrokes collapses
  into one announcement, and a single matching item is announced as "1
  result" instead of misusing the plural template.

None of this changes the public TypeScript surface, but real focus movement,
the radio-group DOM wrapper, and the `hidden` attribute on filtered items are
behavior changes a consumer could observe. @editorjs/ui-kit 1.2.0 -> 2.0.0
to flag that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ent navigation

Search filtering left every item's roving tabindex stripped with nothing to
restore it, so Tab from the search field landed nowhere in the results. The
mobile dialog's focus trap toggled tabindex on an html item's non-interactive
role="none" wrapper instead of its real controls, leaving those permanently
unreachable by Tab. Both were silent gaps in the a11y work from prior commits,
caught while reviewing the branch and confirmed via new regression tests.

Also adds PopoverEvent.ActiveDescendantChanged, since inline popovers keep
real DOM focus on the text selection and had no way to tell a screen reader
which toolbar button is currently highlighted - a consumer can now mirror it
onto aria-activedescendant of whichever element holds focus.

Removes the planning brief now that the implementation is done.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

1 participant