diff --git a/.changeset/sharp-teeth-happen.md b/.changeset/sharp-teeth-happen.md
new file mode 100644
index 0000000000..d1e7907cd9
--- /dev/null
+++ b/.changeset/sharp-teeth-happen.md
@@ -0,0 +1,5 @@
+---
+"@fluentui-react-native/components": patch
+---
+
+Expose Button's plain content as its native name and declare Toggle when selected is supplied, preserving explicit naming, caller actions and externally owned checked state.
diff --git a/packages/agentic/components/src/components/button/SPEC.md b/packages/agentic/components/src/components/button/SPEC.md
index efac5fe7e3..54207440e6 100644
--- a/packages/agentic/components/src/components/button/SPEC.md
+++ b/packages/agentic/components/src/components/button/SPEC.md
@@ -62,11 +62,12 @@ layout and requires an action-oriented `accessibilityLabel`.
with the selected-icon fallback and wrapping content behavior.
- **BTN-003:** Resolve appearance, selection, disabled, pressed, and hovered
visual state from Flex tokens; user root style wins last.
-- **BTN-004:** Expose button semantics, merge caller accessibility state, warn
+- **BTN-004:** Expose button semantics and a native name from plain content
+ unless the caller supplies naming, merge caller accessibility state, warn
for an unnamed icon-only button, and hide decorative icons.
- **BTN-005:** Treat `selected` as externally owned state, expose checked
- accessibility state when that prop is present, and reserve semibold label
- width to prevent toggle reflow.
+ accessibility state and the resolved native Toggle action when that prop is
+ present, and reserve semibold label width to prevent toggle reflow.
- **BTN-006:** Follow the [shared focus visual policy](../../../references/higher-order-components.md#focus-visual-policy)
on the root: default to native rings on Windows/macOS and root-modality-aware
custom rings on Win32, retaining the same dual-ring geometry.
@@ -92,6 +93,14 @@ Windows and macOS use React Native press, hover, and focus events. `Enter` and
`Space` activation are supplied by the native `Pressable` button behavior.
Disabled buttons are not focusable.
+The checked React Native state and resolved Toggle declaration do not imply
+identical native accessibility projection. Windows exposes toggle state through
+UI Automation. With the pinned macOS renderer and desktop provider, the retained
+AXButton role has no supported native `checked` read, and Toggle is a custom
+action rather than an AXPress mapping. Native role and enabled-state checks
+remain shared; checked projection is qualified separately on Windows.
+Caller-owned pointer transitions on macOS require physical input.
+
Button adds no component-owned motion.
## Divergences from Flex
@@ -106,11 +115,11 @@ Button adds no component-owned motion.
## Conformance
-| Requirement | Evidence |
-| ----------- | --------------------------------------------------------------------------- |
-| BTN-001 | `button.types.ts`, `useButton.ts`, `button.test.tsx` |
-| BTN-002 | `renderButton.tsx`, `button.test.tsx`, `button.stories.tsx` |
-| BTN-003 | `button.styles.ts`, `useButtonStyles.ts`, `button.test.tsx` |
-| BTN-004 | `useButton.ts`, `useButtonStyles.ts`, `button.test.tsx` |
-| BTN-005 | `useButton.ts`, `renderButton.tsx`, `button.test.tsx` |
-| BTN-006 | `useButton.ts`, `useButtonStyles.ts`, `renderButton.tsx`, `button.test.tsx` |
+| Requirement | Evidence |
+| ----------- | ----------------------------------------------------------------------------- |
+| BTN-001 | `button.types.ts`, `useButton.ts`, `button.test.tsx` |
+| BTN-002 | `renderButton.tsx`, `button.test.tsx`, `button.stories.tsx` |
+| BTN-003 | `button.styles.ts`, `useButtonStyles.ts`, `button.test.tsx` |
+| BTN-004 | `useButton.ts`, `useButtonStyles.ts`, `button.test.tsx`, `button.stories.tsx` |
+| BTN-005 | `useButton.ts`, `renderButton.tsx`, `button.test.tsx`, `button.stories.tsx` |
+| BTN-006 | `useButton.ts`, `useButtonStyles.ts`, `renderButton.tsx`, `button.test.tsx` |
diff --git a/packages/agentic/components/src/components/button/button.stories.tsx b/packages/agentic/components/src/components/button/button.stories.tsx
index eeae742bf3..a178b57d9a 100644
--- a/packages/agentic/components/src/components/button/button.stories.tsx
+++ b/packages/agentic/components/src/components/button/button.stories.tsx
@@ -1,10 +1,12 @@
/** @jsxImportSource @fluentui-react-native/framework-base */
import { useState } from 'react';
import type { ReactNode } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
+import { Platform, StyleSheet, Text, View } from 'react-native';
+import type { AccessibilityActionEvent } from 'react-native';
import type { Meta, StoryObj } from '@storybook/react-native';
import { ThemedRoot, useRootInputModality } from '@fluentui-react-native/design';
+import { resolveAccessibilityAction } from '@fluentui-react-native/framework-base';
import type { FocusKeyboardEvent, FocusRequest } from '@fluentui-react-native/framework-base';
import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing';
@@ -520,38 +522,63 @@ export const ExternallyDrivenSelection: Story = {
'starts unselected': async ({ browser, expect }) => {
await expect(await browser.$('~agentic-storybook-button-selection-state')).toHaveText('Not selected');
},
- 'updates and resets caller-owned selection': async ({ browser, expect, skip }) => {
+ 'updates and resets caller-owned selection': async ({ browser, expect, platform, skip }) => {
const features = browser.capabilities['furn:features'];
if (!features) throw new Error('Desktop Driver did not provide feature capabilities.');
- if (!features.physicalClick) {
- skip('This test requires physical pointer input.');
+ if (platform === 'macos' && !features.physicalClick) {
+ skip('macOS caller-owned selection requires physical pointer input; the custom Toggle action is not an AXPress route.');
return;
}
+ if (!features.physicalClick && !features.accessibilityClick) {
+ skip('This test requires native accessibility activation or physical pointer input.');
+ return;
+ }
+ const { clickPhysical } = await import('../../common/desktopFocus.wdio.ts');
const favorite = await browser.$('~agentic-storybook-button-favorite');
const reset = await browser.$('~agentic-storybook-button-reset');
const status = await browser.$('~agentic-storybook-button-selection-state');
await expect(favorite).toBeEnabled();
await expect(status).toHaveText('Not selected');
- await favorite.click();
+ if (platform === 'macos') {
+ await clickPhysical(browser, 'agentic-storybook-button-favorite');
+ } else {
+ await favorite.click();
+ }
await expect(status).toHaveText('Selected');
- await reset.click();
+ if (platform === 'macos') {
+ await clickPhysical(browser, 'agentic-storybook-button-reset');
+ } else {
+ await reset.click();
+ }
await expect(status).toHaveText('Not selected');
},
},
render: () => {
const ToggleGroup = () => {
const [selected, setSelected] = useState(false);
+ const toggle = resolveAccessibilityAction('toggle', Platform.OS).name;
return (
);
};
@@ -561,7 +588,7 @@ export const ExternallyDrivenSelection: Story = {
docs: {
description: {
story:
- 'A toggle button never changes its own state. The caller owns selected and updates it from onPress, which is why an external action such as Reset can change it just as easily.',
+ 'A toggle button never changes its own state. The caller handles pointer onPress, Windows native Toggle activation, and the Reset accessibility tap independently. macOS uses physical pointer input because its custom Toggle is not an AXPress route. Pointer/focus policy is tested separately.',
},
},
},
@@ -587,6 +614,240 @@ export const Disabled: Story = {
},
};
+export const NativeNames: Story = {
+ render: () => (
+
+
+
+
+
+
+
+ ),
+ wdio: {
+ 'BTN-004 exposes plain, slot and numeric native names while preserving caller names': async ({ browser, expect }) => {
+ for (const [id, name] of [
+ ['button-name-plain', 'Save'],
+ ['button-name-slot', 'Slot label'],
+ ['button-name-number', '0'],
+ ['button-name-explicit', 'Create document'],
+ ['button-name-aria', 'ARIA action'],
+ ]) {
+ const button = await browser.$(`~${id}`);
+ expect(await button.getTagName()).toBe('button');
+ expect(await button.getProperty('name')).toBe(name);
+ }
+ },
+ },
+};
+
+export const NativeToggleSemantics: Story = {
+ render: () => {
+ const Scene = () => {
+ const [action, setAction] = useState({ count: 0, name: 'none' });
+ const [presses, setPresses] = useState(0);
+ return (
+
+
+ {
+ const name = event.nativeEvent.actionName;
+ setAction((previous) => ({ count: previous.count + 1, name }));
+ }}
+ onPress={() => setPresses((count) => count + 1)}
+ />
+ {
+ const name = event.nativeEvent.actionName;
+ setAction((previous) => ({ count: previous.count + 1, name }));
+ }}
+ onPress={() => setPresses((count) => count + 1)}
+ />
+ {`${action.count}:${action.name}`}
+ {String(presses)}
+
+ );
+ };
+ return ;
+ },
+ wdio: {
+ 'BTN-004 BTN-005 exposes native button roles and enabled or disabled state': async ({ browser, expect }) => {
+ const off = await browser.$('~button-native-toggle-off');
+ const on = await browser.$('~button-native-toggle-on');
+ const disabled = await browser.$('~button-native-toggle-disabled');
+ expect(await off.getTagName()).toBe('button');
+ expect(await on.getTagName()).toBe('button');
+ expect(await disabled.getTagName()).toBe('button');
+ await expect(off).toBeEnabled();
+ await expect(on).toBeEnabled();
+ await expect(disabled).toBeDisabled();
+ },
+ 'BTN-005 Windows native checked projection reflects caller 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;
+ }
+ const off = await browser.$('~button-native-toggle-off');
+ const on = await browser.$('~button-native-toggle-on');
+ const disabled = await browser.$('~button-native-toggle-disabled');
+ expect(await off.getProperty('checked')).toBe(false);
+ expect(await on.getProperty('checked')).toBe(true);
+ expect(await disabled.getProperty('checked')).toBe(true);
+ },
+ 'BTN-005 Win32 Toggle forwards once without a press or a controlled selection change': async ({ browser, expect, platform, skip }) => {
+ if (platform !== 'win32') {
+ skip('This case qualifies Win32 native Toggle transport.');
+ return;
+ }
+ if (!browser.capabilities['furn:features']?.accessibilityClick) {
+ skip('Native accessibility click is unavailable.');
+ return;
+ }
+ expect(browser.capabilities['furn:clickMode']).toBe('accessibility');
+ const button = await browser.$('~button-native-toggle-on');
+ const actions = await browser.$('~button-native-toggle-actions');
+ const presses = await browser.$('~button-native-toggle-presses');
+ await expect(actions).toHaveAttribute('name', '0:none');
+ await expect(presses).toHaveAttribute('name', '0');
+ expect(await button.getProperty('checked')).toBe(true);
+ await button.click();
+ await expect(actions).toHaveAttribute('name', '1:Toggle');
+ await expect(presses).toHaveAttribute('name', '0');
+ expect(await button.getProperty('checked')).toBe(true);
+ },
+ 'BTN-004 Win32 disabled public activation rejects without callbacks or a checked-state change': async ({
+ browser,
+ expect,
+ platform,
+ skip,
+ }) => {
+ if (platform !== 'win32') {
+ skip('This case qualifies the Win32 disabled accessibility-click route, not disabled-provider dispatch.');
+ return;
+ }
+ if (!browser.capabilities['furn:features']?.accessibilityClick) {
+ skip('Native accessibility click is unavailable.');
+ return;
+ }
+ expect(browser.capabilities['furn:clickMode']).toBe('accessibility');
+ const button = await browser.$('~button-native-toggle-disabled');
+ await expect(button).toBeDisabled();
+ expect(await button.getProperty('checked')).toBe(true);
+ await expect(button.click()).rejects.toThrow();
+ await expect(await browser.$('~button-native-toggle-actions')).toHaveAttribute('name', '0:none');
+ await expect(await browser.$('~button-native-toggle-presses')).toHaveAttribute('name', '0');
+ expect(await button.getProperty('checked')).toBe(true);
+ },
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Toggle state remains externally owned. Native Toggle declarations come from Button, not this fixture. Action counters observe the original native event separately from onPress.',
+ },
+ },
+ },
+};
+
+export const NativeToggleSelection: Story = {
+ render: () => {
+ const Scene = () => {
+ const [selected, setSelected] = useState(false);
+ const [actions, setActions] = useState(0);
+ const [presses, setPresses] = useState(0);
+ const toggle = resolveAccessibilityAction('toggle', Platform.OS).name;
+ return (
+
+ {
+ if (event.nativeEvent.actionName === toggle) {
+ setActions((count) => count + 1);
+ setSelected((value) => !value);
+ }
+ }}
+ onPress={() => {
+ setPresses((count) => count + 1);
+ setSelected((value) => !value);
+ }}
+ />
+ {String(selected)}
+ {String(actions)}
+ {String(presses)}
+
+ );
+ };
+ return ;
+ },
+ wdio: {
+ 'BTN-002 BTN-005 Win32 native Toggle updates caller selection without reflow or duplicate presses': async ({
+ browser,
+ expect,
+ platform,
+ skip,
+ }) => {
+ if (platform !== 'win32') {
+ skip('This case qualifies caller handling of Win32 native Toggle events.');
+ return;
+ }
+ if (!browser.capabilities['furn:features']?.accessibilityClick) {
+ skip('Native accessibility click is unavailable.');
+ return;
+ }
+ expect(browser.capabilities['furn:clickMode']).toBe('accessibility');
+ const { expectNativeState } = await import('../../common/desktopFocus.wdio.ts');
+ const button = await browser.$('~button-native-selection');
+ const state = await browser.$('~button-native-selection-state');
+ const actions = await browser.$('~button-native-selection-actions');
+ const presses = await browser.$('~button-native-selection-presses');
+ await expect(state).toHaveAttribute('name', 'false');
+ await expect(actions).toHaveAttribute('name', '0');
+ await expect(presses).toHaveAttribute('name', '0');
+ await expectNativeState(browser, 'button-native-selection', 'checked', false);
+ const before = await browser.getElementRect(await button.elementId);
+ await button.click();
+ await expect(state).toHaveAttribute('name', 'true');
+ await expect(actions).toHaveAttribute('name', '1');
+ await expectNativeState(browser, 'button-native-selection', 'checked', true);
+ const selected = await browser.getElementRect(await button.elementId);
+ expect(selected.width).toBe(before.width);
+ expect(selected.height).toBe(before.height);
+ await button.click();
+ await expect(state).toHaveAttribute('name', 'false');
+ await expect(actions).toHaveAttribute('name', '2');
+ await expect(presses).toHaveAttribute('name', '0');
+ await expectNativeState(browser, 'button-native-selection', 'checked', false);
+ const reset = await browser.getElementRect(await button.elementId);
+ expect(reset.width).toBe(before.width);
+ expect(reset.height).toBe(before.height);
+ },
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'The caller handles the native Toggle action and pointer onPress independently. This input-independent case checks native checked state and same-control bounds across both caller-owned transitions.',
+ },
+ },
+ },
+};
+
export const WithLongText: Story = {
render: () => (
diff --git a/packages/agentic/components/src/components/button/button.test.tsx b/packages/agentic/components/src/components/button/button.test.tsx
index ba5c564178..8691fad177 100644
--- a/packages/agentic/components/src/components/button/button.test.tsx
+++ b/packages/agentic/components/src/components/button/button.test.tsx
@@ -1,6 +1,6 @@
/** @jsxImportSource @fluentui-react-native/framework-base */
import * as React from 'react';
-import { StyleSheet, View } from 'react-native';
+import { Platform, StyleSheet, View } from 'react-native';
import type { Pressable, PressableProps, ViewStyle } from 'react-native';
import { fireEvent } from '@testing-library/react-native';
@@ -78,6 +78,47 @@ describe('Button', () => {
expect(onPress).toHaveBeenCalledTimes(1);
});
+ it.each([
+ ['Save', 'Save'],
+ [0, '0'],
+ [{ children: 'From slot' }, 'From slot'],
+ [{ children: 0 }, '0'],
+ ] as const)('projects plain content %j onto the native name', async (content, name) => {
+ const component = await renderButton({ content });
+
+ expect(getRoot(component).props.accessibilityLabel).toBe(name);
+ });
+
+ it.each([{ accessibilityLabel: 'Custom name' }, { 'aria-label': 'Custom name' }])(
+ 'preserves explicit native or ARIA naming %j',
+ async (naming) => {
+ const component = await renderButton({ content: 'Save', ...naming });
+
+ expect(getRoot(component).props.accessibilityLabel).toBe('Custom name');
+ },
+ );
+
+ it.each([{ accessibilityLabelledBy: 'external-label' }, { 'aria-labelledby': 'external-label' }])(
+ 'does not add a competing name for an explicit label reference %j',
+ async (naming) => {
+ const component = await renderButton({ content: 'Save', ...naming });
+
+ expect(getRoot(component).props.accessibilityLabel).toBeUndefined();
+ },
+ );
+
+ it('preserves an explicitly empty native label', async () => {
+ const component = await renderButton({ content: 'Save', accessibilityLabel: '' });
+
+ expect(getRoot(component).props.accessibilityLabel).toBe('');
+ });
+
+ it('does not infer a native name from rich content', async () => {
+ const component = await renderButton({ content: { children: } });
+
+ expect(getRoot(component).props.accessibilityLabel).toBeUndefined();
+ });
+
it('disables interaction and exposes disabled accessibility state', async () => {
const onPress = jest.fn();
const component = await renderButton({ content: 'Unavailable', disabled: true, onPress });
@@ -185,6 +226,74 @@ describe('Button', () => {
expect(getRoot(component).props.accessibilityState).toEqual({ disabled: false });
});
+ it.each([false, true])('declares a native toggle action for selected=%s and owns the checked alias', async (selected) => {
+ const component = await renderButton({ content: 'Favorite', selected, 'aria-checked': !selected });
+ const root = getRoot(component);
+
+ expect(root.props.accessibilityActions).toEqual([{ name: 'Toggle' }]);
+ expect(root.props.accessibilityState.checked).toBe(selected);
+ });
+
+ it.each([
+ ['win32', 'Toggle'],
+ ['windows', 'toggle'],
+ ['macos', 'Toggle'],
+ ] as const)('resolves the native toggle action on %s', async (platform, action) => {
+ const replacement = jest.replaceProperty(Platform, 'OS', platform as typeof Platform.OS);
+ try {
+ const component = await renderButton({ content: 'Favorite', selected: false });
+
+ expect(getRoot(component).props.accessibilityActions).toEqual([{ name: action }]);
+ } finally {
+ replacement.restore();
+ }
+ });
+
+ it('preserves caller action labels and custom actions while deduplicating toggle declarations', async () => {
+ const component = await renderButton({
+ content: 'Favorite',
+ selected: false,
+ accessibilityActions: [{ name: 'toggle' }, { name: 'Toggle', label: 'Choose favorite' }, { name: 'custom', label: 'Details' }],
+ });
+
+ expect(getRoot(component).props.accessibilityActions).toEqual([
+ { name: 'Toggle', label: 'Choose favorite' },
+ { name: 'custom', label: 'Details' },
+ ]);
+ });
+
+ it('does not add a toggle action to an ordinary button', async () => {
+ const actions = [{ name: 'custom', label: 'Details' }];
+ const component = await renderButton({ content: 'Save', accessibilityActions: actions });
+
+ expect(getRoot(component).props.accessibilityActions).toBe(actions);
+ expect(getRoot(component).props['aria-checked']).toBeUndefined();
+ });
+
+ it.each([false, true])('preserves the caller action handler with disabled=%s', async (disabled) => {
+ const onAccessibilityAction = jest.fn();
+ const component = await renderButton({ content: 'Favorite', selected: true, disabled, onAccessibilityAction });
+ const root = getRoot(component);
+
+ expect(root.props.onAccessibilityAction).toBe(onAccessibilityAction);
+ expect(root.props.accessibilityState).toEqual({ disabled, checked: true });
+ expect(root.props.accessibilityActions).toEqual([{ name: 'Toggle' }]);
+ });
+
+ it('forwards the original native toggle event without synthesizing a press or changing controlled state', async () => {
+ const onAccessibilityAction = jest.fn();
+ const onPress = jest.fn();
+ const component = await renderButton({ content: 'Favorite', selected: false, onAccessibilityAction, onPress });
+ const event = { nativeEvent: { actionName: 'Toggle' } };
+
+ await fireEvent(getRoot(component), 'accessibilityAction', event);
+
+ expect(onAccessibilityAction).toHaveBeenCalledTimes(1);
+ expect(onAccessibilityAction).toHaveBeenCalledWith(event);
+ expect(onPress).not.toHaveBeenCalled();
+ expect(getRoot(component).props.accessibilityState.checked).toBe(false);
+ });
+
it('places the icon after content and applies user styles last', async () => {
const style: ViewStyle = { backgroundColor: 'hotpink' };
const component = await renderButton({
diff --git a/packages/agentic/components/src/components/button/spec/accessibility.md b/packages/agentic/components/src/components/button/spec/accessibility.md
index dac469bd93..7ee5b75f69 100644
--- a/packages/agentic/components/src/components/button/spec/accessibility.md
+++ b/packages/agentic/components/src/components/button/spec/accessibility.md
@@ -12,6 +12,11 @@ The root defaults to `accessible={true}` and is focusable unless disabled.
Callers may provide an accessible name through `accessibilityLabel` or
`accessibilityLabelledBy`.
+Without explicit native or ARIA naming or a labelled-by reference, plain
+string or numeric content (including plain content-slot children) becomes
+the native accessible name. Rich content requires caller-supplied naming.
+Explicit names, including an empty native label, retain their existing precedence.
+
## Icon-only buttons
An icon-only button must provide a concise label that describes its action,
@@ -27,7 +32,24 @@ Both icon slots are decorative children of the named root and set
Disabled buttons communicate unavailable state, do not activate, and are
removed from keyboard focus. Toggle-style buttons remain one button and
-announce their checked state from the externally supplied `selected` value.
+provide checked React Native state from the externally supplied `selected` value.
+
+Supplying `selected`, including `false`, declares the platform's resolved
+Toggle action so Win32 UI Automation can expose checked state. Caller action
+labels and custom declarations are retained. The component-owned checked
+value also takes precedence over a conflicting `aria-checked` alias.
+Native action events are forwarded unchanged through `onAccessibilityAction`;
+they neither mutate `selected` nor synthesize `onPress`. An owner handling a
+native Toggle updates its own state from that event.
+
+Native projection is platform-specific. Windows UI Automation exposes toggle
+state, but the pinned macOS renderer and desktop provider do not expose a
+supported `checked` property for the retained AXButton role. macOS Toggle is a
+custom action, not an implied AXPress/default activation route. Shared native
+tests still verify button roles and enabled/disabled state; Windows checked-state
+and Win32 action-transport tests qualify their respective native contracts.
+Unsupported macOS checked projection is reported explicitly, not treated as false
+or inferred from the rendered selection label.
The focus target follows the [shared focus visual policy](../../../../references/higher-order-components.md#focus-visual-policy).
Windows/macOS default to the native ring, with no custom subtree. Win32 defaults
diff --git a/packages/agentic/components/src/components/button/spec/source.json b/packages/agentic/components/src/components/button/spec/source.json
index 8912cd0710..75bcb05c7f 100644
--- a/packages/agentic/components/src/components/button/spec/source.json
+++ b/packages/agentic/components/src/components/button/spec/source.json
@@ -162,11 +162,11 @@
},
{
"id": "BTN-004",
- "evidence": ["useButton.ts", "useButtonStyles.ts", "button.test.tsx"]
+ "evidence": ["useButton.ts", "useButtonStyles.ts", "button.test.tsx", "button.stories.tsx"]
},
{
"id": "BTN-005",
- "evidence": ["useButton.ts", "renderButton.tsx", "button.test.tsx"]
+ "evidence": ["useButton.ts", "renderButton.tsx", "button.test.tsx", "button.stories.tsx"]
},
{
"id": "BTN-006",
diff --git a/packages/agentic/components/src/components/button/spec/usage.md b/packages/agentic/components/src/components/button/spec/usage.md
index 183d554eac..6b0d2a69fd 100644
--- a/packages/agentic/components/src/components/button/spec/usage.md
+++ b/packages/agentic/components/src/components/button/spec/usage.md
@@ -40,6 +40,31 @@ Do not use `disabled` to represent an unselected value. Avoid introducing new
uses of this Button-specific selection API when a dedicated ToggleButton
contract is required for alignment with current Flex guidance.
+Native Toggle actions arrive through `onAccessibilityAction`, independently
+of pointer and keyboard `onPress`. A caller supporting that transport also
+handles the resolved Toggle name and updates its own selected value:
+
+```tsx
+const toggleName = resolveAccessibilityAction('toggle', Platform.OS).name;
+
+ setFavorite((value) => !value)}
+ onAccessibilityAction={(event) => {
+ if (event.nativeEvent.actionName === toggleName) {
+ setFavorite((value) => !value);
+ }
+ }}
+/>;
+```
+
+Import `Platform` from `react-native` and `resolveAccessibilityAction` from
+`@fluentui-react-native/framework-base`. Button declares the native action;
+the caller need not supply `accessibilityActions` or synthesize a press.
+On macOS this is a custom action declaration, not a guarantee that default
+AX activation invokes Toggle.
+
Content wraps when a consumer constrains the root. Supply a content-slot
override only when a product deliberately needs a different truncation or
text presentation policy.
diff --git a/packages/agentic/components/src/components/button/useButton.ts b/packages/agentic/components/src/components/button/useButton.ts
index f67b845db4..eb478d4ffc 100644
--- a/packages/agentic/components/src/components/button/useButton.ts
+++ b/packages/agentic/components/src/components/button/useButton.ts
@@ -1,7 +1,13 @@
import type { ButtonProps, ButtonState } from './button.types';
-import { useAccessibilityLabelWarning, useFocusablePressable, useSlot, useOptionalSlot } from '@fluentui-react-native/framework-base';
+import {
+ resolveAccessibilityAction,
+ useAccessibilityLabelWarning,
+ useFocusablePressable,
+ useSlot,
+ useOptionalSlot,
+} from '@fluentui-react-native/framework-base';
import { useThemeState } from '@fluentui-react-native/design';
-import { Pressable } from 'react-native';
+import { Platform, Pressable } from 'react-native';
import type { PressableProps } from 'react-native';
import { useFocusVisuals } from '../../common/useFocusVisuals';
import { Icon } from '../../primitives/icon/icon';
@@ -35,6 +41,13 @@ export function useButton_unstable(props: ButtonProps): ButtonState {
const hasIcon = iconProp !== undefined && iconProp !== null;
const hasSelectedIcon = selectedIconProp !== undefined && selectedIconProp !== null;
const iconOnly = !hasContent && (hasIcon || hasSelectedIcon);
+ const contentChildren = contentProp && typeof contentProp === 'object' && 'children' in contentProp ? contentProp.children : contentProp;
+ const contentLabel = typeof contentChildren === 'string' || typeof contentChildren === 'number' ? String(contentChildren) : undefined;
+ const hasExplicitName =
+ rest.accessibilityLabel !== undefined ||
+ rest['aria-label'] !== undefined ||
+ rest.accessibilityLabelledBy !== undefined ||
+ rest['aria-labelledby'] !== undefined;
// A button renders selection but never changes it: a press is an action, so the caller owns the selected value.
const isToggleButton = selected !== undefined;
@@ -50,6 +63,11 @@ export function useButton_unstable(props: ButtonProps): ButtonState {
const themeState = useThemeState();
const nativeProps: PressableProps = {
...rest,
+ ...(!hasExplicitName && { accessibilityLabel: contentLabel }),
+ accessibilityActions: isToggleButton
+ ? resolveAccessibilityAction('toggle', Platform.OS, rest.accessibilityActions).accessibilityActions
+ : rest.accessibilityActions,
+ 'aria-checked': isToggleButton ? selected : rest['aria-checked'],
role: 'button',
accessibilityState: {
...accessibilityState,