diff --git a/.changeset/sour-areas-enjoy.md b/.changeset/sour-areas-enjoy.md new file mode 100644 index 0000000000..6301516b06 --- /dev/null +++ b/.changeset/sour-areas-enjoy.md @@ -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. diff --git a/packages/agentic/components/src/components/menu-item/SPEC.md b/packages/agentic/components/src/components/menu-item/SPEC.md index 99b4e1dada..028982df3d 100644 --- a/packages/agentic/components/src/components/menu-item/SPEC.md +++ b/packages/agentic/components/src/components/menu-item/SPEC.md @@ -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 @@ -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` | diff --git a/packages/agentic/components/src/components/menu-item/menu-item.stories.tsx b/packages/agentic/components/src/components/menu-item/menu-item.stories.tsx index 5d389d99fb..9ca5e46619 100644 --- a/packages/agentic/components/src/components/menu-item/menu-item.stories.tsx +++ b/packages/agentic/components/src/components/menu-item/menu-item.stories.tsx @@ -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'; @@ -56,6 +57,102 @@ type Story = WdioStory>; 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 ( + + + + setPresses((value) => value + 1)} + /> + + setPresses((value) => value + 1)} + /> + {String(actions)} + {String(presses)} + {lastAction} + + ); + }; + return ; + }, + 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: () => ( diff --git a/packages/agentic/components/src/components/menu-item/menu-item.test.tsx b/packages/agentic/components/src/components/menu-item/menu-item.test.tsx index 63ccce8d89..ae1e638eb4 100644 --- a/packages/agentic/components/src/components/menu-item/menu-item.test.tsx +++ b/packages/agentic/components/src/components/menu-item/menu-item.test.tsx @@ -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'; @@ -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); + }); +}); diff --git a/packages/agentic/components/src/components/menu-item/spec/accessibility.md b/packages/agentic/components/src/components/menu-item/spec/accessibility.md index 91a2a2fcf6..59d4ea704f 100644 --- a/packages/agentic/components/src/components/menu-item/spec/accessibility.md +++ b/packages/agentic/components/src/components/menu-item/spec/accessibility.md @@ -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 diff --git a/packages/agentic/components/src/components/menu-item/spec/interaction.md b/packages/agentic/components/src/components/menu-item/spec/interaction.md index 1eade4072d..ce0be27c31 100644 --- a/packages/agentic/components/src/components/menu-item/spec/interaction.md +++ b/packages/agentic/components/src/components/menu-item/spec/interaction.md @@ -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 diff --git a/packages/agentic/components/src/components/menu-item/spec/source.json b/packages/agentic/components/src/components/menu-item/spec/source.json index c7222d8c88..767bd16dd8 100644 --- a/packages/agentic/components/src/components/menu-item/spec/source.json +++ b/packages/agentic/components/src/components/menu-item/spec/source.json @@ -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", diff --git a/packages/agentic/components/src/components/menu-item/spec/usage.md b/packages/agentic/components/src/components/menu-item/spec/usage.md index 7ee24b4c8e..4bb20ea07a 100644 --- a/packages/agentic/components/src/components/menu-item/spec/usage.md +++ b/packages/agentic/components/src/components/menu-item/spec/usage.md @@ -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 diff --git a/packages/agentic/components/src/components/menu-item/useMenuItem.ts b/packages/agentic/components/src/components/menu-item/useMenuItem.ts index a7928dc42f..69dddd53fe 100644 --- a/packages/agentic/components/src/components/menu-item/useMenuItem.ts +++ b/packages/agentic/components/src/components/menu-item/useMenuItem.ts @@ -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'; @@ -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) => { @@ -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), @@ -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,