Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eighty-worlds-shake.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ type Story = WdioStory<StoryObj<SceneProps>>;
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;
Expand Down Expand Up @@ -175,6 +182,40 @@ export const Variants: Story = {
},
};

export const AccessibleNames: Story = {
render: () => (
<View style={styles.story}>
<MenuButton content={{ children: 'Create' }} testID="menu-button-slot-name">
<MenuItem content="Message" />
</MenuButton>
<MenuButton content={0} testID="menu-button-numeric-name">
<MenuItem content="Message" />
</MenuButton>
<MenuButton content="New" accessibilityLabel="Create document" testID="menu-button-explicit-name">
<MenuItem content="Message" />
</MenuButton>
<MenuButton content="New" aria-label="More actions" testID="menu-button-alias-name">
<MenuItem content="Message" />
</MenuButton>
</View>
),
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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Scene content={content} />);
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<MenuButtonProps>; label: string | undefined }[])(
'preserves explicit native naming instead of using the visible content: $props',
async ({ props, label }) => {
await render(<Scene {...props} />);
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(<Scene />);
expect(screen.getByRole('button').props.accessibilityLabel).toBe('New');
await rerender(<Scene content="Insert" />);
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(<Scene onOpenChange={onOpenChange} />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -109,6 +117,7 @@ export function useMenuButton_unstable(props: MenuButtonProps): MenuButtonState
withTriggerExpandActions<ButtonProps>(
{
...buttonProps,
...(!hasExplicitName && { accessibilityLabel: contentLabel }),
accessibilityHint: accessibilityHint ?? defaultHint,
accessibilityState: { ...accessibilityState, expanded: controller.presented },
keyDownEvents: [...(keyDownEvents ?? []), ...triggerKeys],
Expand Down
Loading