Skip to content

[Bug]: headless NavCategoryItem drops the aria-hidden default on its expandIcon slot, so a custom chevron leaks into the button's accessible name #36685

Description

Component

Nav

Package version

@fluentui/react-headless-components-preview 0.2.5 (repo master, checked at a49a072); its styled
twin is @fluentui/react-nav 9.4.5.

React version

19.2.0

Environment

node v22.12.0, TypeScript as pinned by the repo root
@fluentui/react-headless-components-preview 0.2.5
@fluentui/react-nav 9.4.5

Current Behavior

packages/react-components/react-nav/library/src/components/NavCategoryItem/useNavCategoryItem.tsx marks
the expand chevron decorative in both of its hooks — the styled hook (lines 50–56) and the
styling-agnostic base hook useNavCategoryItemBase_unstable (lines 113–118):

expandIcon: slot.always(expandIcon, {
  defaultProps: {
    'aria-hidden': true,
  },
  elementType: 'span',
}),

The headless hook,
packages/react-components/react-headless-components-preview/library/src/components/Nav/NavCategoryItem/useNavCategoryItem.ts:55-57,
re-implements the slot without that default:

expandIcon: slot.optional(expandIcon, {
  elementType: 'span',
}),

Nothing downstream restores it — renderNavCategoryItem.tsx, NavCategoryItem.tsx and the built
lib/components/Nav/NavCategoryItem/useNavCategoryItem.js:40-42 are all bare — so the headless
expandIcon <span> renders with no aria-hidden, and whatever a consumer places in it becomes part of
the button's accessible name.

Measured in jsdom with dom-accessibility-api (the name computation Testing Library and jest-dom use),
identical expandIcon input on both packages, button content Category 1:

expandIcon children headless <span aria-hidden> headless accessible name @fluentui/react-nav accessible name
<ChevronDownFilled /> (the spelling in the package's Nav stories) absent Category 1 Category 1
<ChevronDownFilled title="expand" /> absent Category 1 expand Category 1
'▾' absent Category 1 ▾ Category 1
<img alt="expand" src="…" /> absent Category 1 expand Category 1

The first row only holds because @fluentui/react-icons stamps aria-hidden="true" on any icon rendered
without a title/aria-label (core/useBaseIconState), and every expandIcon in the package's own Nav
stories is an untitled <ChevronDownFilled /> — which is why the gap has not been visible. Any other
glyph (a titled icon, a text arrow, an <img>, an icon from another library) is announced as part of the
category's name.

At the hook level: useNavCategoryItem({ expandIcon: {} }).expandIcon['aria-hidden'] is undefined,
while useNavCategoryItemBase_unstable({}).expandIcon['aria-hidden'] is true.

Expected Behavior

The expand chevron is decorative — the button already carries aria-expanded for the state the chevron
depicts — so the headless slot should default to aria-hidden: true exactly as the base hook does, with
the consumer still able to override it (slot.optional merges defaultProps under the consumer's props:
packages/react-components/react-utilities/src/compose/slot.ts:39-41).

This is the one place the headless Nav diverges from its base hook by omission. useNav, useNavItem
and useNavSubItem compose their *Base_unstable hooks and inherit their ARIA defaults;
useNavCategoryItem re-implements instead — legitimately, since it wants slot.optional rather than
slot.always (the headless package ships no default glyph) and omits value from the toggle payload —
and aria-current and aria-expanded were carried across while the aria-hidden default was not.
(useNavCategoryItemBase_unstable landed in #35812 two days before the headless Nav in #36213.)

Reproduction

Regression test for
packages/react-components/react-headless-components-preview/library/src/components/Nav/Nav.test.tsx
(the file's existing imports cover it):

it('hides the expand icon from assistive technology by default', () => {
  const result = render(
    <Nav>
      <NavCategory value="cat1">
        <NavCategoryItem expandIcon={{ children: <img alt="expand" src="chevron.png" /> }}>Category 1</NavCategoryItem>
      </NavCategory>
    </Nav>,
  );

  expect(result.getByRole('button', { name: 'Category 1' })).toBeInTheDocument();
});

Failing output against current master:

TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "Category 1"

Here are the accessible roles:
  button:
  Name "Category 1 expand":
  <button aria-current="false" aria-expanded="false" />

Steps to reproduce

  1. Add the test above to Nav.test.tsx.
  2. Run npx nx test react-headless-components-preview.
  3. Observe the button's accessible name is Category 1 expand.

Proposed fix

Restore the base hook's default on the headless slot:

expandIcon: slot.optional(expandIcon, {
  defaultProps: { 'aria-hidden': true },
  elementType: 'span',
}),

One line. No rendered change for the untitled-Fluent-icon case (the SVG was already hidden); a consumer
who wants the glyph exposed passes expandIcon={{ 'aria-hidden': false, … }}, the same override
@fluentui/react-nav offers. etc/nav.api.md is unaffected (the slot type does not change). Happy to
open the PR with the regression test above.

Discovery context

Found while building a styling layer over @fluentui/react-headless-components-preview that is verified
against its @fluentui/react-components twin. That layer currently re-adds the attribute itself on the
way into its styles hook; an audit flagged the re-add as compensating for a headless omission rather
than a styling concern, which is what prompted comparing the two hooks.

Suggested severity

Low - Has or doesn't need a workaround (expandIcon={{ 'aria-hidden': true, children }} at each call
site). Worth noting that because the headless API ships no default glyph, every consumer supplies the
chevron, so every consumer is exposed to the default rather than only those who customise it.

Are you willing to submit a PR to fix?

yes

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions