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/chatty-candles-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---

---


5 changes: 5 additions & 0 deletions .changeset/silver-grapes-see.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 63 additions & 6 deletions packages/agentic/components/src/components/listbox-item/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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` |
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -21,7 +23,40 @@ const StoryGroup = ({ children, label }: StoryGroupProps) => (
</View>
);

const Avatar = directComponent<ViewProps>((props) => <View {...props} accessibilityHint="avatar" />);
const Avatar = directComponent<ViewProps>((props: ViewProps) => <View {...props} accessibilityHint="avatar" />);

const NativeToggleActionFixture = () => {
const [actions, setActions] = useState<string[]>([]);
const [presses, setPresses] = useState(0);
const [disabledActions, setDisabledActions] = useState(0);
const [disabledPresses, setDisabledPresses] = useState(0);
return (
<View style={styles.story}>
<ListboxItem
content="Controlled selected option"
selected
onAccessibilityAction={(event: AccessibilityActionEvent) => {
const actionName = event.nativeEvent.actionName;
setActions((previous) => [...previous, actionName]);
}}
onPress={() => setPresses((previous) => previous + 1)}
testID="listbox-toggle-option"
/>
<StoryStatus testID="listbox-toggle-actions">{`${actions.length}:${actions.join(',') || 'none'}`}</StoryStatus>
<StoryStatus testID="listbox-toggle-presses">{String(presses)}</StoryStatus>
<ListboxItem
content="Disabled controlled selected option"
selected
disabled
onAccessibilityAction={() => setDisabledActions((previous) => previous + 1)}
onPress={() => setDisabledPresses((previous) => previous + 1)}
testID="listbox-toggle-disabled"
/>
<StoryStatus testID="listbox-toggle-disabled-actions">{String(disabledActions)}</StoryStatus>
<StoryStatus testID="listbox-toggle-disabled-presses">{String(disabledPresses)}</StoryStatus>
</View>
);
};

const meta: Meta<typeof ListboxItem> = {
title: 'Components/ListboxItem',
Expand All @@ -45,10 +80,122 @@ const meta: Meta<typeof ListboxItem> = {

export default meta;

type Story = StoryObj<typeof ListboxItem>;
type Story = WdioStory<StoryObj<typeof ListboxItem>>;

export const Default: Story = {};

export const NativeSelection: Story = {
render: () => (
<View style={styles.story}>
<ListboxItem content="Unselected option" selected={false} testID="listbox-fix-unselected" />
<ListboxItem content="Selected option" selected testID="listbox-fix-selected" />
<ListboxItem content="Disabled selected option" selected disabled testID="listbox-fix-disabled" />
</View>
),
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: () => <NativeToggleActionFixture />,
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: () => (
<View style={styles.story}>
Expand Down
Loading
Loading