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
- Add the test above to
Nav.test.tsx.
- Run
npx nx test react-headless-components-preview.
- 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
Component
Nav
Package version
@fluentui/react-headless-components-preview0.2.5 (repomaster, checked at a49a072); its styledtwin is
@fluentui/react-nav9.4.5.React version
19.2.0
Environment
Current Behavior
packages/react-components/react-nav/library/src/components/NavCategoryItem/useNavCategoryItem.tsxmarksthe 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):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:
Nothing downstream restores it —
renderNavCategoryItem.tsx,NavCategoryItem.tsxand the builtlib/components/Nav/NavCategoryItem/useNavCategoryItem.js:40-42are all bare — so the headlessexpandIcon<span>renders with noaria-hidden, and whatever a consumer places in it becomes part ofthe button's accessible name.
Measured in jsdom with
dom-accessibility-api(the name computation Testing Library and jest-dom use),identical
expandIconinput on both packages, button contentCategory 1:expandIconchildren<span aria-hidden>@fluentui/react-navaccessible name<ChevronDownFilled />(the spelling in the package's Nav stories)Category 1Category 1<ChevronDownFilled title="expand" />Category 1 expandCategory 1'▾'Category 1 ▾Category 1<img alt="expand" src="…" />Category 1 expandCategory 1The first row only holds because
@fluentui/react-iconsstampsaria-hidden="true"on any icon renderedwithout a
title/aria-label(core/useBaseIconState), and everyexpandIconin the package's own Navstories is an untitled
<ChevronDownFilled />— which is why the gap has not been visible. Any otherglyph (a titled icon, a text arrow, an
<img>, an icon from another library) is announced as part of thecategory's name.
At the hook level:
useNavCategoryItem({ expandIcon: {} }).expandIcon['aria-hidden']isundefined,while
useNavCategoryItemBase_unstable({}).expandIcon['aria-hidden']istrue.Expected Behavior
The expand chevron is decorative — the button already carries
aria-expandedfor the state the chevrondepicts — so the headless slot should default to
aria-hidden: trueexactly as the base hook does, withthe consumer still able to override it (
slot.optionalmergesdefaultPropsunder 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,useNavItemand
useNavSubItemcompose their*Base_unstablehooks and inherit their ARIA defaults;useNavCategoryItemre-implements instead — legitimately, since it wantsslot.optionalrather thanslot.always(the headless package ships no default glyph) and omitsvaluefrom the toggle payload —and
aria-currentandaria-expandedwere carried across while thearia-hiddendefault was not.(
useNavCategoryItemBase_unstablelanded 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):
Failing output against current
master:Steps to reproduce
Nav.test.tsx.npx nx test react-headless-components-preview.Category 1 expand.Proposed fix
Restore the base hook's default on the headless slot:
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-navoffers.etc/nav.api.mdis unaffected (the slot type does not change). Happy toopen the PR with the regression test above.
Discovery context
Found while building a styling layer over
@fluentui/react-headless-components-previewthat is verifiedagainst its
@fluentui/react-componentstwin. That layer currently re-adds the attribute itself on theway 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 callsite). 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