From a6697a05105492ee87c62101521d273c569099e1 Mon Sep 17 00:00:00 2001 From: Srividya Ramaswamy Date: Tue, 27 Oct 2026 12:17:39 -0700 Subject: [PATCH] fix(agentic): expose MenuButton label on native trigger Project plain string, numeric and content-slot labels onto the actual native trigger, without overriding explicit accessibility names or references. Preserve menu-label derivation and cover caller renaming, native semantics and naming precedence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 --- .changeset/eighty-worlds-shake.md | 5 +++ .../src/components/menu-button/SPEC.md | 5 ++- .../menu-button/menu-button.stories.tsx | 41 +++++++++++++++++++ .../menu-button/menu-button.test.tsx | 34 +++++++++++++++ .../menu-button/spec/accessibility.md | 7 ++++ .../components/menu-button/useMenuButton.ts | 11 ++++- 6 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 .changeset/eighty-worlds-shake.md diff --git a/.changeset/eighty-worlds-shake.md b/.changeset/eighty-worlds-shake.md new file mode 100644 index 0000000000..e4e44c62a2 --- /dev/null +++ b/.changeset/eighty-worlds-shake.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": patch +--- + +Project MenuButton's plain content label onto its native trigger name while preserving explicit accessibility naming props. diff --git a/packages/agentic/components/src/components/menu-button/SPEC.md b/packages/agentic/components/src/components/menu-button/SPEC.md index 4b2c412dab..57e95252e0 100644 --- a/packages/agentic/components/src/components/menu-button/SPEC.md +++ b/packages/agentic/components/src/components/menu-button/SPEC.md @@ -81,8 +81,9 @@ The chevron is private and cannot be replaced or hidden. input (MNU-006) return focus to the trigger. Tab closes the whole menu and moves focus to the next focusable view after the trigger through native-core continuation. -- **MNB-006:** Expose the button role, the expanded state from actual - presence, and the `Opens a menu` hint without toggle or selected semantics. +- **MNB-006:** Expose the button role, a name from explicit naming props or + the plain label text, the expanded state from actual presence, and the + `Opens a menu` hint without toggle or selected semantics. - **MNB-007:** Present through native-core's modern Callout and FocusZone on macOS (Paper and Fabric) and Windows Fabric, and through its Win32 Callout and FocusZone adapters on Office Win32. Other hosts throw a descriptive error diff --git a/packages/agentic/components/src/components/menu-button/menu-button.stories.tsx b/packages/agentic/components/src/components/menu-button/menu-button.stories.tsx index 19f66fdb09..f83a332e00 100644 --- a/packages/agentic/components/src/components/menu-button/menu-button.stories.tsx +++ b/packages/agentic/components/src/components/menu-button/menu-button.stories.tsx @@ -103,6 +103,13 @@ type Story = WdioStory>; export const Default: Story = { tags: ['desktop-focus'], wdio: { + 'MNB-006 exposes a named, collapsed native button before input': async ({ browser, expect }) => { + const trigger = await browser.$('~menu-button-trigger'); + expect(await trigger.getProperty('role')).toBe('button'); + expect(await trigger.getProperty('name')).toBe('New'); + expect(await trigger.getProperty('expanded')).toBe(false); + await expect(trigger).toBeEnabled(); + }, 'opens on Down to the first row and on Up to the last row': async (context) => { const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); if (!requireDesktopFocus(context)) return; @@ -175,6 +182,40 @@ export const Variants: Story = { }, }; +export const AccessibleNames: Story = { + render: () => ( + + + + + + + + + + + + + + + ), + wdio: { + 'MNB-006 exposes slot, numeric and explicit native names without input': async ({ browser, expect }) => { + expect(await (await browser.$('~menu-button-slot-name')).getProperty('name')).toBe('Create'); + expect(await (await browser.$('~menu-button-numeric-name')).getProperty('name')).toBe('0'); + expect(await (await browser.$('~menu-button-explicit-name')).getProperty('name')).toBe('Create document'); + expect(await (await browser.$('~menu-button-alias-name')).getProperty('name')).toBe('More actions'); + }, + }, + parameters: { + docs: { + description: { + story: 'Plain labels name the native trigger directly. Explicit accessibility names override the visible label.', + }, + }, + }, +}; + const styles = StyleSheet.create({ story: { alignItems: 'flex-start', diff --git a/packages/agentic/components/src/components/menu-button/menu-button.test.tsx b/packages/agentic/components/src/components/menu-button/menu-button.test.tsx index bb7d8e1bff..082774c432 100644 --- a/packages/agentic/components/src/components/menu-button/menu-button.test.tsx +++ b/packages/agentic/components/src/components/menu-button/menu-button.test.tsx @@ -58,6 +58,40 @@ describe('MenuButton', () => { expect(screen.queryByTestId('menu')).toBeNull(); }); + it.each([ + { content: 'New', label: 'New' }, + { content: 0, label: '0' }, + { content: { children: 'Create' }, label: 'Create' }, + { content: { children: 0 }, label: '0' }, + ])('projects the plain content label $label onto the native trigger and menu', async ({ content, label }) => { + await render(); + expect(screen.getByRole('button').props.accessibilityLabel).toBe(label); + await fireEvent.press(screen.getByTestId('trigger')); + expect(screen.getByTestId('menu').props.accessibilityLabel).toBe(label); + }); + + it.each([ + { props: { accessibilityLabel: 'Create document' }, label: 'Create document' }, + { props: { accessibilityLabel: '' }, label: '' }, + { props: { 'aria-label': 'Create document' }, label: 'Create document' }, + { props: { accessibilityLabelledBy: 'external-label' }, label: undefined }, + { props: { 'aria-labelledby': 'external-label' }, label: undefined }, + { props: { accessibilityLabel: 'Create document', accessibilityLabelledBy: 'external-label' }, label: 'Create document' }, + ] satisfies { props: Partial; label: string | undefined }[])( + 'preserves explicit native naming instead of using the visible content: $props', + async ({ props, label }) => { + await render(); + expect(screen.getByTestId('trigger').props.accessibilityLabel).toBe(label); + }, + ); + + it('updates the native name when the caller changes the plain content', async () => { + const { rerender } = await render(); + expect(screen.getByRole('button').props.accessibilityLabel).toBe('New'); + await rerender(); + expect(screen.getByRole('button').props.accessibilityLabel).toBe('Insert'); + }); + it('opens on Down to the first row and on Up to the last row, below and start-aligned', async () => { const onOpenChange = jest.fn(); await render(); diff --git a/packages/agentic/components/src/components/menu-button/spec/accessibility.md b/packages/agentic/components/src/components/menu-button/spec/accessibility.md index 05096ff184..49c0dabb8e 100644 --- a/packages/agentic/components/src/components/menu-button/spec/accessibility.md +++ b/packages/agentic/components/src/components/menu-button/spec/accessibility.md @@ -9,6 +9,13 @@ development when neither label nor icon is present. It exposes `accessibilityState.expanded` from the menu's actual presence and the default hint `Opens a menu`. It never exposes checked or selected state. +Project a plain string or numeric label onto the trigger's native +`accessibilityLabel`, including plain `children` supplied through content-slot +props. Do not rely on the native host to aggregate the label's descendant text. +Explicit naming props (`accessibilityLabel`, `aria-label`, +`accessibilityLabelledBy`, or `aria-labelledby`) suppress this fallback and +retain their native precedence. Rich content needs an explicit naming prop. + React Native has no typed has-popup or controls property on Windows or macOS; the hint describes the behavior but is not a programmatic relationship (`menu-button-haspopup`). The chevron is decorative and inaccessible. diff --git a/packages/agentic/components/src/components/menu-button/useMenuButton.ts b/packages/agentic/components/src/components/menu-button/useMenuButton.ts index 54da2d7358..532960e524 100644 --- a/packages/agentic/components/src/components/menu-button/useMenuButton.ts +++ b/packages/agentic/components/src/components/menu-button/useMenuButton.ts @@ -72,12 +72,20 @@ export function useMenuButton_unstable(props: MenuButtonProps): MenuButtonState })); const disabled = buttonProps.disabled === true; + const content = buttonProps.content; + const contentChildren = content && typeof content === 'object' && 'children' in content ? content.children : content; + const contentLabel = typeof contentChildren === 'string' || typeof contentChildren === 'number' ? String(contentChildren) : undefined; + const hasExplicitName = + buttonProps.accessibilityLabel !== undefined || + buttonProps['aria-label'] !== undefined || + buttonProps.accessibilityLabelledBy !== undefined || + buttonProps['aria-labelledby'] !== undefined; const controller = useMenuController({ componentName: 'MenuButton', children, defaultOpen, disabledFocusable, - label: typeof buttonProps.content === 'string' ? buttonProps.content : undefined, + label: contentLabel, onOpenChange, open, placement: { side: 'bottom', align: 'start' }, @@ -109,6 +117,7 @@ export function useMenuButton_unstable(props: MenuButtonProps): MenuButtonState withTriggerExpandActions( { ...buttonProps, + ...(!hasExplicitName && { accessibilityLabel: contentLabel }), accessibilityHint: accessibilityHint ?? defaultHint, accessibilityState: { ...accessibilityState, expanded: controller.presented }, keyDownEvents: [...(keyDownEvents ?? []), ...triggerKeys],