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/sour-areas-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fluentui-react-native/components": patch
---

Expose native MenuItem indicator state and actions while preserving caller-owned selection and original accessibility events.
7 changes: 6 additions & 1 deletion packages/agentic/components/src/components/menu-item/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ The caller owns `selected` and changes it from `onPress`. Checkmark creates a
radio menu role and multiselect creates a checkbox menu role; either adds
checked state. With neither, a selected list item exposes selected state.
Multiselect takes visual precedence over selected fill and semibold text.
Interactive indicator rows declare the native Toggle action; single-choice
rows also declare Select and project selected state. Component-owned values
take precedence over conflicting checked/selected ARIA aliases. Original
custom action events and caller action labels are preserved without
synthesizing presses or changing externally owned selection.

### Requirements

Expand Down Expand Up @@ -133,7 +138,7 @@ Windows and Win32 retain native `disabled` independently of `focusable`.
| ----------- | --------------------------------------------------------------------------------------------------- |
| MNI-001 | `menu-item.types.ts`, `useMenuItem.ts`, `menu-item.types.test.ts` |
| MNI-002 | `useMenuItem.ts`, `renderMenuItem.tsx`, `menu-item.test.tsx` |
| MNI-003 | `useMenuItem.ts`, `menu-item.styles.ts`, `menu-item.test.tsx` |
| MNI-003 | `useMenuItem.ts`, `menu-item.styles.ts`, `menu-item.test.tsx`, `menu-item.stories.tsx` |
| MNI-004 | `useMenuItem.ts`, `renderMenuItem.tsx`, `menu-item.test.tsx` |
| MNI-005 | `useMenuItem.ts`, `useMenuItemStyles.ts`, `menu-item.styles.ts`, `menu-item.test.tsx` |
| MNI-006 | `menu-item.types.ts`, `useMenuItem.ts`, `menu-item.test.tsx`, `menu-item.types.test.ts` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import type { AccessibilityActionEvent } from 'react-native';

import type { Meta, StoryObj } from '@storybook/react-native';
import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing';
Expand Down Expand Up @@ -56,6 +57,102 @@ type Story = WdioStory<StoryObj<typeof MenuItem>>;

export const Default: Story = {};

export const NativeIndicatorSemantics: Story = {
render: () => {
const Scene = () => {
const [actions, setActions] = useState(0);
const [presses, setPresses] = useState(0);
const [lastAction, setLastAction] = useState('none');
const recordAction = (event: AccessibilityActionEvent) => {
setLastAction(event.nativeEvent.actionName);
setActions((value) => value + 1);
};
return (
<View style={styles.story}>
<MenuItem content="Single choice off" hasCheckmark selected={false} testID="menu-item-native-single-off" />
<MenuItem content="Single choice on" hasCheckmark selected testID="menu-item-native-single-on" />
<MenuItem
content="Multiple choice off"
hasMultiselect
selected={false}
testID="menu-item-native-multi-off"
onAccessibilityAction={recordAction}
onPress={() => setPresses((value) => value + 1)}
/>
<MenuItem content="Multiple choice on" hasMultiselect selected testID="menu-item-native-multi-on" />
<MenuItem
content="Disabled choice"
hasMultiselect
selected
disabled
testID="menu-item-native-disabled"
onAccessibilityAction={recordAction}
onPress={() => setPresses((value) => value + 1)}
/>
<StoryStatus testID="menu-item-native-actions">{String(actions)}</StoryStatus>
<StoryStatus testID="menu-item-native-presses">{String(presses)}</StoryStatus>
<StoryStatus testID="menu-item-native-last-action">{lastAction}</StoryStatus>
</View>
);
};
return <Scene />;
},
wdio: {
'MNI-003 Win32 exposes checked native indicator roles, names and availability': async ({ browser, expect, platform, skip }) => {
if (platform !== 'win32') {
skip('This case qualifies Win32 UI Automation indicator-state projection.');
return;
}
for (const [id, name, checked] of [
['menu-item-native-single-off', 'Single choice off', false],
['menu-item-native-single-on', 'Single choice on', true],
['menu-item-native-multi-off', 'Multiple choice off', false],
['menu-item-native-multi-on', 'Multiple choice on', true],
['menu-item-native-disabled', 'Disabled choice', true],
] as const) {
const row = await browser.$(`~${id}`);
expect(await row.getTagName()).toBe('menuitem');
expect(await row.getProperty('name')).toBe(name);
expect(await row.getProperty('checked')).toBe(checked);
}
expect(await (await browser.$('~menu-item-native-single-off')).getProperty('selected')).toBe(false);
expect(await (await browser.$('~menu-item-native-single-on')).getProperty('selected')).toBe(true);
await expect(await browser.$('~menu-item-native-disabled')).toBeDisabled();
},
'MNI-003 Win32 Toggle forwards once without changing selection or pressing and disabled public activation rejects': async ({
browser,
expect,
platform,
skip,
}) => {
if (platform !== 'win32') {
skip('This case qualifies Win32 UI Automation Toggle transport.');
return;
}
const row = await browser.$('~menu-item-native-multi-off');
const disabled = await browser.$('~menu-item-native-disabled');
const actions = await browser.$('~menu-item-native-actions');
const presses = await browser.$('~menu-item-native-presses');
await expect(actions).toHaveAttribute('name', '0');
await row.click();
await expect(actions).toHaveAttribute('name', '1');
await expect(await browser.$('~menu-item-native-last-action')).toHaveAttribute('name', 'Toggle');
await expect(presses).toHaveAttribute('name', '0');
expect(await row.getProperty('checked')).toBe(false);
let rejection: unknown;
try {
await disabled.click();
} catch (error) {
rejection = error;
}
expect(rejection).toBeDefined();
await expect(actions).toHaveAttribute('name', '1');
await expect(presses).toHaveAttribute('name', '0');
expect(await disabled.getProperty('checked')).toBe(true);
},
},
};

export const Overview: Story = {
render: () => (
<View style={styles.story}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @jsxImportSource @fluentui-react-native/framework-base */
import { StyleSheet, View } from 'react-native';
import { Platform, StyleSheet, View } from 'react-native';

import { fireEvent } from '@testing-library/react-native';
import { render } from '../../common/renderWithTheme';
Expand Down Expand Up @@ -238,3 +238,84 @@ describe('MenuItem inside a Menu (MNI-007)', () => {
}
});
});

describe('MenuItem native indicator semantics (MNI-003)', () => {
it.each([
['win32', false, ['Select', 'Toggle']],
['win32', true, ['Toggle']],
['windows', false, ['select', 'toggle']],
['windows', true, ['toggle']],
['macos', false, ['Select', 'Toggle']],
['macos', true, ['Toggle']],
] as const)('declares native indicator actions on %s with multiselect %s', async (platform, hasMultiselect, actions) => {
const replacement = jest.replaceProperty(Platform, 'OS', platform as typeof Platform.OS);
try {
const component = await renderMenuItem({ hasCheckmark: !hasMultiselect, hasMultiselect });
expect(getRoot(component, hasMultiselect ? 'menuitemcheckbox' : 'menuitemradio').props.accessibilityActions).toEqual(
actions.map((name) => ({ name })),
);
} finally {
replacement.restore();
}
});

it('preserves caller labels and deduplicates both owned actions', async () => {
const component = await renderMenuItem({
hasCheckmark: true,
accessibilityActions: [
{ name: 'Toggle', label: 'Toggle choice' },
{ name: 'Toggle' },
{ name: 'Select', label: 'Choose command' },
{ name: 'Select' },
{ name: 'custom' },
],
});
expect(getRoot(component, 'menuitemradio').props.accessibilityActions).toEqual([
{ name: 'Toggle', label: 'Toggle choice' },
{ name: 'Select', label: 'Choose command' },
{ name: 'custom' },
]);
});

it.each([
[false, false],
[false, true],
[true, false],
[true, true],
] as const)('owns checked aliases with multiselect %s and selected %s', async (hasMultiselect, selected) => {
const component = await renderMenuItem({
hasCheckmark: !hasMultiselect,
hasMultiselect,
selected,
accessibilityState: { busy: true, checked: !selected, selected: !selected },
'aria-checked': !selected,
'aria-selected': !selected,
});
expect(getRoot(component, hasMultiselect ? 'menuitemcheckbox' : 'menuitemradio').props.accessibilityState).toEqual({
busy: true,
checked: selected,
selected: hasMultiselect ? !selected : selected,
disabled: false,
});
});

it('does not add owned actions to ordinary rows or section headers', async () => {
const ordinary = await renderMenuItem({ accessibilityActions: [{ name: 'custom' }] });
expect(getRoot(ordinary).props.accessibilityActions).toEqual([{ name: 'custom' }]);
const header = await renderMenuItem({ menuStyle: 'section-header', hasCheckmark: true });
expect(getRoot(header, 'none').props.accessibilityActions).toBeUndefined();
});

it.each([false, true])('preserves original custom events without pressing or selecting when disabled is %s', async (disabled) => {
const onAccessibilityAction = jest.fn();
const onPress = jest.fn();
const component = await renderMenuItem({ hasMultiselect: true, selected: false, disabled, onAccessibilityAction, onPress });
const event = { nativeEvent: { actionName: 'Toggle' } };
const root = getRoot(component, 'menuitemcheckbox');
root.props.onAccessibilityAction(event);
expect(onAccessibilityAction).toHaveBeenCalledTimes(1);
expect(onAccessibilityAction).toHaveBeenCalledWith(event);
expect(onPress).not.toHaveBeenCalled();
expect(root.props.accessibilityState.checked).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ and on macOS through AX, using the resolved role, name, state, and availability.
An owning menu remains responsible for a menu-level name, menu position, and
relationships to any submenu.

Interactive indicator rows declare the platform-resolved Toggle action so
native automation exposes their checked value. Single-choice rows additionally
declare Select and project selected state. Caller action labels and unrelated
actions are preserved and owned declarations deduplicated. Owned checked
aliases, and selected aliases for single-choice rows, cannot override the
caller-supplied component selection. Ordinary rows and section headers gain
no owned indicator actions. The Win32 story qualifies native checked state
and Toggle dispatch; other renderer projection remains unqualified.

`disabledFocusable` is an opt-in for interactive list rows. A disabled row
remains announced as disabled, but can own native focus. It does not gain an
enabled action: press, long press, keyboard activation, and accessibility tap
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ or, unless `disabledFocusable` is set, receive focus. Pressing never changes
`selected`, so an owning menu must
update that value and clear peers when required.

Indicator accessibility actions preserve the same external selection owner.
The component forwards the original custom accessibility event once, including
disabled events, but never synthesizes `onPress` or selects itself. An owner
handling these actions must reject disabled activation and coordinate its
menu just as it does for other caller-owned selection updates.

Hover and press state select the root and text colors after style selection.

Section headers do not activate, focus, or show a ring, including when their
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
},
{
"id": "MNI-003",
"evidence": ["useMenuItem.ts", "menu-item.styles.ts", "menu-item.test.tsx"]
"evidence": ["useMenuItem.ts", "menu-item.styles.ts", "menu-item.test.tsx", "menu-item.stories.tsx"]
},
{
"id": "MNI-004",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ multiselect precedence and only warns in development. `hasChevron` supplies an
indicator and hint; the surrounding menu must open the submenu. Use `under`
secondary content for a description and `right` for compact metadata.

Native indicator actions arrive through `onAccessibilityAction` as the
original platform event. If the owner updates selection from those actions,
guard disabled state and coordinate peer selection and menu lifetime there;
the component does not convert an accessibility action into `onPress`.

Use `disabled disabledFocusable` when a disabled command must remain a keyboard
focus stop. It is still unavailable and cannot activate. Do not set it on a
section header; headers never become focus stops. The default is false for
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as React from 'react';
import { Pressable, View } from 'react-native';
import { Platform, Pressable, View } from 'react-native';
import type { GestureResponderEvent, MouseEvent, NativeSyntheticEvent, TargetedEvent } from 'react-native';

import { useFocusablePressable, useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base';
import { resolveAccessibilityAction, useFocusablePressable, useOptionalSlot, useSlot } from '@fluentui-react-native/framework-base';
import type { FocusablePressableProps, FocusKeyboardEvent } from '@fluentui-react-native/framework-base';
import { useThemeState } from '@fluentui-react-native/design';

Expand Down Expand Up @@ -78,6 +78,14 @@ export function useMenuItem_unstable(props: MenuItemProps): MenuItemState {
}, [inMenu, submenu]);
const showChevron = hasChevron || hasSubmenu;
const rowFocusable = isInteractive && (!disabled || disabledFocusable);
const ownsChecked = isInteractive && isSelectionIndicator;
const ownsSelected = ownsChecked && !hasMultiselect;
const toggleActions = ownsChecked
? resolveAccessibilityAction('toggle', Platform.OS, rest.accessibilityActions).accessibilityActions
: rest.accessibilityActions;
const accessibilityActions = ownsSelected
? resolveAccessibilityAction('select', Platform.OS, toggleActions).accessibilityActions
: toggleActions;
const menuHandlers = inMenu
? {
onBlur: (event: NativeSyntheticEvent<TargetedEvent>) => {
Expand Down Expand Up @@ -110,6 +118,7 @@ export function useMenuItem_unstable(props: MenuItemProps): MenuItemState {

const rowProps: FocusablePressableProps = {
...rest,
accessibilityActions,
accessible: rest.accessible ?? true,
...menuHandlers,
accessibilityHint: accessibilityHint ?? (showChevron ? 'Has submenu' : undefined),
Expand All @@ -119,8 +128,11 @@ export function useMenuItem_unstable(props: MenuItemProps): MenuItemState {
...props.accessibilityState,
disabled: disabled && isInteractive,
...(isSelectionIndicator ? { checked: selected } : isSelectedVisual ? { selected: true } : {}),
...(ownsSelected && { selected }),
...(inMenu && hasSubmenu && { expanded: submenuOpen }),
},
...(ownsChecked && { 'aria-checked': selected }),
...(ownsSelected && { 'aria-selected': selected }),
disabled: !isInteractive || disabled,
focusable: rowFocusable,
onPress: isInteractive ? (menuHandlers?.onPress ?? rest.onPress) : undefined,
Expand Down
Loading