Skip to content

fix(navigation): correct aria-expanded state for screen readers on keyboard focus and hover#6214

Draft
Copilot wants to merge 7 commits intomainfrom
copilot/fix-screen-reader-navigation
Draft

fix(navigation): correct aria-expanded state for screen readers on keyboard focus and hover#6214
Copilot wants to merge 7 commits intomainfrom
copilot/fix-screen-reader-navigation

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 2, 2026

On desktop, the sub-navigation opens via CSS :focus-visible and :hover when the expand button receives keyboard focus or pointer device hover, but aria-expanded remained false — causing screen readers (e.g. NVDA) to announce "collapsed" for an already-visible sub-menu.

Changes

  • navigation-item.lite.tsx

    • Added handleFocusIn: sets isSubNavigationExpanded = true when focus enters or the pointer enters the <li> from outside (guards via event.relatedTarget + _ref.contains())
    • Added handleFocusOut: resets isSubNavigationExpanded = false when focus leaves or the pointer leaves the <li> entirely
    • Registers all four as focusin/focusout/mouseenter/mouseleave listeners on the root <li> ref in onUpdate, following the same cross-framework addEventListener pattern used in DBPopover
  • model.ts — Added handleFocusIn and handleFocusOut to DBNavigationItemDefaultState

  • navigation.spec.tsx — Added testFocus() with four Playwright component tests (desktop viewport) covering aria-expanded state for both top-level and first sub-level navigation items on focus and blur

  • navigation-item.spec.tsx — Added a hover test covering aria-expanded=true on pointer enter and aria-expanded=false on pointer leave

Using bubbling focusin/focusout (rather than focus/blur) means focus movement within the nav item (button → sub-menu items) does not incorrectly toggle the state. The relatedTarget null-check handles focus/pointer arriving from or departing to the browser chrome.

Tests

testFocus() in navigation.spec.tsx (desktop viewport):

Test Assertion
Top-level button on focus aria-expanded="true"
Top-level button on blur aria-expanded="false"
First sub-level button on focus aria-expanded="true"
First sub-level button on blur aria-expanded="false"

Hover test in navigation-item.spec.tsx:

Test Assertion
Expand button on pointer enter aria-expanded="true"
Outside element on pointer enter (leaving nav item) 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

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 2, 2026

🦋 Changeset detected

Latest commit: 6f7e7dd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@db-ux/core-components Patch
@db-ux/ngx-core-components Patch
@db-ux/react-core-components Patch
@db-ux/wc-core-components Patch
@db-ux/v-core-components Patch
@db-ux/core-foundations Patch
@db-ux/core-stylelint Patch
@db-ux/core-migration Patch
@db-ux/agent-cli Patch
@db-ux/core-eslint-plugin Patch

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 AI changed the title [WIP] Fix screen reader announces wrong sub-menu expansion state fix(navigation): correct aria-expanded state for screen readers on keyboard focus Mar 2, 2026
@mfranzke
Copy link
Copy Markdown
Collaborator

mfranzke commented Mar 3, 2026

@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>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 3, 2026

@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=...

Added in commit 63ed81d.

Four new tests in testFocus() (run on desktop viewport) cover:

  • Top-level button aria-expanded=true on focus — focuses the expand button on a nav item with sub-nav and asserts aria-expanded="true"
  • Top-level button aria-expanded=false on blur — blurs after focusing and asserts aria-expanded="false"
  • First sub-level button aria-expanded=true on focus — focuses an expand button nested inside the first sub-navigation level and asserts aria-expanded="true"
  • First sub-level button aria-expanded=false on blur — blurs after focusing and asserts aria-expanded="false"

A new compWithSubLevels fixture was added with a two-level hierarchy to support both top-level and sub-level assertions.

@github-actions github-actions bot added the 📕documentation Improvements or additions to documentation label Mar 3, 2026
@mfranzke mfranzke moved this from 🏗 In progress to 🎁 Ready for review in UX Engineering Team Backlog Mar 3, 2026
@mfranzke mfranzke marked this pull request as ready for review March 3, 2026 10:40
@mfranzke mfranzke enabled auto-merge (squash) March 3, 2026 10:40
@mfranzke mfranzke requested a review from Copilot March 3, 2026 10:41
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.tsx to toggle isSubNavigationExpanded on focus/blur and mouse enter/leave.
  • Extended Playwright CT coverage for aria-expanded behavior 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.

Comment on lines +156 to +160
onFocus={(event: FocusEvent) => state.handleFocusIn(event)}
onBlur={(event: FocusEvent) => state.handleFocusOut(event)}
onMouseEnter={(event: MouseEvent) =>
state.handlePointerEnter(event)
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

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

  • (e.g. 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.

  • Copilot uses AI. Check for mistakes.
    Comment on lines +1 to +6
    ---
    "@db-ux/ngx-core-components": patch
    "@db-ux/react-core-components": patch
    "@db-ux/wc-core-components": patch
    "@db-ux/v-core-components": patch
    ---
    Copy link

    Copilot AI Mar 3, 2026

    Choose a reason for hiding this comment

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

    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.

    Copilot uses AI. Check for mistakes.
    "@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
    Copy link

    Copilot AI Mar 3, 2026

    Choose a reason for hiding this comment

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

    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.').

    Suggested change
    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.

    Copilot uses AI. Check for mistakes.
    await expect(expandButton).toHaveAttribute('aria-expanded', 'true');
    await component.locator('[data-testid="outside-target"]').hover();
    await expect(expandButton).toHaveAttribute('aria-expanded', 'false');
    });
    Copy link

    Copilot AI Mar 3, 2026

    Choose a reason for hiding this comment

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

    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.

    Suggested change
    });
    });
    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');
    });

    Copilot uses AI. Check for mistakes.
    await expect(subButton).toHaveAttribute('aria-expanded', 'true');
    await subButton.blur();
    await expect(subButton).toHaveAttribute('aria-expanded', 'false');
    });
    Copy link

    Copilot AI Mar 3, 2026

    Choose a reason for hiding this comment

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

    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.

    Suggested change
    });
    });
    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');
    });

    Copilot uses AI. Check for mistakes.
    @mfranzke mfranzke moved this from 🎁 Ready for review to 🏗 In progress in UX Engineering Team Backlog Mar 3, 2026
    @mfranzke mfranzke marked this pull request as draft March 3, 2026 15:42
    auto-merge was automatically disabled March 3, 2026 15:42

    Pull request was converted to draft

    @mfranzke
    Copy link
    Copy Markdown
    Collaborator

    mfranzke commented Mar 4, 2026

    @copilot we actually need to revert 8113df8 and come back to the previous approach, except that it didn't work correctly, so some condition in it might not be correct.

    …stener for hover+focus
    
    Co-authored-by: mfranzke <787658+mfranzke@users.noreply.github.com>
    Copy link
    Copy Markdown
    Contributor Author

    Copilot AI commented Mar 4, 2026

    @copilot we actually need to revert 8113df8 and come back to the previous approach, except that it didn't work correctly, so some condition in it might not be correct.

    Done in commit cab2a44. Reverted 8113df8 — removed the onFocus/onBlur/onMouseEnter/onMouseLeave props from the <button> and went back to _ref.addEventListener on the root <li>. Also added mouseenter/mouseleave listeners alongside the existing focusin/focusout ones so hover state is also covered.

    Copilot AI changed the title fix(navigation): correct aria-expanded state for screen readers on keyboard focus fix(navigation): correct aria-expanded state for screen readers on keyboard focus and hover Mar 4, 2026
    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Labels

    🏘components 📕documentation Improvements or additions to documentation

    Projects

    Status: 🏗 In progress

    Development

    Successfully merging this pull request may close these issues.

    3 participants