fix(navigation): correct aria-expanded state for screen readers on keyboard focus and hover#6214
fix(navigation): correct aria-expanded state for screen readers on keyboard focus and hover#6214
Conversation
🦋 Changeset detectedLatest commit: 6f7e7dd The changes in this PR will be included in the next version bump. This PR includes changesets to release 10 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
…n state Co-authored-by: mfranzke <787658+mfranzke@users.noreply.github.com>
|
@copilot please add tests that should include the hovercstste of both top level as well as first sublevel main navigation items (aria-expanded=true) as well as the redult of their blur (aria-expanded=false) |
…essibility Co-authored-by: mfranzke <787658+mfranzke@users.noreply.github.com>
Added in commit 63ed81d. Four new tests in
A new |
There was a problem hiding this comment.
Pull request overview
Fixes DBNavigationItem so aria-expanded reflects desktop hover/focus-driven submenu visibility, improving screen reader announcements (e.g., NVDA) when sub-navigation is opened via CSS interactions.
Changes:
- Added interaction handlers in
navigation-item.lite.tsxto toggleisSubNavigationExpandedon focus/blur and mouse enter/leave. - Extended Playwright CT coverage for
aria-expandedbehavior on desktop focus (navigation) and hover (navigation-item). - Added a changeset for the framework packages.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/components/src/components/navigation/navigation.spec.tsx | Adds desktop focus tests asserting aria-expanded toggling for top-level and sub-level items. |
| packages/components/src/components/navigation-item/navigation-item.spec.tsx | Adds a hover-based test asserting aria-expanded toggling. |
| packages/components/src/components/navigation-item/navigation-item.lite.tsx | Introduces focus/hover event handlers to keep aria-expanded in sync with desktop interactions. |
| packages/components/src/components/navigation-item/model.ts | Extends state typing with new interaction handler functions. |
| package-lock.json | Lockfile metadata changes (e.g., esbuild spec normalization). |
| .changeset/navigation-item-pointer-hover-fix.md | Adds a changeset documenting the behavior change. |
| onFocus={(event: FocusEvent) => state.handleFocusIn(event)} | ||
| onBlur={(event: FocusEvent) => state.handleFocusOut(event)} | ||
| onMouseEnter={(event: MouseEvent) => | ||
| state.handlePointerEnter(event) | ||
| } |
There was a problem hiding this comment.
The focus/hover handlers are attached only to the expand . When the pointer moves from the button into the submenu and then leaves the submenu (or when keyboard focus moves from the button into submenu links and then leaves the nav item), the button won’t receive mouseleave/blur again, so aria-expanded can stay true while the submenu is no longer visible (CSS closes it). Consider listening on the root
focusin/focusout + mouseenter/mouseleave or mouseover/mouseout with the same relatedTarget/contains guard) so the state tracks entering/leaving the whole navigation item, not just the button.| --- | ||
| "@db-ux/ngx-core-components": patch | ||
| "@db-ux/react-core-components": patch | ||
| "@db-ux/wc-core-components": patch | ||
| "@db-ux/v-core-components": patch | ||
| --- |
There was a problem hiding this comment.
This changeset is missing @db-ux/core-components. Since the change modifies component source under packages/components/src, the changeset should include @db-ux/core-components alongside the framework packages so versioning/changelogs stay consistent.
| "@db-ux/v-core-components": patch | ||
| --- | ||
|
|
||
| fix(navigation-item): assistive technology (like screen reader) announcing expansion behavior so it now reacts to hover and focus on opening a sub navigation |
There was a problem hiding this comment.
The changeset summary sentence is hard to parse and reads ungrammatical. Consider rewriting it to a concise, user-facing changelog entry (e.g. 'Fix navigation item aria-expanded to reflect hover/focus expansion for screen readers.').
| fix(navigation-item): assistive technology (like screen reader) announcing expansion behavior so it now reacts to hover and focus on opening a sub navigation | |
| Fix navigation item so screen readers announce submenu expansion on hover and focus. |
| await expect(expandButton).toHaveAttribute('aria-expanded', 'true'); | ||
| await component.locator('[data-testid="outside-target"]').hover(); | ||
| await expect(expandButton).toHaveAttribute('aria-expanded', 'false'); | ||
| }); |
There was a problem hiding this comment.
The new hover test only covers leaving directly from the expand button to an outside element. It doesn’t cover the common path where the pointer moves into .db-sub-navigation (submenu stays open via CSS) and then leaves the entire nav item; that’s the scenario where aria-expanded can get out of sync if the collapse logic isn’t triggered from the submenu/root element. Add a test that hovers the button → hovers an element inside the submenu → hovers outside, and assert aria-expanded becomes false at the end.
| }); | |
| }); | |
| test('should collapse after hovering submenu and then leaving navigation item', async ({ | |
| mount | |
| }) => { | |
| const component = await mount(compWithSubNavigation); | |
| const expandButton = component.locator( | |
| '.db-navigation-item-expand-button' | |
| ); | |
| // Initial state: collapsed | |
| await expect(expandButton).toHaveAttribute('aria-expanded', 'false'); | |
| // Hover the expand button to open the submenu | |
| await expandButton.hover(); | |
| await expect(expandButton).toHaveAttribute('aria-expanded', 'true'); | |
| // Move pointer into an element inside the submenu | |
| const submenuItem = component.locator('.db-sub-navigation a').first(); | |
| await submenuItem.hover(); | |
| // Then move pointer outside the entire navigation item | |
| await component.locator('[data-testid="outside-target"]').hover(); | |
| // aria-expanded should be collapsed again | |
| await expect(expandButton).toHaveAttribute('aria-expanded', 'false'); | |
| }); |
| await expect(subButton).toHaveAttribute('aria-expanded', 'true'); | ||
| await subButton.blur(); | ||
| await expect(subButton).toHaveAttribute('aria-expanded', 'false'); | ||
| }); |
There was a problem hiding this comment.
The focus tests only assert aria-expanded on focus/blur of the expand buttons. To validate the stated intent (using bubbling focus events so focus moves within the nav item don’t collapse it, but leaving the item does), add a test that focuses the button → tabs/focuses into a submenu link → then moves focus outside the nav item, and assert aria-expanded stays true while focus is inside and becomes false once focus leaves the item.
| }); | |
| }); | |
| test(`top-level item should stay expanded while focus is inside and collapse when focus leaves`, async ({ | |
| mount, | |
| page | |
| }) => { | |
| await page.setViewportSize({ | |
| width: DESKTOP_VIEWPORT.width, | |
| height: DESKTOP_VIEWPORT.height | |
| }); | |
| const component = await mount(compWithSubLevels); | |
| const topLevelItem = component.getByTestId('toplevel'); | |
| const topButton = topLevelItem.getByRole('button'); | |
| // Initial state: collapsed | |
| await expect(topButton).toHaveAttribute('aria-expanded', 'false'); | |
| // Focus the top-level button: should expand | |
| await topButton.focus(); | |
| await expect(topButton).toHaveAttribute('aria-expanded', 'true'); | |
| // Move focus into a submenu link within the same nav item: should stay expanded | |
| const submenuLink = topLevelItem.locator('a').first(); | |
| await submenuLink.focus(); | |
| await expect(topButton).toHaveAttribute('aria-expanded', 'true'); | |
| // Move focus outside the nav item: should collapse | |
| const outsideNavItem = component.getByText('Test2'); | |
| await outsideNavItem.focus(); | |
| await expect(topButton).toHaveAttribute('aria-expanded', 'false'); | |
| }); |
Pull request was converted to draft
…stener for hover+focus Co-authored-by: mfranzke <787658+mfranzke@users.noreply.github.com>
Done in commit cab2a44. Reverted |
On desktop, the sub-navigation opens via CSS
:focus-visibleand:hoverwhen the expand button receives keyboard focus or pointer device hover, butaria-expandedremainedfalse— causing screen readers (e.g. NVDA) to announce "collapsed" for an already-visible sub-menu.Changes
navigation-item.lite.tsxhandleFocusIn: setsisSubNavigationExpanded = truewhen focus enters or the pointer enters the<li>from outside (guards viaevent.relatedTarget+_ref.contains())handleFocusOut: resetsisSubNavigationExpanded = falsewhen focus leaves or the pointer leaves the<li>entirelyfocusin/focusout/mouseenter/mouseleavelisteners on the root<li>ref inonUpdate, following the same cross-frameworkaddEventListenerpattern used inDBPopovermodel.ts— AddedhandleFocusInandhandleFocusOuttoDBNavigationItemDefaultStatenavigation.spec.tsx— AddedtestFocus()with four Playwright component tests (desktop viewport) coveringaria-expandedstate for both top-level and first sub-level navigation items on focus and blurnavigation-item.spec.tsx— Added a hover test coveringaria-expanded=trueon pointer enter andaria-expanded=falseon pointer leaveUsing bubbling
focusin/focusout(rather thanfocus/blur) means focus movement within the nav item (button → sub-menu items) does not incorrectly toggle the state. TherelatedTargetnull-check handles focus/pointer arriving from or departing to the browser chrome.Tests
testFocus()innavigation.spec.tsx(desktop viewport):aria-expanded="true"aria-expanded="false"aria-expanded="true"aria-expanded="false"Hover test in
navigation-item.spec.tsx:aria-expanded="true"aria-expanded="false"💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.
🔭🐙🐈 Test this branch here: https://design-system.deutschebahn.com/core-web/review/copilot/fix-screen-reader-navigation