diff --git a/.changeset/chatty-candles-build.md b/.changeset/chatty-candles-build.md new file mode 100644 index 0000000000..064435dc68 --- /dev/null +++ b/.changeset/chatty-candles-build.md @@ -0,0 +1,5 @@ +--- + +--- + + diff --git a/.changeset/silver-grapes-see.md b/.changeset/silver-grapes-see.md new file mode 100644 index 0000000000..e3803645a2 --- /dev/null +++ b/.changeset/silver-grapes-see.md @@ -0,0 +1,5 @@ +--- +"@fluentui-react-native/components": patch +--- + +Project standalone ListboxItem selection through React Native's supported checked accessibility state and platform toggle action while preserving the button role. Keep externally owned selection and disabled semantics authoritative over caller ARIA aliases without changing Dropdown option selection. diff --git a/packages/agentic/components/src/components/listbox-item/SPEC.md b/packages/agentic/components/src/components/listbox-item/SPEC.md index e5e29b4482..d5a2dc7700 100644 --- a/packages/agentic/components/src/components/listbox-item/SPEC.md +++ b/packages/agentic/components/src/components/listbox-item/SPEC.md @@ -59,7 +59,7 @@ owning list (LBI-006). A standalone list item ignores it. bound option. It registers its value and focus target with the list and exposes the `listitem` role with `selected` and `disabled` state, its one-based position, and the option count, instead of the `button` role and - `pressed` state. It declares the select, add-to-selection, and + `checked` state. It declares the select, add-to-selection, and remove-from-selection actions, lowercase on Windows Fabric and title case elsewhere; the list applies an action before the caller's `onAccessibilityAction` receives the event once. The list owns the option's @@ -95,9 +95,24 @@ These hook options do not add new component props. Scenes require `ThemedRoot`. On Windows and macOS, a list item is an accessible React Native `Pressable` with the `button` role. Its accessibility state combines the caller's state -with `disabled` and `pressed: selected`, and it is focusable unless disabled. +with `disabled` and `checked: selected`, and it is focusable unless disabled. +The checked value represents persistent, externally owned toggle selection, +not the transient pointer-pressed interaction state. The owned role, disabled +state, and checked value take precedence over their caller-provided React Native +and ARIA aliases; unrelated caller accessibility values are preserved. +The standalone root declares the platform toggle accessibility action using +the shared resolver, preserving caller labels and custom actions. Windows +exposes the native Toggle pattern without changing the button's role. On macOS, +Toggle is a custom action, not an AXPress mapping; the pinned renderer and desktop +provider do not expose a supported native `checked` read for the retained +AXButton role. Shared native checks retain button roles and enabled/disabled +state, while Windows checked projection is qualified separately. Selection still +belongs to the caller: accessibility actions reach `onAccessibilityAction` +unchanged and do not synthesize `onPress` or mutate `selected`. A bound option uses the `listitem` role and `selected` state instead, which Windows exposes through UI Automation's SelectionItem pattern (LBI-006). +Its owned role, disabled state, and selected value also take precedence over +their caller-provided aliases. A section header is exposed through a nonfocusable React Native `View` with the `header` role. The internal leading and trailing visuals are inaccessible. @@ -111,9 +126,10 @@ navigation, selection, dismissal, and focus return. - `native-system-focus-visuals` (**accepted**): Apply the [shared native adaptation](../../../references/higher-order-components.md#focus-visual-policy). -- `listbox-item-native-pressed-state` (**accepted**): The selected option is - surfaced to native accessibility through `accessibilityState.pressed` - instead of a web accessibility attribute. A bound option uses `selected` +- `listbox-item-native-toggle-state` (**accepted**): Standalone selection is + surfaced through `accessibilityState.checked`, preserving toggle-button + semantics using the supported React Native state instead of a web + accessibility attribute. A bound option continues to use `selected` (LBI-006). - `listbox-item-default-icon` (**accepted**): FURN creates the normal and selected icon slots by default; the pinned Flex reference requires an @@ -127,11 +143,52 @@ navigation, selection, dismissal, and focus return. ## Conformance +### October 27, 2026 representation review + +LBI-005 retains the requirement to expose both selected and unselected +standalone buttons to native accessibility. The previous +`listbox-item-native-pressed-state` adaptation named an unsupported state: +[React Native 0.81 accessibility](https://reactnative.dev/docs/0.81/accessibility#accessibilitystate) +defines `checked`, not `pressed`, and the pinned +[`Pressable` implementation](https://github.com/facebook/react-native/blob/cddf8a7536de12db889e35e946281cfaf327197a/packages/react-native/Libraries/Components/Pressable/Pressable.js) +reconstructs the native state without `pressed`. It also gives ARIA state +aliases precedence over `accessibilityState`. + +The corrected representation follows the existing [Button state hook](../button/useButton.ts): +`checked` mirrors the owner-supplied selection, including false and disabled +selection, without changing selection on activation. This is a platform +adaptation correction, not removal of the persistent pressed/toggled behavior. +Native Win32 validation additionally showed that checked state alone on a +generic button does not expose a Toggle pattern. Although the pinned public +type lists `togglebutton`, the installed REX host projected that role as a +Group rather than a Button. The shipped +[FURN ToggleButton](../../../../../components/Button/src/ToggleButton/useToggleButton.ts) +instead preserves the button role and declares `Toggle` alongside checked +state. This component follows that native compatibility precedent using +`resolveAccessibilityAction` for platform naming, without adopting its +internally driven state or synthesizing a press event. The immutable +compatibility source is recorded alongside the RN platform source. +Flex source identities, slot anatomy, focus/ref ownership, disabled activation, +and bound Dropdown option semantics are unchanged. The public RN source +identity and per-requirement authority are recorded in `spec/source.json`. +Regression coverage includes rendered public state, contradictory caller aliases, +owner rerenders, shared input-free native role/enabled reads, and separate +Windows native `checked` reads. Unsupported macOS button checked projection is +reported explicitly, not treated as false or inferred from rendered selection. +JavaScript +coverage alone does not establish native projection. + +Win32 action coverage separately uses the public accessibility-click route and +requires exactly one `Toggle` event, no `onPress`, and unchanged owner-selected +checked state. Disabled coverage verifies that the public route rejects +activation with no callbacks or selection change; it does not claim that a +disabled native Toggle was dispatched. + | Requirement | Evidence | | ----------- | ---------------------------------------------------------------------------------------------------- | | LBI-001 | `listbox-item.types.ts`, `useListboxItem.ts`, `listbox-item.types.test.ts` | | LBI-002 | `useListboxItem.ts`, `renderListboxItem.tsx`, `listbox-item.test.tsx` | | LBI-003 | `useListboxItem.ts`, `listbox-item.styles.ts`, `listbox-item.test.tsx` | | LBI-004 | `useListboxItem.ts`, `listbox-item.styles.ts`, `useListboxItemStyles.ts`, `listbox-item.test.tsx` | -| LBI-005 | `useListboxItem.ts`, `renderListboxItem.tsx`, `listbox-item.test.tsx` | +| LBI-005 | `useListboxItem.ts`, `renderListboxItem.tsx`, `listbox-item.test.tsx`, `listbox-item.stories.tsx` | | LBI-006 | `listbox-item.types.ts`, `listboxItemOptionContext.ts`, `useListboxItem.ts`, `listbox-item.test.tsx` | diff --git a/packages/agentic/components/src/components/listbox-item/listbox-item.stories.tsx b/packages/agentic/components/src/components/listbox-item/listbox-item.stories.tsx index 8bc5e9b444..c5cad07c83 100644 --- a/packages/agentic/components/src/components/listbox-item/listbox-item.stories.tsx +++ b/packages/agentic/components/src/components/listbox-item/listbox-item.stories.tsx @@ -2,11 +2,13 @@ import { useState } from 'react'; import type { ReactNode } from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import type { ViewProps } from 'react-native'; +import type { AccessibilityActionEvent, ViewProps } from 'react-native'; import type { Meta, StoryObj } from '@storybook/react-native'; +import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing'; import { directComponent } from '@fluentui-react-native/framework-base'; +import { StoryStatus } from '../../common/StoryStatus.story-helpers'; import { ListboxItem } from './listbox-item'; type StoryGroupProps = { @@ -21,7 +23,40 @@ const StoryGroup = ({ children, label }: StoryGroupProps) => ( ); -const Avatar = directComponent((props) => ); +const Avatar = directComponent((props: ViewProps) => ); + +const NativeToggleActionFixture = () => { + const [actions, setActions] = useState([]); + const [presses, setPresses] = useState(0); + const [disabledActions, setDisabledActions] = useState(0); + const [disabledPresses, setDisabledPresses] = useState(0); + return ( + + { + const actionName = event.nativeEvent.actionName; + setActions((previous) => [...previous, actionName]); + }} + onPress={() => setPresses((previous) => previous + 1)} + testID="listbox-toggle-option" + /> + {`${actions.length}:${actions.join(',') || 'none'}`} + {String(presses)} + setDisabledActions((previous) => previous + 1)} + onPress={() => setDisabledPresses((previous) => previous + 1)} + testID="listbox-toggle-disabled" + /> + {String(disabledActions)} + {String(disabledPresses)} + + ); +}; const meta: Meta = { title: 'Components/ListboxItem', @@ -45,10 +80,122 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = WdioStory>; export const Default: Story = {}; +export const NativeSelection: Story = { + render: () => ( + + + + + + ), + wdio: { + 'LBI-005 standalone unselected exposes an enabled native button': async ({ browser, expect }) => { + const option = await browser.$('~listbox-fix-unselected'); + expect(await option.getTagName()).toBe('button'); + await expect(option).toBeEnabled(); + }, + 'LBI-005 standalone selected exposes an enabled native button': async ({ browser, expect }) => { + const option = await browser.$('~listbox-fix-selected'); + expect(await option.getTagName()).toBe('button'); + await expect(option).toBeEnabled(); + }, + 'LBI-005 standalone disabled selection exposes a disabled native button': async ({ browser, expect }) => { + const option = await browser.$('~listbox-fix-disabled'); + expect(await option.getTagName()).toBe('button'); + await expect(option).toBeDisabled(); + }, + 'LBI-005 Windows native checked projection reflects standalone selection including disabled': async ({ + browser, + expect, + platform, + skip, + }) => { + if (platform === 'macos') { + skip( + 'Native checked projection is unsupported for the retained macOS AXButton role; shared role and enabled-state checks run separately.', + ); + return; + } + expect(await (await browser.$('~listbox-fix-unselected')).getProperty('checked')).toBe(false); + expect(await (await browser.$('~listbox-fix-selected')).getProperty('checked')).toBe(true); + expect(await (await browser.$('~listbox-fix-disabled')).getProperty('checked')).toBe(true); + }, + }, +}; + +export const NativeToggleActions: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + 'Native accessibility activation records the original action and press callbacks separately. The owner deliberately keeps selection fixed; disabled activation must not call either handler.', + }, + }, + }, + wdio: { + 'LBI-005 Win32 native Toggle forwards once without a press or controlled selection change': async ({ + browser, + expect, + platform, + skip, + }) => { + if (platform !== 'win32') { + skip('This case qualifies the Win32 Toggle action transport; other hosts require their own action invocation.'); + return; + } + if (!browser.capabilities['furn:features']?.accessibilityClick) { + skip('Native accessibility click is unavailable.'); + return; + } + expect(browser.capabilities['furn:clickMode']).toBe('accessibility'); + const option = await browser.$('~listbox-toggle-option'); + const actions = await browser.$('~listbox-toggle-actions'); + const presses = await browser.$('~listbox-toggle-presses'); + expect(await option.getProperty('checked')).toBe(true); + await expect(actions).toHaveAttribute('name', '0:none'); + await expect(presses).toHaveAttribute('name', '0'); + await option.click(); + await expect(actions).toHaveAttribute('name', '1:Toggle'); + expect(await option.getProperty('checked')).toBe(true); + await expect(presses).toHaveAttribute('name', '0'); + await expect(actions).toHaveAttribute('name', '1:Toggle'); + }, + 'LBI-003 LBI-005 Win32 disabled native activation has no callbacks or selection change': async ({ + browser, + expect, + platform, + skip, + }) => { + if (platform !== 'win32') { + skip('This case qualifies the Win32 disabled accessibility-click route.'); + return; + } + if (!browser.capabilities['furn:features']?.accessibilityClick) { + skip('Native accessibility click is unavailable.'); + return; + } + expect(browser.capabilities['furn:clickMode']).toBe('accessibility'); + const option = await browser.$('~listbox-toggle-disabled'); + const actions = await browser.$('~listbox-toggle-disabled-actions'); + const presses = await browser.$('~listbox-toggle-disabled-presses'); + await expect(option).toBeDisabled(); + expect(await option.getProperty('checked')).toBe(true); + await expect(actions).toHaveAttribute('name', '0'); + await expect(presses).toHaveAttribute('name', '0'); + // The public protocol command avoids WDIO's DOM-based element-error recovery. + await expect(browser.elementClick(await option.elementId)).rejects.toThrow('cannot be clicked'); + expect(await option.getProperty('checked')).toBe(true); + await expect(actions).toHaveAttribute('name', '0'); + await expect(presses).toHaveAttribute('name', '0'); + }, + }, +}; + export const Overview: Story = { render: () => ( diff --git a/packages/agentic/components/src/components/listbox-item/listbox-item.test.tsx b/packages/agentic/components/src/components/listbox-item/listbox-item.test.tsx index 04803afe1c..94df9b8000 100644 --- a/packages/agentic/components/src/components/listbox-item/listbox-item.test.tsx +++ b/packages/agentic/components/src/components/listbox-item/listbox-item.test.tsx @@ -20,6 +20,59 @@ function renderListboxItem(props: React.ComponentProps): Pro } describe('ListboxItem', () => { + it.each([ + { selected: false, disabled: false }, + { selected: true, disabled: false }, + { selected: false, disabled: true }, + { selected: true, disabled: true }, + ])('LBI-003 LBI-005 projects owner-selected=$selected with disabled=$disabled', async ({ selected, disabled }) => { + const onPress = jest.fn(); + const component = await renderListboxItem({ content: 'Choice', selected, disabled, onPress }); + const root = component.getByRole('button'); + + expect(root.props.accessibilityState).toMatchObject({ checked: selected, disabled }); + expect(root.props.focusable).toBe(!disabled); + await fireEvent.press(root); + expect(onPress).toHaveBeenCalledTimes(disabled ? 0 : 1); + expect(root.props.accessibilityState.checked).toBe(selected); + + await component.rerender(); + expect(component.getByRole('button').props.accessibilityState).toMatchObject({ checked: !selected, disabled }); + expect(onPress).toHaveBeenCalledTimes(disabled ? 0 : 1); + }); + + it('LBI-005 owns checked, disabled and role over caller state and ARIA aliases without losing busy', async () => { + const component = await renderListboxItem({ + content: 'Choice', + selected: false, + accessibilityState: { busy: true, checked: true, disabled: true }, + 'aria-checked': true, + 'aria-disabled': true, + role: 'checkbox', + }); + + expect(component.getByRole('button').props.accessibilityState).toMatchObject({ busy: true, checked: false, disabled: false }); + }); + + it('LBI-005 declares the native toggle action and forwards the original owner event without synthesizing a press', async () => { + const onAccessibilityAction = jest.fn(); + const onPress = jest.fn(); + const component = await renderListboxItem({ + content: 'Choice', + selected: true, + accessibilityActions: [{ name: 'Toggle', label: 'Update selection' }, { name: 'inspect' }], + onAccessibilityAction, + onPress, + }); + const root = component.getByRole('button'); + expect(root.props.accessibilityActions).toEqual([{ name: 'Toggle', label: 'Update selection' }, { name: 'inspect' }]); + const event = { nativeEvent: { actionName: 'Toggle' } }; + await fireEvent(root, 'accessibilityAction', event); + expect(onAccessibilityAction.mock.calls).toEqual([[event]]); + expect(onPress).not.toHaveBeenCalled(); + expect(root.props.accessibilityState.checked).toBe(true); + }); + it('renders selection without changing it on press', async () => { const onPress = jest.fn(); const component = await renderListboxItem({ content: 'Inbox', multiselect: true, onPress, selected: false }); @@ -40,6 +93,7 @@ describe('ListboxItem', () => { const component = await renderListboxItem({ content: 'Choice' }); expect(component.getByRole('button').props.accessibilityState).toMatchObject({ disabled: false }); + expect(component.getByRole('button').props.accessibilityActions).toContainEqual({ name: 'Toggle' }); expect(component.getByText('Choice')).toBeOnTheScreen(); expect(component.getByText('Secondary')).toBeOnTheScreen(); expect(component.getByText('○')).toBeOnTheScreen(); @@ -85,6 +139,8 @@ describe('ListboxItem', () => { expect(component.getByRole('header')).toBeOnTheScreen(); expect(component.queryByText('Group')).toBeNull(); expect(component.queryByRole('button')).toBeNull(); + expect(component.getByRole('header').props.focusable).toBe(false); + expect(component.getByRole('header').props.accessibilityState?.checked).toBeUndefined(); }); it('renders hover, press, and focus feedback for list items', async () => { @@ -151,10 +207,22 @@ describe('ListboxItem bound to a list (LBI-006)', () => { it('exposes a list item with list-owned selection, position, and selection actions', async () => { const { binding, registrations } = createBinding(); - const component = await renderBound(binding, { value: 'b', content: 'Bravo', selected: false, checkmark: false, testID: 'b' }); + const component = await renderBound(binding, { + value: 'b', + content: 'Bravo', + selected: false, + checkmark: false, + testID: 'b', + accessibilityState: { busy: true, selected: false, disabled: true }, + 'aria-selected': false, + 'aria-disabled': true, + role: 'button', + }); const option = component.getByTestId('b'); expect(option.props.accessibilityRole).toBe('listitem'); - expect(option.props.accessibilityState).toMatchObject({ selected: true, disabled: false }); + expect(component.getByRole('listitem')).toBe(option); + expect(option.props.accessibilityState).toMatchObject({ busy: true, selected: true, disabled: false }); + expect(option.props.accessibilityState.checked).toBeUndefined(); expect(option.props.accessibilityState.pressed).toBeUndefined(); expect(option.props.accessibilityLabel).toBe('Bravo'); expect(option.props.accessibilityPosInSet).toBe(2); diff --git a/packages/agentic/components/src/components/listbox-item/spec/accessibility.md b/packages/agentic/components/src/components/listbox-item/spec/accessibility.md index b5ddec2219..104d9a82e8 100644 --- a/packages/agentic/components/src/components/listbox-item/spec/accessibility.md +++ b/packages/agentic/components/src/components/listbox-item/spec/accessibility.md @@ -1,11 +1,28 @@ # ListboxItem accessibility -A `listItem` root exposes the React Native `button` role. It merges caller -accessibility state with disabled and pressed state; `pressed` mirrors the -externally supplied selected value. The root is accessible and focusable by +A standalone `listItem` root exposes the React Native `button` role. It merges caller +accessibility state with owned disabled and checked state; `checked` mirrors the +externally supplied selected value, including false and disabled selection. +This is the supported React Native representation of a persistently toggled +button, not the transient pointer-pressed state. Caller `aria-checked`, +`aria-disabled`, or `role` values cannot override those owned semantics. +Unrelated caller state such as `busy` is preserved. The root is accessible and focusable by default unless disabled. Its visible content provides the accessible name unless the caller supplies React Native naming props. +The standalone root declares `toggle` on Windows Fabric and `Toggle` on +Win32/macOS through the shared action resolver. This preserves the button role +and exposes toggle state through Windows UI Automation, retaining caller labels +and custom actions. On macOS, Toggle is a custom action, not a default AXPress +activation route. With the pinned renderer and desktop provider, the retained +AXButton role has no supported native `checked` read. Native role and +enabled/disabled assertions remain shared; Windows checked projection is +qualified separately. The unsupported macOS observation is reported explicitly, +not defaulted to false or replaced by rendered text. +The caller receives the original accessibility action through +`onAccessibilityAction` and owns any resulting selection change, just as it owns +selection changes after `onPress`. The component never synthesizes a press event. + A `sectionHeader` is a nonfocusable accessible View with the `header` role. It does not expose an interactive option state. Icons, avatar, chevron, checkmark, and the multiselect visual are inaccessible presentation, so the @@ -18,7 +35,9 @@ rules, and any focus restoration policy. A bound option inside a Dropdown list (LBI-006) takes those from its list instead. It has the `listitem` role with `selected` and `disabled` state, its one-based position, and the option count, which Windows exposes as a ListItem -with the SelectionItem pattern. It declares the select, add-to-selection, and +with the SelectionItem pattern. Its owned role, selected and disabled state +take precedence over caller `role`, `aria-selected`, and `aria-disabled`. +It declares the select, add-to-selection, and remove-from-selection actions, lowercase on Windows Fabric and title case elsewhere. The list applies each action, or refuses it for a disabled option, and then forwards the original event to the caller's `onAccessibilityAction` diff --git a/packages/agentic/components/src/components/listbox-item/spec/source.json b/packages/agentic/components/src/components/listbox-item/spec/source.json index 3338904dfd..bb3de709f8 100644 --- a/packages/agentic/components/src/components/listbox-item/spec/source.json +++ b/packages/agentic/components/src/components/listbox-item/spec/source.json @@ -3,7 +3,7 @@ "component": "listbox-item", "lifecycle": "implemented", "conformance": "reviewed", - "reviewedAt": "2026-10-19", + "reviewedAt": "2026-10-27", "sources": [ { "id": "flex-component", @@ -67,6 +67,44 @@ } ], "releaseDifferences": [] + }, + { + "id": "furn-toggle-button", + "kind": "git-files", + "authority": "compatibility-reference", + "repository": "microsoft/fluentui-react-native", + "commit": "e57c4dce9733ef459e544c72a9469a6594dcf5b1", + "files": [ + { + "role": "toggle", + "path": "packages/components/Button/src/ToggleButton/useToggleButton.ts", + "sha256": "2340f2be1df9d9ddd0539a58f452b2cd565bd74fa05bb960eff303f6c51268ac" + }, + { + "role": "button", + "path": "packages/components/Button/src/useButton.ts", + "sha256": "2776a0259f3305e5fa5cab6a9624c936acdf70df091aab8a56a1d4184ad0784c" + } + ] + }, + { + "id": "react-native", + "kind": "git-files", + "authority": "platform-contract", + "repository": "facebook/react-native", + "commit": "cddf8a7536de12db889e35e946281cfaf327197a", + "files": [ + { + "role": "implementation", + "path": "packages/react-native/Libraries/Components/Pressable/Pressable.js", + "sha256": "6b5cc38e2f8b19c4d4bbfb0227e4941eb01f68254d4a4825b783991f9bf6c442" + }, + { + "role": "types", + "path": "packages/react-native/Libraries/Components/View/ViewAccessibility.d.ts", + "sha256": "c5dc49c81f9cb20dff16b7933b50e19ac3565430cf685bbe51bcbcdb760fc03f" + } + ] } ], "divergences": [ @@ -75,7 +113,7 @@ "status": "accepted" }, { - "id": "listbox-item-native-pressed-state", + "id": "listbox-item-native-toggle-state", "status": "accepted" }, { @@ -94,26 +132,32 @@ "requirements": [ { "id": "LBI-001", + "sources": ["flex-component"], "evidence": ["listbox-item.types.ts", "useListboxItem.ts", "listbox-item.types.test.ts"] }, { "id": "LBI-002", + "sources": ["flex-component"], "evidence": ["useListboxItem.ts", "renderListboxItem.tsx", "listbox-item.test.tsx"] }, { "id": "LBI-003", + "sources": ["flex-component"], "evidence": ["useListboxItem.ts", "listbox-item.styles.ts", "listbox-item.test.tsx"] }, { "id": "LBI-004", + "sources": ["flex-component"], "evidence": ["useListboxItem.ts", "listbox-item.styles.ts", "useListboxItemStyles.ts", "listbox-item.test.tsx"] }, { "id": "LBI-005", - "evidence": ["useListboxItem.ts", "renderListboxItem.tsx", "listbox-item.test.tsx"] + "sources": ["flex-component", "furn-toggle-button", "react-native"], + "evidence": ["useListboxItem.ts", "renderListboxItem.tsx", "listbox-item.test.tsx", "listbox-item.stories.tsx"] }, { "id": "LBI-006", + "sources": ["flex-component", "react-native"], "evidence": ["listbox-item.types.ts", "listboxItemOptionContext.ts", "useListboxItem.ts", "listbox-item.test.tsx"] } ] diff --git a/packages/agentic/components/src/components/listbox-item/useListboxItem.ts b/packages/agentic/components/src/components/listbox-item/useListboxItem.ts index 06616d5770..df12145209 100644 --- a/packages/agentic/components/src/components/listbox-item/useListboxItem.ts +++ b/packages/agentic/components/src/components/listbox-item/useListboxItem.ts @@ -122,9 +122,13 @@ export function useListboxItem_unstable(props: ListboxItemProps): ListboxItemSta ? { ...accessibilityState, disabled, - pressed: selected, + checked: selected, } : accessibilityState; + const standaloneActions = + isListItem && !bound + ? resolveAccessibilityAction('toggle', Platform.OS, rest.accessibilityActions).accessibilityActions + : rest.accessibilityActions; const optionProps: Partial | undefined = bound && { accessibilityActions: optionActions.accessibilityActions, @@ -163,7 +167,13 @@ export function useListboxItem_unstable(props: ListboxItemProps): ListboxItemSta const [rootProps, pressableState, focusBinding] = useFocusablePressable({ ...rest, + accessibilityActions: standaloneActions, ...optionProps, + // Pressable gives ARIA aliases precedence over accessibilityState. + 'aria-checked': isListItem && !bound ? selected : rest['aria-checked'], + 'aria-disabled': isListItem ? disabled : rest['aria-disabled'], + 'aria-selected': bound ? selected : rest['aria-selected'], + role: bound ? 'listitem' : isListItem ? 'button' : rest.role, accessibilityRole: bound ? 'listitem' : isListItem ? 'button' : 'header', accessibilityState: rootAccessibilityState, accessible: rest.accessible ?? true, diff --git a/packages/agentic/components/tsconfig.stories.json b/packages/agentic/components/tsconfig.stories.json index 3c3f82336e..1089e0327e 100644 --- a/packages/agentic/components/tsconfig.stories.json +++ b/packages/agentic/components/tsconfig.stories.json @@ -13,6 +13,7 @@ "src/components/checkbox/checkbox.stories.tsx", "src/components/dropdown/dropdown.stories.tsx", "src/components/input/input.stories.tsx", + "src/components/listbox-item/listbox-item.stories.tsx", "src/components/menu-item/menu-item.stories.tsx", "src/components/menu/menu.stories.tsx", "src/components/menu-button/menu-button.stories.tsx",