@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.
- package/lib/commonjs/accordion/Accordion.js +7 -5
- package/lib/commonjs/accordion/Accordion.js.map +1 -1
- package/lib/commonjs/button/Button.js +8 -7
- package/lib/commonjs/button/Button.js.map +1 -1
- package/lib/commonjs/checkbox/Checkbox.js +10 -5
- package/lib/commonjs/checkbox/Checkbox.js.map +1 -1
- package/lib/commonjs/chip/Chip.js +10 -1
- package/lib/commonjs/chip/Chip.js.map +1 -1
- package/lib/commonjs/collapsible/Collapsible.js +5 -4
- package/lib/commonjs/collapsible/Collapsible.js.map +1 -1
- package/lib/commonjs/composition-bar/CompositionBar.js +6 -1
- package/lib/commonjs/composition-bar/CompositionBar.js.map +1 -1
- package/lib/commonjs/frosted-icon-button/FrostedIconButton.js +7 -1
- package/lib/commonjs/frosted-icon-button/FrostedIconButton.js.map +1 -1
- package/lib/commonjs/input-group/InputGroup.js +6 -4
- package/lib/commonjs/input-group/InputGroup.js.map +1 -1
- package/lib/commonjs/item/Item.js +23 -5
- package/lib/commonjs/item/Item.js.map +1 -1
- package/lib/commonjs/segmented-control/index.js +12 -5
- package/lib/commonjs/segmented-control/index.js.map +1 -1
- package/lib/commonjs/select/index.js +6 -4
- package/lib/commonjs/select/index.js.map +1 -1
- package/lib/commonjs/select/index.web.js +6 -4
- package/lib/commonjs/select/index.web.js.map +1 -1
- package/lib/commonjs/settings-list/SettingsList.js +6 -4
- package/lib/commonjs/settings-list/SettingsList.js.map +1 -1
- package/lib/commonjs/slider/Slider.js +12 -9
- package/lib/commonjs/slider/Slider.js.map +1 -1
- package/lib/commonjs/switch/Switch.js +9 -5
- package/lib/commonjs/switch/Switch.js.map +1 -1
- package/lib/commonjs/tab-bar/TabBarBase.js +6 -4
- package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
- package/lib/module/accordion/Accordion.js +7 -5
- package/lib/module/accordion/Accordion.js.map +1 -1
- package/lib/module/button/Button.js +8 -7
- package/lib/module/button/Button.js.map +1 -1
- package/lib/module/checkbox/Checkbox.js +10 -5
- package/lib/module/checkbox/Checkbox.js.map +1 -1
- package/lib/module/chip/Chip.js +10 -1
- package/lib/module/chip/Chip.js.map +1 -1
- package/lib/module/collapsible/Collapsible.js +5 -4
- package/lib/module/collapsible/Collapsible.js.map +1 -1
- package/lib/module/composition-bar/CompositionBar.js +6 -1
- package/lib/module/composition-bar/CompositionBar.js.map +1 -1
- package/lib/module/frosted-icon-button/FrostedIconButton.js +7 -1
- package/lib/module/frosted-icon-button/FrostedIconButton.js.map +1 -1
- package/lib/module/input-group/InputGroup.js +6 -4
- package/lib/module/input-group/InputGroup.js.map +1 -1
- package/lib/module/item/Item.js +23 -5
- package/lib/module/item/Item.js.map +1 -1
- package/lib/module/segmented-control/index.js +12 -5
- package/lib/module/segmented-control/index.js.map +1 -1
- package/lib/module/select/index.js +6 -4
- package/lib/module/select/index.js.map +1 -1
- package/lib/module/select/index.web.js +6 -4
- package/lib/module/select/index.web.js.map +1 -1
- package/lib/module/settings-list/SettingsList.js +6 -4
- package/lib/module/settings-list/SettingsList.js.map +1 -1
- package/lib/module/slider/Slider.js +12 -9
- package/lib/module/slider/Slider.js.map +1 -1
- package/lib/module/switch/Switch.js +9 -5
- package/lib/module/switch/Switch.js.map +1 -1
- package/lib/module/tab-bar/TabBarBase.js +6 -4
- package/lib/module/tab-bar/TabBarBase.js.map +1 -1
- package/lib/typescript/commonjs/accordion/Accordion.d.ts.map +1 -1
- package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
- package/lib/typescript/commonjs/checkbox/Checkbox.d.ts.map +1 -1
- package/lib/typescript/commonjs/chip/Chip.d.ts.map +1 -1
- package/lib/typescript/commonjs/collapsible/Collapsible.d.ts.map +1 -1
- package/lib/typescript/commonjs/composition-bar/CompositionBar.d.ts.map +1 -1
- package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/input-group/InputGroup.d.ts.map +1 -1
- package/lib/typescript/commonjs/item/Item.d.ts.map +1 -1
- package/lib/typescript/commonjs/segmented-control/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/select/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/select/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/settings-list/SettingsList.d.ts.map +1 -1
- package/lib/typescript/commonjs/slider/Slider.d.ts.map +1 -1
- package/lib/typescript/commonjs/switch/Switch.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/module/accordion/Accordion.d.ts.map +1 -1
- package/lib/typescript/module/button/Button.d.ts.map +1 -1
- package/lib/typescript/module/checkbox/Checkbox.d.ts.map +1 -1
- package/lib/typescript/module/chip/Chip.d.ts.map +1 -1
- package/lib/typescript/module/collapsible/Collapsible.d.ts.map +1 -1
- package/lib/typescript/module/composition-bar/CompositionBar.d.ts.map +1 -1
- package/lib/typescript/module/frosted-icon-button/FrostedIconButton.d.ts.map +1 -1
- package/lib/typescript/module/input-group/InputGroup.d.ts.map +1 -1
- package/lib/typescript/module/item/Item.d.ts.map +1 -1
- package/lib/typescript/module/segmented-control/index.d.ts.map +1 -1
- package/lib/typescript/module/select/index.d.ts.map +1 -1
- package/lib/typescript/module/select/index.web.d.ts.map +1 -1
- package/lib/typescript/module/settings-list/SettingsList.d.ts.map +1 -1
- package/lib/typescript/module/slider/Slider.d.ts.map +1 -1
- package/lib/typescript/module/switch/Switch.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/Button.test.tsx +11 -3
- package/src/__tests__/InputGroup.test.tsx +4 -1
- package/src/__tests__/Item.test.tsx +5 -1
- package/src/__tests__/Slider.test.tsx +11 -2
- package/src/__tests__/TabBar.test.tsx +9 -1
- package/src/__tests__/TabBarNoSelection.test.tsx +3 -1
- package/src/__tests__/aria-state-web.test.tsx +466 -0
- package/src/accordion/Accordion.tsx +5 -1
- package/src/button/Button.tsx +6 -1
- package/src/checkbox/Checkbox.tsx +8 -1
- package/src/chip/Chip.tsx +8 -0
- package/src/collapsible/Collapsible.tsx +3 -1
- package/src/composition-bar/CompositionBar.tsx +4 -0
- package/src/frosted-icon-button/FrostedIconButton.tsx +5 -0
- package/src/input-group/InputGroup.tsx +4 -1
- package/src/item/Item.tsx +24 -2
- package/src/segmented-control/index.tsx +8 -1
- package/src/select/index.tsx +4 -1
- package/src/select/index.web.tsx +4 -1
- package/src/settings-list/SettingsList.tsx +4 -1
- package/src/slider/Slider.tsx +10 -2
- package/src/switch/Switch.tsx +7 -1
- 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
|
-
|
|
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 }}>
|
package/src/button/Button.tsx
CHANGED
|
@@ -334,7 +334,12 @@ const ButtonComponent: React.FC<ButtonProps> = ({
|
|
|
334
334
|
accessibilityLabel={accessibilityLabel}
|
|
335
335
|
accessibilityHint={accessibilityHint}
|
|
336
336
|
accessibilityRole="button"
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
package/src/select/index.tsx
CHANGED
|
@@ -289,7 +289,10 @@ export function SelectItem({ children, value, label, style }: SelectItemProps) {
|
|
|
289
289
|
<Pressable
|
|
290
290
|
accessibilityRole="radio"
|
|
291
291
|
accessibilityLabel={label}
|
|
292
|
-
|
|
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}
|