Skip to content

Commit b13da50

Browse files
srividyaramasSriRamas23Copilot
authored
Agentic Button: expose visible labels and toggle state (#4403)
* fix(agentic): expose native Button Toggle semantics Declare resolved Toggle actions for caller-owned selection, retain native action forwarding, and add checked-state ownership and stable-layout regressions without physical input. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 * fix(button): project content names and demonstrate native toggle ownership Preserve explicit naming and label references. Add runtime and native naming regressions, and allow the caller-owned selection example to use real native action transport without physical input. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 * test(button): handle the reset control's native action in its owner Keep native accessibility actions distinct from onPress while qualifying caller-owned reset without physical input. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 * test(button): use the native accessibility tap for ordinary reset Keep ordinary accessibility taps and declared Toggle actions distinct from pointer/keyboard onPress; exercise each real transport through its caller. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 * test(button): qualify native selection by desktop contract Keep portable button role and enabled-state assertions shared, isolate Windows checked projection, and require physical macOS caller activation without changing product behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 * chore(button): record contiguous qualification trailers Keep the validated source unchanged and preserve append-only history. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1 --------- Co-authored-by: Srividya Ramaswamy <sriramas@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b66f3ef-85ac-4405-bd35-dcc167a077b1
1 parent cdea138 commit b13da50

8 files changed

Lines changed: 474 additions & 25 deletions

File tree

‎.changeset/sharp-teeth-happen.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@fluentui-react-native/components": patch
3+
---
4+
5+
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.

‎packages/agentic/components/src/components/button/SPEC.md‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,12 @@ layout and requires an action-oriented `accessibilityLabel`.
6262
with the selected-icon fallback and wrapping content behavior.
6363
- **BTN-003:** Resolve appearance, selection, disabled, pressed, and hovered
6464
visual state from Flex tokens; user root style wins last.
65-
- **BTN-004:** Expose button semantics, merge caller accessibility state, warn
65+
- **BTN-004:** Expose button semantics and a native name from plain content
66+
unless the caller supplies naming, merge caller accessibility state, warn
6667
for an unnamed icon-only button, and hide decorative icons.
6768
- **BTN-005:** Treat `selected` as externally owned state, expose checked
68-
accessibility state when that prop is present, and reserve semibold label
69-
width to prevent toggle reflow.
69+
accessibility state and the resolved native Toggle action when that prop is
70+
present, and reserve semibold label width to prevent toggle reflow.
7071
- **BTN-006:** Follow the [shared focus visual policy](../../../references/higher-order-components.md#focus-visual-policy)
7172
on the root: default to native rings on Windows/macOS and root-modality-aware
7273
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
9293
`Space` activation are supplied by the native `Pressable` button behavior.
9394
Disabled buttons are not focusable.
9495

96+
The checked React Native state and resolved Toggle declaration do not imply
97+
identical native accessibility projection. Windows exposes toggle state through
98+
UI Automation. With the pinned macOS renderer and desktop provider, the retained
99+
AXButton role has no supported native `checked` read, and Toggle is a custom
100+
action rather than an AXPress mapping. Native role and enabled-state checks
101+
remain shared; checked projection is qualified separately on Windows.
102+
Caller-owned pointer transitions on macOS require physical input.
103+
95104
Button adds no component-owned motion.
96105

97106
## Divergences from Flex
@@ -106,11 +115,11 @@ Button adds no component-owned motion.
106115

107116
## Conformance
108117

109-
| Requirement | Evidence |
110-
| ----------- | --------------------------------------------------------------------------- |
111-
| BTN-001 | `button.types.ts`, `useButton.ts`, `button.test.tsx` |
112-
| BTN-002 | `renderButton.tsx`, `button.test.tsx`, `button.stories.tsx` |
113-
| BTN-003 | `button.styles.ts`, `useButtonStyles.ts`, `button.test.tsx` |
114-
| BTN-004 | `useButton.ts`, `useButtonStyles.ts`, `button.test.tsx` |
115-
| BTN-005 | `useButton.ts`, `renderButton.tsx`, `button.test.tsx` |
116-
| BTN-006 | `useButton.ts`, `useButtonStyles.ts`, `renderButton.tsx`, `button.test.tsx` |
118+
| Requirement | Evidence |
119+
| ----------- | ----------------------------------------------------------------------------- |
120+
| BTN-001 | `button.types.ts`, `useButton.ts`, `button.test.tsx` |
121+
| BTN-002 | `renderButton.tsx`, `button.test.tsx`, `button.stories.tsx` |
122+
| BTN-003 | `button.styles.ts`, `useButtonStyles.ts`, `button.test.tsx` |
123+
| BTN-004 | `useButton.ts`, `useButtonStyles.ts`, `button.test.tsx`, `button.stories.tsx` |
124+
| BTN-005 | `useButton.ts`, `renderButton.tsx`, `button.test.tsx`, `button.stories.tsx` |
125+
| BTN-006 | `useButton.ts`, `useButtonStyles.ts`, `renderButton.tsx`, `button.test.tsx` |

‎packages/agentic/components/src/components/button/button.stories.tsx‎

Lines changed: 269 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
/** @jsxImportSource @fluentui-react-native/framework-base */
22
import { useState } from 'react';
33
import type { ReactNode } from 'react';
4-
import { StyleSheet, Text, View } from 'react-native';
4+
import { Platform, StyleSheet, Text, View } from 'react-native';
5+
import type { AccessibilityActionEvent } from 'react-native';
56

67
import type { Meta, StoryObj } from '@storybook/react-native';
78
import { ThemedRoot, useRootInputModality } from '@fluentui-react-native/design';
9+
import { resolveAccessibilityAction } from '@fluentui-react-native/framework-base';
810
import type { FocusKeyboardEvent, FocusRequest } from '@fluentui-react-native/framework-base';
911
import type { WdioStory } from '@fluentui-react-native/storybook-desktop/testing';
1012

@@ -520,38 +522,63 @@ export const ExternallyDrivenSelection: Story = {
520522
'starts unselected': async ({ browser, expect }) => {
521523
await expect(await browser.$('~agentic-storybook-button-selection-state')).toHaveText('Not selected');
522524
},
523-
'updates and resets caller-owned selection': async ({ browser, expect, skip }) => {
525+
'updates and resets caller-owned selection': async ({ browser, expect, platform, skip }) => {
524526
const features = browser.capabilities['furn:features'];
525527
if (!features) throw new Error('Desktop Driver did not provide feature capabilities.');
526-
if (!features.physicalClick) {
527-
skip('This test requires physical pointer input.');
528+
if (platform === 'macos' && !features.physicalClick) {
529+
skip('macOS caller-owned selection requires physical pointer input; the custom Toggle action is not an AXPress route.');
528530
return;
529531
}
532+
if (!features.physicalClick && !features.accessibilityClick) {
533+
skip('This test requires native accessibility activation or physical pointer input.');
534+
return;
535+
}
536+
const { clickPhysical } = await import('../../common/desktopFocus.wdio.ts');
530537
const favorite = await browser.$('~agentic-storybook-button-favorite');
531538
const reset = await browser.$('~agentic-storybook-button-reset');
532539
const status = await browser.$('~agentic-storybook-button-selection-state');
533540
await expect(favorite).toBeEnabled();
534541
await expect(status).toHaveText('Not selected');
535-
await favorite.click();
542+
if (platform === 'macos') {
543+
await clickPhysical(browser, 'agentic-storybook-button-favorite');
544+
} else {
545+
await favorite.click();
546+
}
536547
await expect(status).toHaveText('Selected');
537-
await reset.click();
548+
if (platform === 'macos') {
549+
await clickPhysical(browser, 'agentic-storybook-button-reset');
550+
} else {
551+
await reset.click();
552+
}
538553
await expect(status).toHaveText('Not selected');
539554
},
540555
},
541556
render: () => {
542557
const ToggleGroup = () => {
543558
const [selected, setSelected] = useState(false);
559+
const toggle = resolveAccessibilityAction('toggle', Platform.OS).name;
544560
return (
545561
<StoryGroup label={selected ? 'Selected' : 'Not selected'} labelTestID="agentic-storybook-button-selection-state">
546562
<Button
547563
content="Favorite"
548564
icon={regularStarIcon}
549565
onPress={() => setSelected(!selected)}
566+
onAccessibilityAction={(event: AccessibilityActionEvent) => {
567+
if (event.nativeEvent.actionName === toggle) {
568+
setSelected((value) => !value);
569+
}
570+
}}
550571
selected={selected}
551572
selectedIcon={filledStarIcon}
552573
testID="agentic-storybook-button-favorite"
553574
/>
554-
<Button appearance="subtle" content="Reset" onPress={() => setSelected(false)} testID="agentic-storybook-button-reset" />
575+
<Button
576+
appearance="subtle"
577+
content="Reset"
578+
onAccessibilityTap={() => setSelected(false)}
579+
onPress={() => setSelected(false)}
580+
testID="agentic-storybook-button-reset"
581+
/>
555582
</StoryGroup>
556583
);
557584
};
@@ -561,7 +588,7 @@ export const ExternallyDrivenSelection: Story = {
561588
docs: {
562589
description: {
563590
story:
564-
'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.',
591+
'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.',
565592
},
566593
},
567594
},
@@ -587,6 +614,240 @@ export const Disabled: Story = {
587614
},
588615
};
589616

617+
export const NativeNames: Story = {
618+
render: () => (
619+
<View style={styles.story}>
620+
<Button content="Save" testID="button-name-plain" />
621+
<Button content={{ children: 'Slot label' }} testID="button-name-slot" />
622+
<Button content={0} testID="button-name-number" />
623+
<Button content="Save" accessibilityLabel="Create document" testID="button-name-explicit" />
624+
<Button content="Save" aria-label="ARIA action" testID="button-name-aria" />
625+
</View>
626+
),
627+
wdio: {
628+
'BTN-004 exposes plain, slot and numeric native names while preserving caller names': async ({ browser, expect }) => {
629+
for (const [id, name] of [
630+
['button-name-plain', 'Save'],
631+
['button-name-slot', 'Slot label'],
632+
['button-name-number', '0'],
633+
['button-name-explicit', 'Create document'],
634+
['button-name-aria', 'ARIA action'],
635+
]) {
636+
const button = await browser.$(`~${id}`);
637+
expect(await button.getTagName()).toBe('button');
638+
expect(await button.getProperty('name')).toBe(name);
639+
}
640+
},
641+
},
642+
};
643+
644+
export const NativeToggleSemantics: Story = {
645+
render: () => {
646+
const Scene = () => {
647+
const [action, setAction] = useState({ count: 0, name: 'none' });
648+
const [presses, setPresses] = useState(0);
649+
return (
650+
<View style={styles.story}>
651+
<Button content="Not selected" selected={false} testID="button-native-toggle-off" />
652+
<Button
653+
content="Selected"
654+
selected
655+
testID="button-native-toggle-on"
656+
onAccessibilityAction={(event: AccessibilityActionEvent) => {
657+
const name = event.nativeEvent.actionName;
658+
setAction((previous) => ({ count: previous.count + 1, name }));
659+
}}
660+
onPress={() => setPresses((count) => count + 1)}
661+
/>
662+
<Button
663+
content="Unavailable selection"
664+
selected
665+
disabled
666+
testID="button-native-toggle-disabled"
667+
onAccessibilityAction={(event: AccessibilityActionEvent) => {
668+
const name = event.nativeEvent.actionName;
669+
setAction((previous) => ({ count: previous.count + 1, name }));
670+
}}
671+
onPress={() => setPresses((count) => count + 1)}
672+
/>
673+
<StoryStatus testID="button-native-toggle-actions">{`${action.count}:${action.name}`}</StoryStatus>
674+
<StoryStatus testID="button-native-toggle-presses">{String(presses)}</StoryStatus>
675+
</View>
676+
);
677+
};
678+
return <Scene />;
679+
},
680+
wdio: {
681+
'BTN-004 BTN-005 exposes native button roles and enabled or disabled state': async ({ browser, expect }) => {
682+
const off = await browser.$('~button-native-toggle-off');
683+
const on = await browser.$('~button-native-toggle-on');
684+
const disabled = await browser.$('~button-native-toggle-disabled');
685+
expect(await off.getTagName()).toBe('button');
686+
expect(await on.getTagName()).toBe('button');
687+
expect(await disabled.getTagName()).toBe('button');
688+
await expect(off).toBeEnabled();
689+
await expect(on).toBeEnabled();
690+
await expect(disabled).toBeDisabled();
691+
},
692+
'BTN-005 Windows native checked projection reflects caller selection including disabled': async ({
693+
browser,
694+
expect,
695+
platform,
696+
skip,
697+
}) => {
698+
if (platform === 'macos') {
699+
skip(
700+
'Native checked projection is unsupported for the retained macOS AXButton role; shared role and enabled-state checks run separately.',
701+
);
702+
return;
703+
}
704+
const off = await browser.$('~button-native-toggle-off');
705+
const on = await browser.$('~button-native-toggle-on');
706+
const disabled = await browser.$('~button-native-toggle-disabled');
707+
expect(await off.getProperty('checked')).toBe(false);
708+
expect(await on.getProperty('checked')).toBe(true);
709+
expect(await disabled.getProperty('checked')).toBe(true);
710+
},
711+
'BTN-005 Win32 Toggle forwards once without a press or a controlled selection change': async ({ browser, expect, platform, skip }) => {
712+
if (platform !== 'win32') {
713+
skip('This case qualifies Win32 native Toggle transport.');
714+
return;
715+
}
716+
if (!browser.capabilities['furn:features']?.accessibilityClick) {
717+
skip('Native accessibility click is unavailable.');
718+
return;
719+
}
720+
expect(browser.capabilities['furn:clickMode']).toBe('accessibility');
721+
const button = await browser.$('~button-native-toggle-on');
722+
const actions = await browser.$('~button-native-toggle-actions');
723+
const presses = await browser.$('~button-native-toggle-presses');
724+
await expect(actions).toHaveAttribute('name', '0:none');
725+
await expect(presses).toHaveAttribute('name', '0');
726+
expect(await button.getProperty('checked')).toBe(true);
727+
await button.click();
728+
await expect(actions).toHaveAttribute('name', '1:Toggle');
729+
await expect(presses).toHaveAttribute('name', '0');
730+
expect(await button.getProperty('checked')).toBe(true);
731+
},
732+
'BTN-004 Win32 disabled public activation rejects without callbacks or a checked-state change': async ({
733+
browser,
734+
expect,
735+
platform,
736+
skip,
737+
}) => {
738+
if (platform !== 'win32') {
739+
skip('This case qualifies the Win32 disabled accessibility-click route, not disabled-provider dispatch.');
740+
return;
741+
}
742+
if (!browser.capabilities['furn:features']?.accessibilityClick) {
743+
skip('Native accessibility click is unavailable.');
744+
return;
745+
}
746+
expect(browser.capabilities['furn:clickMode']).toBe('accessibility');
747+
const button = await browser.$('~button-native-toggle-disabled');
748+
await expect(button).toBeDisabled();
749+
expect(await button.getProperty('checked')).toBe(true);
750+
await expect(button.click()).rejects.toThrow();
751+
await expect(await browser.$('~button-native-toggle-actions')).toHaveAttribute('name', '0:none');
752+
await expect(await browser.$('~button-native-toggle-presses')).toHaveAttribute('name', '0');
753+
expect(await button.getProperty('checked')).toBe(true);
754+
},
755+
},
756+
parameters: {
757+
docs: {
758+
description: {
759+
story:
760+
'Toggle state remains externally owned. Native Toggle declarations come from Button, not this fixture. Action counters observe the original native event separately from onPress.',
761+
},
762+
},
763+
},
764+
};
765+
766+
export const NativeToggleSelection: Story = {
767+
render: () => {
768+
const Scene = () => {
769+
const [selected, setSelected] = useState(false);
770+
const [actions, setActions] = useState(0);
771+
const [presses, setPresses] = useState(0);
772+
const toggle = resolveAccessibilityAction('toggle', Platform.OS).name;
773+
return (
774+
<View style={styles.story}>
775+
<Button
776+
content="Favorite"
777+
selected={selected}
778+
testID="button-native-selection"
779+
onAccessibilityAction={(event: AccessibilityActionEvent) => {
780+
if (event.nativeEvent.actionName === toggle) {
781+
setActions((count) => count + 1);
782+
setSelected((value) => !value);
783+
}
784+
}}
785+
onPress={() => {
786+
setPresses((count) => count + 1);
787+
setSelected((value) => !value);
788+
}}
789+
/>
790+
<StoryStatus testID="button-native-selection-state">{String(selected)}</StoryStatus>
791+
<StoryStatus testID="button-native-selection-actions">{String(actions)}</StoryStatus>
792+
<StoryStatus testID="button-native-selection-presses">{String(presses)}</StoryStatus>
793+
</View>
794+
);
795+
};
796+
return <Scene />;
797+
},
798+
wdio: {
799+
'BTN-002 BTN-005 Win32 native Toggle updates caller selection without reflow or duplicate presses': async ({
800+
browser,
801+
expect,
802+
platform,
803+
skip,
804+
}) => {
805+
if (platform !== 'win32') {
806+
skip('This case qualifies caller handling of Win32 native Toggle events.');
807+
return;
808+
}
809+
if (!browser.capabilities['furn:features']?.accessibilityClick) {
810+
skip('Native accessibility click is unavailable.');
811+
return;
812+
}
813+
expect(browser.capabilities['furn:clickMode']).toBe('accessibility');
814+
const { expectNativeState } = await import('../../common/desktopFocus.wdio.ts');
815+
const button = await browser.$('~button-native-selection');
816+
const state = await browser.$('~button-native-selection-state');
817+
const actions = await browser.$('~button-native-selection-actions');
818+
const presses = await browser.$('~button-native-selection-presses');
819+
await expect(state).toHaveAttribute('name', 'false');
820+
await expect(actions).toHaveAttribute('name', '0');
821+
await expect(presses).toHaveAttribute('name', '0');
822+
await expectNativeState(browser, 'button-native-selection', 'checked', false);
823+
const before = await browser.getElementRect(await button.elementId);
824+
await button.click();
825+
await expect(state).toHaveAttribute('name', 'true');
826+
await expect(actions).toHaveAttribute('name', '1');
827+
await expectNativeState(browser, 'button-native-selection', 'checked', true);
828+
const selected = await browser.getElementRect(await button.elementId);
829+
expect(selected.width).toBe(before.width);
830+
expect(selected.height).toBe(before.height);
831+
await button.click();
832+
await expect(state).toHaveAttribute('name', 'false');
833+
await expect(actions).toHaveAttribute('name', '2');
834+
await expect(presses).toHaveAttribute('name', '0');
835+
await expectNativeState(browser, 'button-native-selection', 'checked', false);
836+
const reset = await browser.getElementRect(await button.elementId);
837+
expect(reset.width).toBe(before.width);
838+
expect(reset.height).toBe(before.height);
839+
},
840+
},
841+
parameters: {
842+
docs: {
843+
description: {
844+
story:
845+
'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.',
846+
},
847+
},
848+
},
849+
};
850+
590851
export const WithLongText: Story = {
591852
render: () => (
592853
<StoryGroup label="Content width">

0 commit comments

Comments
 (0)