@oxyhq/bloom 0.88.0 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/lib/commonjs/accordion/Accordion.js +7 -5
  2. package/lib/commonjs/accordion/Accordion.js.map +1 -1
  3. package/lib/commonjs/button/Button.js +8 -7
  4. package/lib/commonjs/button/Button.js.map +1 -1
  5. package/lib/commonjs/checkbox/Checkbox.js +10 -5
  6. package/lib/commonjs/checkbox/Checkbox.js.map +1 -1
  7. package/lib/commonjs/chip/Chip.js +10 -1
  8. package/lib/commonjs/chip/Chip.js.map +1 -1
  9. package/lib/commonjs/collapsible/Collapsible.js +5 -4
  10. package/lib/commonjs/collapsible/Collapsible.js.map +1 -1
  11. package/lib/commonjs/composition-bar/CompositionBar.js +6 -1
  12. package/lib/commonjs/composition-bar/CompositionBar.js.map +1 -1
  13. package/lib/commonjs/frosted-icon-button/FrostedIconButton.js +7 -1
  14. package/lib/commonjs/frosted-icon-button/FrostedIconButton.js.map +1 -1
  15. package/lib/commonjs/input-group/InputGroup.js +6 -4
  16. package/lib/commonjs/input-group/InputGroup.js.map +1 -1
  17. package/lib/commonjs/item/Item.js +23 -5
  18. package/lib/commonjs/item/Item.js.map +1 -1
  19. package/lib/commonjs/segmented-control/index.js +12 -5
  20. package/lib/commonjs/segmented-control/index.js.map +1 -1
  21. package/lib/commonjs/select/index.js +6 -4
  22. package/lib/commonjs/select/index.js.map +1 -1
  23. package/lib/commonjs/select/index.web.js +6 -4
  24. package/lib/commonjs/select/index.web.js.map +1 -1
  25. package/lib/commonjs/settings-list/SettingsList.js +6 -4
  26. package/lib/commonjs/settings-list/SettingsList.js.map +1 -1
  27. package/lib/commonjs/slider/Slider.js +12 -9
  28. package/lib/commonjs/slider/Slider.js.map +1 -1
  29. package/lib/commonjs/switch/Switch.js +9 -5
  30. package/lib/commonjs/switch/Switch.js.map +1 -1
  31. package/lib/commonjs/tab-bar/TabBarBase.js +6 -4
  32. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  33. package/lib/module/accordion/Accordion.js +7 -5
  34. package/lib/module/accordion/Accordion.js.map +1 -1
  35. package/lib/module/button/Button.js +8 -7
  36. package/lib/module/button/Button.js.map +1 -1
  37. package/lib/module/checkbox/Checkbox.js +10 -5
  38. package/lib/module/checkbox/Checkbox.js.map +1 -1
  39. package/lib/module/chip/Chip.js +10 -1
  40. package/lib/module/chip/Chip.js.map +1 -1
  41. package/lib/module/collapsible/Collapsible.js +5 -4
  42. package/lib/module/collapsible/Collapsible.js.map +1 -1
  43. package/lib/module/composition-bar/CompositionBar.js +6 -1
  44. package/lib/module/composition-bar/CompositionBar.js.map +1 -1
  45. package/lib/module/frosted-icon-button/FrostedIconButton.js +7 -1
  46. package/lib/module/frosted-icon-button/FrostedIconButton.js.map +1 -1
  47. package/lib/module/input-group/InputGroup.js +6 -4
  48. package/lib/module/input-group/InputGroup.js.map +1 -1
  49. package/lib/module/item/Item.js +23 -5
  50. package/lib/module/item/Item.js.map +1 -1
  51. package/lib/module/segmented-control/index.js +12 -5
  52. package/lib/module/segmented-control/index.js.map +1 -1
  53. package/lib/module/select/index.js +6 -4
  54. package/lib/module/select/index.js.map +1 -1
  55. package/lib/module/select/index.web.js +6 -4
  56. package/lib/module/select/index.web.js.map +1 -1
  57. package/lib/module/settings-list/SettingsList.js +6 -4
  58. package/lib/module/settings-list/SettingsList.js.map +1 -1
  59. package/lib/module/slider/Slider.js +12 -9
  60. package/lib/module/slider/Slider.js.map +1 -1
  61. package/lib/module/switch/Switch.js +9 -5
  62. package/lib/module/switch/Switch.js.map +1 -1
  63. package/lib/module/tab-bar/TabBarBase.js +6 -4
  64. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  65. package/lib/typescript/commonjs/accordion/Accordion.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/checkbox/Checkbox.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/chip/Chip.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/collapsible/Collapsible.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/composition-bar/CompositionBar.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/input-group/InputGroup.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/item/Item.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/segmented-control/index.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/select/index.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/select/index.web.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/settings-list/SettingsList.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/slider/Slider.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/switch/Switch.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  81. package/lib/typescript/module/accordion/Accordion.d.ts.map +1 -1
  82. package/lib/typescript/module/button/Button.d.ts.map +1 -1
  83. package/lib/typescript/module/checkbox/Checkbox.d.ts.map +1 -1
  84. package/lib/typescript/module/chip/Chip.d.ts.map +1 -1
  85. package/lib/typescript/module/collapsible/Collapsible.d.ts.map +1 -1
  86. package/lib/typescript/module/composition-bar/CompositionBar.d.ts.map +1 -1
  87. package/lib/typescript/module/frosted-icon-button/FrostedIconButton.d.ts.map +1 -1
  88. package/lib/typescript/module/input-group/InputGroup.d.ts.map +1 -1
  89. package/lib/typescript/module/item/Item.d.ts.map +1 -1
  90. package/lib/typescript/module/segmented-control/index.d.ts.map +1 -1
  91. package/lib/typescript/module/select/index.d.ts.map +1 -1
  92. package/lib/typescript/module/select/index.web.d.ts.map +1 -1
  93. package/lib/typescript/module/settings-list/SettingsList.d.ts.map +1 -1
  94. package/lib/typescript/module/slider/Slider.d.ts.map +1 -1
  95. package/lib/typescript/module/switch/Switch.d.ts.map +1 -1
  96. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  97. package/package.json +1 -1
  98. package/src/__tests__/Button.test.tsx +11 -3
  99. package/src/__tests__/InputGroup.test.tsx +4 -1
  100. package/src/__tests__/Item.test.tsx +5 -1
  101. package/src/__tests__/Slider.test.tsx +11 -2
  102. package/src/__tests__/TabBar.test.tsx +9 -1
  103. package/src/__tests__/TabBarNoSelection.test.tsx +3 -1
  104. package/src/__tests__/aria-state-web.test.tsx +466 -0
  105. package/src/accordion/Accordion.tsx +5 -1
  106. package/src/button/Button.tsx +6 -1
  107. package/src/checkbox/Checkbox.tsx +8 -1
  108. package/src/chip/Chip.tsx +8 -0
  109. package/src/collapsible/Collapsible.tsx +3 -1
  110. package/src/composition-bar/CompositionBar.tsx +4 -0
  111. package/src/frosted-icon-button/FrostedIconButton.tsx +5 -0
  112. package/src/input-group/InputGroup.tsx +4 -1
  113. package/src/item/Item.tsx +24 -2
  114. package/src/segmented-control/index.tsx +8 -1
  115. package/src/select/index.tsx +4 -1
  116. package/src/select/index.web.tsx +4 -1
  117. package/src/settings-list/SettingsList.tsx +4 -1
  118. package/src/slider/Slider.tsx +10 -2
  119. package/src/switch/Switch.tsx +7 -1
  120. package/src/tab-bar/TabBarBase.tsx +4 -1
@@ -0,0 +1,466 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ // Every stateful Bloom control, checked against the DOM it actually produces.
6
+ //
7
+ // The bug this suite exists for: `Checkbox` announced no state on web. It set
8
+ // `accessibilityState={{checked}}` — the React Native answer, and the one every
9
+ // other control here used too — but react-native-web's `createDOMProps` never
10
+ // reads `accessibilityState`. It reads the `aria-*` props. So the box was drawn
11
+ // checked while assistive technology saw a `role="checkbox"` with no state at
12
+ // all, and the same was true of tab strips, radio options, accordions, sliders
13
+ // and toggles across the library.
14
+ //
15
+ // It survived because the existing suites assert on PROPS: `accessibilityState`
16
+ // was set, so they passed, and no prop-level assertion can tell you what
17
+ // react-native-web does with it afterwards. These tests render through the REAL
18
+ // react-native-web into jsdom and read the emitted ATTRIBUTES, which is the only
19
+ // place the difference is visible.
20
+ //
21
+ // The cross-platform contract, verified against both runtimes in node_modules:
22
+ // - react-native-web `createDOMProps` reads `aria-*`, ignores
23
+ // `accessibilityState` (it appears nowhere in that module).
24
+ // - React Native's `Pressable`/`View` fold `aria-busy|checked|disabled|
25
+ // expanded|selected` and `aria-value{min,max,now,text}` BACK into
26
+ // `accessibilityState`/`accessibilityValue`, with the `aria-*` value
27
+ // winning. So `aria-*` is the one spelling both platforms honour.
28
+ // - EXCEPT `aria-pressed`, which React Native has no concept of (its
29
+ // `AccessibilityState` is disabled/selected/checked/busy/expanded only).
30
+ // Toggles with `role="button"` therefore set BOTH: `accessibilityState`
31
+ // for native, `aria-pressed` for web. ARIA scopes these states by role —
32
+ // `aria-selected` would be invalid on a button, as `aria-pressed` would be
33
+ // on a tab — which is why the spelling differs per control rather than
34
+ // being one blanket substitution.
35
+ // - `aria-disabled` on a `Pressable` is a special case: react-native-web's
36
+ // `Pressable` appends its own from the `disabled` prop AFTER the caller's
37
+ // props, so a caller-supplied `aria-disabled` is overwritten there and the
38
+ // `disabled` PROP is the only spelling that works. On a plain `View` there
39
+ // is no such prop, so `aria-disabled` is the only one that works.
40
+
41
+ import React from 'react';
42
+ import { act } from 'react';
43
+ import { createRoot, type Root } from 'react-dom/client';
44
+
45
+ // The whole point is what react-native-web puts in the DOM, so this suite runs
46
+ // against the REAL RNW. Under the repo-wide `react-native` mock every assertion
47
+ // below would read a prop straight back off a stub element and pass whether or
48
+ // not the fix is present — the exact vacuity that let this ship.
49
+ jest.mock('react-native', () => jest.requireActual('react-native-web'));
50
+
51
+ import { Text } from 'react-native';
52
+
53
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
54
+ import { Checkbox } from '../checkbox/Checkbox';
55
+ import { Switch } from '../switch/Switch';
56
+ import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '../accordion';
57
+ import { Collapsible } from '../collapsible';
58
+ import { Chip } from '../chip';
59
+ import { Item } from '../item';
60
+ import { SegmentedControl, SegmentedControlItem } from '../segmented-control';
61
+ import { Button } from '../button/Button';
62
+ import { Slider } from '../slider';
63
+ import { InputGroup } from '../input-group';
64
+ import { SettingsListItem } from '../settings-list/SettingsList';
65
+ import { FrostedIconButton } from '../frosted-icon-button';
66
+ import { CompositionBar } from '../composition-bar';
67
+ // Both Select forks by explicit filename: jest has no platform-extension
68
+ // resolution, so a bare `'../select'` would only ever exercise the native one
69
+ // and the web fork could drift back unnoticed.
70
+ import {
71
+ Select as WebSelect,
72
+ SelectItem as WebSelectItem,
73
+ SelectItemText as WebSelectItemText,
74
+ } from '../select/index.web';
75
+ import {
76
+ Select as NativeSelect,
77
+ SelectItem as NativeSelectItem,
78
+ SelectItemText as NativeSelectItemText,
79
+ } from '../select/index';
80
+ import { TabBar, TabBarButton } from '../tab-bar';
81
+
82
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
83
+
84
+ let container: HTMLDivElement;
85
+ let root: Root;
86
+
87
+ function mount(ui: React.ReactElement): HTMLElement {
88
+ act(() => {
89
+ root.render(
90
+ <BloomThemeProvider mode="light" colorPreset="teal">
91
+ {ui}
92
+ </BloomThemeProvider>,
93
+ );
94
+ });
95
+ return container;
96
+ }
97
+
98
+ beforeEach(() => {
99
+ container = document.createElement('div');
100
+ document.body.appendChild(container);
101
+ root = createRoot(container);
102
+ });
103
+
104
+ afterEach(() => {
105
+ act(() => root.unmount());
106
+ container.remove();
107
+ });
108
+
109
+ /** The DOM node react-native-web rendered for `testID`. */
110
+ function byTestId(c: HTMLElement, id: string): HTMLElement {
111
+ const el = c.querySelector(`[data-testid="${id}"]`);
112
+ if (!(el instanceof HTMLElement)) {
113
+ throw new Error(`No element rendered for testID "${id}"`);
114
+ }
115
+ return el;
116
+ }
117
+
118
+ /** The first node carrying `role`, which is what a screen reader would find. */
119
+ function byRole(c: HTMLElement, role: string): HTMLElement {
120
+ const el = c.querySelector(`[role="${role}"]`);
121
+ if (!(el instanceof HTMLElement)) {
122
+ throw new Error(
123
+ `No element with role="${role}" in DOM:\n${c.innerHTML.slice(0, 600)}`,
124
+ );
125
+ }
126
+ return el;
127
+ }
128
+
129
+ function allByRole(c: HTMLElement, role: string): HTMLElement[] {
130
+ return Array.from(c.querySelectorAll(`[role="${role}"]`)).filter(
131
+ (el): el is HTMLElement => el instanceof HTMLElement,
132
+ );
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe('Checkbox (the reported defect)', () => {
138
+ it('emits aria-checked="true" when checked', () => {
139
+ const c = mount(<Checkbox checked onCheckedChange={() => {}} label="Sync" testID="cb" />);
140
+ const el = byTestId(c, 'cb');
141
+ expect(el.getAttribute('role')).toBe('checkbox');
142
+ expect(el.getAttribute('aria-checked')).toBe('true');
143
+ });
144
+
145
+ it('emits aria-checked="false" when unchecked — absent is not the same as false', () => {
146
+ // An omitted attribute leaves the control stateless to assistive tech,
147
+ // which is what shipped. "false" is a positive statement of unchecked.
148
+ const c = mount(
149
+ <Checkbox checked={false} onCheckedChange={() => {}} label="Sync" testID="cb" />,
150
+ );
151
+ expect(byTestId(c, 'cb').getAttribute('aria-checked')).toBe('false');
152
+ });
153
+
154
+ it('emits aria-checked="mixed" when indeterminate', () => {
155
+ const c = mount(
156
+ <Checkbox checked={false} indeterminate onCheckedChange={() => {}} label="S" testID="cb" />,
157
+ );
158
+ expect(byTestId(c, 'cb').getAttribute('aria-checked')).toBe('mixed');
159
+ });
160
+
161
+ it('emits aria-disabled when disabled', () => {
162
+ const c = mount(
163
+ <Checkbox checked onCheckedChange={() => {}} label="Sync" disabled testID="cb" />,
164
+ );
165
+ expect(byTestId(c, 'cb').getAttribute('aria-disabled')).toBe('true');
166
+ });
167
+
168
+ it('tracks a state change across a re-render', () => {
169
+ const c = mount(<Checkbox checked={false} onCheckedChange={() => {}} label="S" testID="cb" />);
170
+ expect(byTestId(c, 'cb').getAttribute('aria-checked')).toBe('false');
171
+ mount(<Checkbox checked onCheckedChange={() => {}} label="S" testID="cb" />);
172
+ expect(byTestId(c, 'cb').getAttribute('aria-checked')).toBe('true');
173
+ });
174
+ });
175
+
176
+ describe('Switch', () => {
177
+ it('emits role="switch" with aria-checked', () => {
178
+ const c = mount(<Switch value onValueChange={() => {}} testID="sw" />);
179
+ const el = byTestId(c, 'sw');
180
+ expect(el.getAttribute('role')).toBe('switch');
181
+ expect(el.getAttribute('aria-checked')).toBe('true');
182
+ });
183
+
184
+ it('emits aria-checked="false" when off', () => {
185
+ const c = mount(<Switch value={false} onValueChange={() => {}} testID="sw" />);
186
+ expect(byTestId(c, 'sw').getAttribute('aria-checked')).toBe('false');
187
+ });
188
+
189
+ it('emits aria-disabled when disabled', () => {
190
+ // Regression: the switch guarded presses internally but never passed
191
+ // `disabled` to its Pressable, so web announced a disabled switch as
192
+ // operable. react-native-web overwrites a caller's `aria-disabled` on a
193
+ // Pressable, so the prop is the only route.
194
+ const c = mount(<Switch value onValueChange={() => {}} disabled testID="sw" />);
195
+ expect(byTestId(c, 'sw').getAttribute('aria-disabled')).toBe('true');
196
+ });
197
+ });
198
+
199
+ describe('Accordion / Collapsible', () => {
200
+ it('Accordion trigger emits aria-expanded reflecting the open item', () => {
201
+ const c = mount(
202
+ <Accordion type="single" value="a" onValueChange={() => {}}>
203
+ <AccordionItem value="a">
204
+ <AccordionTrigger>Open one</AccordionTrigger>
205
+ <AccordionContent>
206
+ <Text>body</Text>
207
+ </AccordionContent>
208
+ </AccordionItem>
209
+ <AccordionItem value="b">
210
+ <AccordionTrigger>Closed one</AccordionTrigger>
211
+ <AccordionContent>
212
+ <Text>body</Text>
213
+ </AccordionContent>
214
+ </AccordionItem>
215
+ </Accordion>,
216
+ );
217
+ const triggers = allByRole(c, 'button');
218
+ expect(triggers).toHaveLength(2);
219
+ expect(triggers[0]?.getAttribute('aria-expanded')).toBe('true');
220
+ expect(triggers[1]?.getAttribute('aria-expanded')).toBe('false');
221
+ });
222
+
223
+ it('Collapsible header emits aria-expanded', () => {
224
+ const c = mount(
225
+ <Collapsible title="Details" defaultOpen>
226
+ <Text>body</Text>
227
+ </Collapsible>,
228
+ );
229
+ expect(byRole(c, 'button').getAttribute('aria-expanded')).toBe('true');
230
+ });
231
+
232
+ it('Collapsible header emits aria-expanded="false" when closed', () => {
233
+ const c = mount(
234
+ <Collapsible title="Details">
235
+ <Text>body</Text>
236
+ </Collapsible>,
237
+ );
238
+ expect(byRole(c, 'button').getAttribute('aria-expanded')).toBe('false');
239
+ });
240
+ });
241
+
242
+ describe('role="button" toggles use aria-pressed', () => {
243
+ // ARIA defines the toggle state of a button as `aria-pressed`; `aria-selected`
244
+ // is not valid on that role. React Native has no `aria-pressed`, so these
245
+ // components keep `accessibilityState={{selected}}` for native alongside it.
246
+ it('Chip emits aria-pressed when selected', () => {
247
+ const c = mount(
248
+ <Chip onPress={() => {}} selected testID="chip">
249
+ Filter
250
+ </Chip>,
251
+ );
252
+ expect(byTestId(c, 'chip').getAttribute('aria-pressed')).toBe('true');
253
+ });
254
+
255
+ it('Chip emits aria-pressed="false" when unselected', () => {
256
+ const c = mount(
257
+ <Chip onPress={() => {}} selected={false} testID="chip">
258
+ Filter
259
+ </Chip>,
260
+ );
261
+ expect(byTestId(c, 'chip').getAttribute('aria-pressed')).toBe('false');
262
+ });
263
+
264
+ it('FrostedIconButton emits aria-pressed, matching its .web fork', () => {
265
+ const c = mount(
266
+ <FrostedIconButton accessibilityLabel="Back" icon={<Text>x</Text>} active testID="fib" />,
267
+ );
268
+ expect(byTestId(c, 'fib').getAttribute('aria-pressed')).toBe('true');
269
+ });
270
+
271
+ it('CompositionBar marks the selected segment with aria-pressed', () => {
272
+ const c = mount(
273
+ <CompositionBar
274
+ categories={[
275
+ { key: 'a', name: 'Alpha', amount: 3, color: '#111111' },
276
+ { key: 'b', name: 'Beta', amount: 1, color: '#222222' },
277
+ ]}
278
+ selectedKey="b"
279
+ onSelect={() => {}}
280
+ hintLabel="Tap a segment"
281
+ />,
282
+ );
283
+ const segments = allByRole(c, 'button');
284
+ expect(segments).toHaveLength(2);
285
+ expect(segments[0]?.getAttribute('aria-pressed')).toBe('false');
286
+ expect(segments[1]?.getAttribute('aria-pressed')).toBe('true');
287
+ });
288
+ });
289
+
290
+ // `Item` puts `testID` on its inner content View, so these query the node that
291
+ // actually carries the role — which is the one a screen reader reads.
292
+ describe('Item selection is spelled per its role', () => {
293
+ it('a pressable row defaults to a button toggle: aria-pressed', () => {
294
+ const c = mount(<Item title="Row" onPress={() => {}} selected />);
295
+ expect(byRole(c, 'button').getAttribute('aria-pressed')).toBe('true');
296
+ });
297
+
298
+ it('role="option" gets aria-selected, which is what ARIA allows there', () => {
299
+ const c = mount(<Item title="Row" role="option" onPress={() => {}} selected />);
300
+ const el = byRole(c, 'option');
301
+ expect(el.getAttribute('aria-selected')).toBe('true');
302
+ // `aria-pressed` is not valid on `option` and must not be emitted too.
303
+ expect(el.hasAttribute('aria-pressed')).toBe(false);
304
+ });
305
+
306
+ it('a row with no selection state emits neither attribute', () => {
307
+ const c = mount(<Item title="Row" onPress={() => {}} />);
308
+ const el = byRole(c, 'button');
309
+ expect(el.hasAttribute('aria-pressed')).toBe(false);
310
+ expect(el.hasAttribute('aria-selected')).toBe(false);
311
+ });
312
+
313
+ it('a non-pressable disabled row emits aria-disabled', () => {
314
+ // This branch renders a View, which has no `disabled` prop for
315
+ // react-native-web to derive the attribute from.
316
+ const c = mount(<Item title="Row" disabled />);
317
+ expect(c.querySelector('[aria-disabled="true"]')).not.toBeNull();
318
+ });
319
+ });
320
+
321
+ describe('SegmentedControl spells the active state for its role', () => {
322
+ it('type="tabs" emits aria-selected on role="tab"', () => {
323
+ const c = mount(
324
+ <SegmentedControl label="View" type="tabs" value="one" onChange={() => {}}>
325
+ <SegmentedControlItem value="one" testID="seg-one">
326
+ <Text>One</Text>
327
+ </SegmentedControlItem>
328
+ <SegmentedControlItem value="two" testID="seg-two">
329
+ <Text>Two</Text>
330
+ </SegmentedControlItem>
331
+ </SegmentedControl>,
332
+ );
333
+ expect(byTestId(c, 'seg-one').getAttribute('role')).toBe('tab');
334
+ expect(byTestId(c, 'seg-one').getAttribute('aria-selected')).toBe('true');
335
+ expect(byTestId(c, 'seg-two').getAttribute('aria-selected')).toBe('false');
336
+ });
337
+
338
+ it('type="radio" emits aria-checked on role="radio", not aria-selected', () => {
339
+ // ARIA gives `radio` a checked state; `aria-selected` is invalid there, so
340
+ // one blanket spelling for both modes would be wrong for this one.
341
+ const c = mount(
342
+ <SegmentedControl label="View" type="radio" value="two" onChange={() => {}}>
343
+ <SegmentedControlItem value="one" testID="seg-one">
344
+ <Text>One</Text>
345
+ </SegmentedControlItem>
346
+ <SegmentedControlItem value="two" testID="seg-two">
347
+ <Text>Two</Text>
348
+ </SegmentedControlItem>
349
+ </SegmentedControl>,
350
+ );
351
+ const two = byTestId(c, 'seg-two');
352
+ expect(two.getAttribute('role')).toBe('radio');
353
+ expect(two.getAttribute('aria-checked')).toBe('true');
354
+ expect(two.hasAttribute('aria-selected')).toBe(false);
355
+ expect(byTestId(c, 'seg-one').getAttribute('aria-checked')).toBe('false');
356
+ });
357
+ });
358
+
359
+ describe('Button', () => {
360
+ it('emits aria-busy while loading, matching Button.web', () => {
361
+ const c = mount(
362
+ <Button loading testID="btn">
363
+ Saving
364
+ </Button>,
365
+ );
366
+ expect(byTestId(c, 'btn').getAttribute('aria-busy')).toBe('true');
367
+ });
368
+
369
+ it('emits aria-disabled while loading, from the disabled prop', () => {
370
+ const c = mount(
371
+ <Button loading testID="btn">
372
+ Saving
373
+ </Button>,
374
+ );
375
+ expect(byTestId(c, 'btn').getAttribute('aria-disabled')).toBe('true');
376
+ });
377
+
378
+ it('emits no aria-busy when idle', () => {
379
+ const c = mount(<Button testID="btn">Save</Button>);
380
+ expect(byTestId(c, 'btn').hasAttribute('aria-busy')).toBe(false);
381
+ });
382
+ });
383
+
384
+ describe('Slider', () => {
385
+ it('emits the value as aria-value* — the object form reached web as nothing', () => {
386
+ // react-native-web has no handling for `accessibilityValue` at all, so the
387
+ // slider announced its role and no value whatsoever.
388
+ const c = mount(
389
+ <Slider value={40} min={0} max={100} onValueChange={() => {}} testID="sl" />,
390
+ );
391
+ const el = byTestId(c, 'sl');
392
+ expect(el.getAttribute('role')).toBe('slider');
393
+ expect(el.getAttribute('aria-valuenow')).toBe('40');
394
+ expect(el.getAttribute('aria-valuemin')).toBe('0');
395
+ expect(el.getAttribute('aria-valuemax')).toBe('100');
396
+ });
397
+
398
+ it('emits aria-disabled when disabled', () => {
399
+ const c = mount(
400
+ <Slider value={0} min={0} max={10} onValueChange={() => {}} disabled testID="sl" />,
401
+ );
402
+ expect(byTestId(c, 'sl').getAttribute('aria-disabled')).toBe('true');
403
+ });
404
+ });
405
+
406
+ // ARIA gives `role="radio"` a checked state, not a selected one. Both forks are
407
+ // asserted from one place so the platforms cannot drift apart again.
408
+ describe.each([
409
+ ['web', WebSelect, WebSelectItem, WebSelectItemText] as const,
410
+ ['native', NativeSelect, NativeSelectItem, NativeSelectItemText] as const,
411
+ ])('SelectItem (%s fork) through react-native-web', (_p, Select, SelectItem, SelectItemText) => {
412
+ it('emits aria-checked reflecting the current value', () => {
413
+ const c = mount(
414
+ <Select value="a">
415
+ <SelectItem value="a" label="Option A">
416
+ <SelectItemText>Option A</SelectItemText>
417
+ </SelectItem>
418
+ <SelectItem value="b" label="Option B">
419
+ <SelectItemText>Option B</SelectItemText>
420
+ </SelectItem>
421
+ </Select>,
422
+ );
423
+ const options = allByRole(c, 'radio');
424
+ expect(options).toHaveLength(2);
425
+ expect(options[0]?.getAttribute('aria-checked')).toBe('true');
426
+ expect(options[1]?.getAttribute('aria-checked')).toBe('false');
427
+ });
428
+ });
429
+
430
+ describe('TabBar', () => {
431
+ it('emits aria-selected on the focused tab', () => {
432
+ const items = [
433
+ { name: 'home', label: 'Home', icon: <Text>h</Text> },
434
+ { name: 'search', label: 'Search', icon: <Text>s</Text> },
435
+ ];
436
+ const c = mount(
437
+ <TabBar activeIndex={1}>
438
+ {items.map((item, index) => (
439
+ <TabBarButton key={item.name} item={item} index={index} />
440
+ ))}
441
+ </TabBar>,
442
+ );
443
+ const tabs = allByRole(c, 'tab');
444
+ expect(tabs).toHaveLength(2);
445
+ expect(tabs[0]?.getAttribute('aria-selected')).toBe('false');
446
+ expect(tabs[1]?.getAttribute('aria-selected')).toBe('true');
447
+ });
448
+ });
449
+
450
+ describe('View-based containers emit aria-disabled', () => {
451
+ it('InputGroup', () => {
452
+ const c = mount(
453
+ <InputGroup disabled testID="ig">
454
+ <Text>field</Text>
455
+ </InputGroup>,
456
+ );
457
+ expect(byTestId(c, 'ig').getAttribute('aria-disabled')).toBe('true');
458
+ });
459
+
460
+ it('SettingsListItem without onPress', () => {
461
+ const c = mount(<SettingsListItem title="Wi-Fi" disabled />);
462
+ // The row renders a View wrapping the content; the disabled state belongs
463
+ // on whichever node carries the a11y role.
464
+ expect(c.querySelector('[aria-disabled="true"]')).not.toBeNull();
465
+ });
466
+ });
@@ -176,7 +176,11 @@ const AccordionTriggerComponent: React.FC<AccordionTriggerProps> = ({
176
176
  onPressOut={disabled ? undefined : onPressOut}
177
177
  disabled={disabled}
178
178
  accessibilityRole="button"
179
- accessibilityState={{ expanded: isExpanded, disabled }}
179
+ // `aria-expanded` rather than `accessibilityState`, which react-native-web
180
+ // drops entirely. React Native folds this back into `accessibilityState`,
181
+ // so it is the one spelling both platforms read. `disabled` travels on the
182
+ // `disabled` prop above.
183
+ aria-expanded={isExpanded}
180
184
  >
181
185
  {icon}
182
186
  <View style={{ flex: 1 }}>
@@ -334,7 +334,12 @@ const ButtonComponent: React.FC<ButtonProps> = ({
334
334
  accessibilityLabel={accessibilityLabel}
335
335
  accessibilityHint={accessibilityHint}
336
336
  accessibilityRole="button"
337
- accessibilityState={loading ? { disabled: isInteractionBlocked, busy: true } : { disabled: isInteractionBlocked }}
337
+ // `aria-busy` matches what `Button.web.tsx` emits, so a loading button
338
+ // announces the same thing whichever fork a bundler resolves.
339
+ // react-native-web never reads `accessibilityState`; React Native folds
340
+ // `aria-busy` back into it. The disabled half travels on the `disabled`
341
+ // prop above, which both platforms map.
342
+ aria-busy={loading || undefined}
338
343
  testID={testID}
339
344
  >
340
345
  {loading ? (
@@ -106,7 +106,14 @@ const CheckboxComponent: React.FC<CheckboxProps> = ({
106
106
  onPressOut={onPressOut}
107
107
  disabled={disabled}
108
108
  accessibilityRole="checkbox"
109
- accessibilityState={{ checked: indeterminate ? 'mixed' : checked, disabled }}
109
+ // `aria-checked`, not `accessibilityState`: react-native-web's
110
+ // `createDOMProps` never reads `accessibilityState`, so a checkbox that
111
+ // set only that announced no state at all on web — the box was drawn
112
+ // checked while assistive tech saw an unchecked control. React Native's
113
+ // `Pressable` folds `aria-checked` back into `accessibilityState`, so
114
+ // this one prop is the spelling both platforms honour. `disabled`
115
+ // travels on the `disabled` prop above, which both platforms map.
116
+ aria-checked={indeterminate ? 'mixed' : checked}
110
117
  accessibilityLabel={accessibilityLabel ?? label}
111
118
  hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
112
119
  testID={testID}
package/src/chip/Chip.tsx CHANGED
@@ -121,7 +121,15 @@ const ChipComponent: React.FC<ChipProps> = ({
121
121
  disabled={disabled}
122
122
  accessibilityLabel={accessibilityLabel}
123
123
  accessibilityRole="button"
124
+ // Both spellings, because neither platform reads the other's and
125
+ // there is no single prop that serves both here: react-native-web
126
+ // ignores `accessibilityState`, while React Native has no
127
+ // `aria-pressed` at all (its `AccessibilityState` is
128
+ // disabled/selected/checked/busy/expanded). `aria-pressed` is also
129
+ // the state ARIA defines for a toggle with `role="button"` —
130
+ // `aria-selected` would be invalid on that role.
124
131
  accessibilityState={{ disabled, selected }}
132
+ aria-pressed={selected}
125
133
  testID={testID}
126
134
  >
127
135
  {content}
@@ -58,7 +58,9 @@ const CollapsibleComponent: React.FC<CollapsibleProps> = ({
58
58
  onPress={handleToggle}
59
59
  activeOpacity={0.7}
60
60
  accessibilityRole="button"
61
- accessibilityState={{ expanded: isOpen }}
61
+ // See `Accordion`: react-native-web reads `aria-expanded`, never
62
+ // `accessibilityState`; React Native folds it back into that state.
63
+ aria-expanded={isOpen}
62
64
  style={styles.trigger}
63
65
  >
64
66
  {chevronIcon ?? (
@@ -75,7 +75,11 @@ const CompositionBarComponent: React.FC<CompositionBarProps> = ({
75
75
  onPress={() => onSelect(category.key)}
76
76
  accessibilityRole="button"
77
77
  accessibilityLabel={category.name}
78
+ // See `Chip`: `accessibilityState` carries this on native (which
79
+ // has no `aria-pressed`), `aria-pressed` on web (where
80
+ // react-native-web never reads `accessibilityState`).
78
81
  accessibilityState={{ selected: category.key === selectedKey }}
82
+ aria-pressed={category.key === selectedKey}
79
83
  style={[
80
84
  styles.segment,
81
85
  {
@@ -110,7 +110,12 @@ const FrostedIconButtonComponent: React.FC<FrostedIconButtonProps> = ({
110
110
  accessibilityRole="button"
111
111
  accessibilityLabel={accessibilityLabel}
112
112
  accessibilityHint={accessibilityHint}
113
+ // `aria-pressed` matches what the `.web.tsx` fork emits, so the same
114
+ // button announces the same state whichever fork a bundler picks.
115
+ // Both spellings are needed: react-native-web ignores
116
+ // `accessibilityState`, React Native has no `aria-pressed`.
113
117
  accessibilityState={{ disabled, selected: active }}
118
+ aria-pressed={active}
114
119
  testID={testID}
115
120
  >
116
121
  {/* Clip layer: blur (frosted only) + translucent/solid tint, rounded. */}
@@ -126,7 +126,10 @@ const InputGroupComponent = function InputGroup({
126
126
  <InputGroupContext.Provider value={ctx}>
127
127
  <View
128
128
  testID={testID}
129
- accessibilityState={disabled ? { disabled: true } : undefined}
129
+ // `aria-disabled`, not `accessibilityState`: this is a `View`, so
130
+ // react-native-web has no `disabled` prop to derive the attribute from
131
+ // and never reads `accessibilityState`. React Native folds it back.
132
+ aria-disabled={disabled || undefined}
130
133
  // Capture focus bubbling from a nested input so the whole chrome
131
134
  // reflects focus — RN-web bubbles focus/blur, native does not but a
132
135
  // nested TextInput's own focus ring is sufficient there.
package/src/item/Item.tsx CHANGED
@@ -28,7 +28,11 @@ const ItemComponent = function Item({
28
28
  onLongPress,
29
29
  disabled = false,
30
30
  destructive = false,
31
- selected = false,
31
+ // Deliberately NOT defaulted: a row that never mentions selection is not a
32
+ // toggle, and defaulting to `false` made every plain navigation row announce
33
+ // itself as an unpressed toggle button on web. Absence and "off" are
34
+ // different statements to assistive tech; both remain falsy for styling.
35
+ selected,
32
36
  active = false,
33
37
  density = 'comfortable',
34
38
  style,
@@ -109,6 +113,19 @@ const ItemComponent = function Item({
109
113
 
110
114
  if (onPress || onLongPress) {
111
115
  const resolvedRole = accessibilityRole ?? 'button';
116
+ // Web reads only `aria-*` — react-native-web never consults
117
+ // `accessibilityState` — and ARIA scopes these states by role: `option`
118
+ // carries a selected state, a `button` toggle carries a pressed one, and
119
+ // `menuitem`/`listitem` carry neither, so those emit nothing. React Native
120
+ // keeps reading `accessibilityState` below, which is why both are set.
121
+ const selectedAria =
122
+ selected == null
123
+ ? null
124
+ : role === 'option'
125
+ ? { 'aria-selected': selected }
126
+ : role == null
127
+ ? { 'aria-pressed': selected }
128
+ : null;
112
129
  const handlePress = disabled || !onPress ? undefined : onPress;
113
130
  const handleLongPress = disabled || !onLongPress ? undefined : onLongPress;
114
131
  return (
@@ -126,6 +143,7 @@ const ItemComponent = function Item({
126
143
  }
127
144
  accessibilityHint={accessibilityHint}
128
145
  accessibilityState={{ disabled, selected }}
146
+ {...selectedAria}
129
147
  style={[
130
148
  disabled && styles.disabled,
131
149
  pressed && !disabled && styles.pressed,
@@ -143,7 +161,11 @@ const ItemComponent = function Item({
143
161
  accessibilityLabel ?? (typeof title === 'string' ? title : undefined)
144
162
  }
145
163
  accessibilityHint={accessibilityHint}
146
- accessibilityState={disabled ? { disabled: true } : undefined}
164
+ // A `View` has no `disabled` prop for react-native-web to derive
165
+ // `aria-disabled` from, and it ignores `accessibilityState`, so without
166
+ // this the non-pressable row announced nothing. React Native folds
167
+ // `aria-disabled` back into `accessibilityState`.
168
+ aria-disabled={disabled || undefined}
147
169
  style={disabled ? styles.disabled : undefined}>
148
170
  {content}
149
171
  </View>
@@ -240,7 +240,14 @@ export function SegmentedControlItem({
240
240
  onPressOut={disabled ? undefined : onPressOut}
241
241
  accessibilityLabel={accessibilityLabel}
242
242
  accessibilityHint={accessibilityHint}
243
- accessibilityState={{ selected: active, disabled: !!disabled }}
243
+ // The active state has to be spelled as an `aria-*` prop, because
244
+ // react-native-web never reads `accessibilityState` (React Native folds
245
+ // these back into it, so native is unaffected) — and WHICH prop depends
246
+ // on the role: ARIA gives `tab` a selected state and `radio` a checked
247
+ // one, so a single spelling would be invalid for one of the two modes.
248
+ {...(itemRole === 'tab'
249
+ ? { 'aria-selected': active }
250
+ : { 'aria-checked': active })}
244
251
  role={itemRole}
245
252
  disabled={disabled}
246
253
  testID={testID}
@@ -289,7 +289,10 @@ export function SelectItem({ children, value, label, style }: SelectItemProps) {
289
289
  <Pressable
290
290
  accessibilityRole="radio"
291
291
  accessibilityLabel={label}
292
- accessibilityState={{ checked: isSelected }}
292
+ // ARIA gives `role="radio"` a checked state, not a selected one. Spelled
293
+ // as `aria-checked` because react-native-web never reads
294
+ // `accessibilityState`; React Native folds this back into it.
295
+ aria-checked={isSelected}
293
296
  onPress={handlePress}
294
297
  onFocus={onFocus}
295
298
  onBlur={onBlur}