@oxyhq/bloom 0.87.1 → 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 (214) 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/bottom-sheet/BottomSheetBase.js +7 -12
  4. package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
  5. package/lib/commonjs/button/Button.js +8 -7
  6. package/lib/commonjs/button/Button.js.map +1 -1
  7. package/lib/commonjs/button/Button.web.js +2 -6
  8. package/lib/commonjs/button/Button.web.js.map +1 -1
  9. package/lib/commonjs/checkbox/Checkbox.js +10 -5
  10. package/lib/commonjs/checkbox/Checkbox.js.map +1 -1
  11. package/lib/commonjs/chip/Chip.js +10 -1
  12. package/lib/commonjs/chip/Chip.js.map +1 -1
  13. package/lib/commonjs/collapsible/Collapsible.js +5 -4
  14. package/lib/commonjs/collapsible/Collapsible.js.map +1 -1
  15. package/lib/commonjs/composition-bar/CompositionBar.js +6 -1
  16. package/lib/commonjs/composition-bar/CompositionBar.js.map +1 -1
  17. package/lib/commonjs/connection-dots/ConnectionDots.web.js +2 -6
  18. package/lib/commonjs/connection-dots/ConnectionDots.web.js.map +1 -1
  19. package/lib/commonjs/design-tokens/index.js +19 -0
  20. package/lib/commonjs/design-tokens/index.js.map +1 -1
  21. package/lib/commonjs/design-tokens/tokens-json.js +149 -0
  22. package/lib/commonjs/design-tokens/tokens-json.js.map +1 -0
  23. package/lib/commonjs/design-tokens/tokens.json +6681 -0
  24. package/lib/commonjs/dialog/Dialog.web.js +2 -6
  25. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  26. package/lib/commonjs/fab/Fab.web.js +2 -6
  27. package/lib/commonjs/fab/Fab.web.js.map +1 -1
  28. package/lib/commonjs/fonts/apply-font-faces.web.js +8 -10
  29. package/lib/commonjs/fonts/apply-font-faces.web.js.map +1 -1
  30. package/lib/commonjs/frosted-icon-button/FrostedIconButton.js +7 -1
  31. package/lib/commonjs/frosted-icon-button/FrostedIconButton.js.map +1 -1
  32. package/lib/commonjs/frosted-icon-button/FrostedIconButton.web.js +2 -6
  33. package/lib/commonjs/frosted-icon-button/FrostedIconButton.web.js.map +1 -1
  34. package/lib/commonjs/input-group/InputGroup.js +6 -4
  35. package/lib/commonjs/input-group/InputGroup.js.map +1 -1
  36. package/lib/commonjs/item/Item.js +23 -5
  37. package/lib/commonjs/item/Item.js.map +1 -1
  38. package/lib/commonjs/loading/SpinnerIcon.web.js +2 -6
  39. package/lib/commonjs/loading/SpinnerIcon.web.js.map +1 -1
  40. package/lib/commonjs/segmented-control/index.js +12 -5
  41. package/lib/commonjs/segmented-control/index.js.map +1 -1
  42. package/lib/commonjs/select/index.js +6 -4
  43. package/lib/commonjs/select/index.js.map +1 -1
  44. package/lib/commonjs/select/index.web.js +6 -4
  45. package/lib/commonjs/select/index.web.js.map +1 -1
  46. package/lib/commonjs/settings-list/SettingsList.js +6 -4
  47. package/lib/commonjs/settings-list/SettingsList.js.map +1 -1
  48. package/lib/commonjs/slider/Slider.js +12 -9
  49. package/lib/commonjs/slider/Slider.js.map +1 -1
  50. package/lib/commonjs/styles/adopt-style-sheet.js +126 -0
  51. package/lib/commonjs/styles/adopt-style-sheet.js.map +1 -0
  52. package/lib/commonjs/switch/Switch.js +9 -5
  53. package/lib/commonjs/switch/Switch.js.map +1 -1
  54. package/lib/commonjs/tab-bar/TabBarBase.js +6 -4
  55. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  56. package/lib/module/accordion/Accordion.js +7 -5
  57. package/lib/module/accordion/Accordion.js.map +1 -1
  58. package/lib/module/bottom-sheet/BottomSheetBase.js +7 -12
  59. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
  60. package/lib/module/button/Button.js +8 -7
  61. package/lib/module/button/Button.js.map +1 -1
  62. package/lib/module/button/Button.web.js +2 -6
  63. package/lib/module/button/Button.web.js.map +1 -1
  64. package/lib/module/checkbox/Checkbox.js +10 -5
  65. package/lib/module/checkbox/Checkbox.js.map +1 -1
  66. package/lib/module/chip/Chip.js +10 -1
  67. package/lib/module/chip/Chip.js.map +1 -1
  68. package/lib/module/collapsible/Collapsible.js +5 -4
  69. package/lib/module/collapsible/Collapsible.js.map +1 -1
  70. package/lib/module/composition-bar/CompositionBar.js +6 -1
  71. package/lib/module/composition-bar/CompositionBar.js.map +1 -1
  72. package/lib/module/connection-dots/ConnectionDots.web.js +2 -6
  73. package/lib/module/connection-dots/ConnectionDots.web.js.map +1 -1
  74. package/lib/module/design-tokens/index.js +5 -0
  75. package/lib/module/design-tokens/index.js.map +1 -1
  76. package/lib/module/design-tokens/tokens-json.js +144 -0
  77. package/lib/module/design-tokens/tokens-json.js.map +1 -0
  78. package/lib/module/design-tokens/tokens.json +6681 -0
  79. package/lib/module/dialog/Dialog.web.js +2 -6
  80. package/lib/module/dialog/Dialog.web.js.map +1 -1
  81. package/lib/module/fab/Fab.web.js +2 -6
  82. package/lib/module/fab/Fab.web.js.map +1 -1
  83. package/lib/module/fonts/apply-font-faces.web.js +8 -10
  84. package/lib/module/fonts/apply-font-faces.web.js.map +1 -1
  85. package/lib/module/frosted-icon-button/FrostedIconButton.js +7 -1
  86. package/lib/module/frosted-icon-button/FrostedIconButton.js.map +1 -1
  87. package/lib/module/frosted-icon-button/FrostedIconButton.web.js +2 -6
  88. package/lib/module/frosted-icon-button/FrostedIconButton.web.js.map +1 -1
  89. package/lib/module/input-group/InputGroup.js +6 -4
  90. package/lib/module/input-group/InputGroup.js.map +1 -1
  91. package/lib/module/item/Item.js +23 -5
  92. package/lib/module/item/Item.js.map +1 -1
  93. package/lib/module/loading/SpinnerIcon.web.js +2 -6
  94. package/lib/module/loading/SpinnerIcon.web.js.map +1 -1
  95. package/lib/module/segmented-control/index.js +12 -5
  96. package/lib/module/segmented-control/index.js.map +1 -1
  97. package/lib/module/select/index.js +6 -4
  98. package/lib/module/select/index.js.map +1 -1
  99. package/lib/module/select/index.web.js +6 -4
  100. package/lib/module/select/index.web.js.map +1 -1
  101. package/lib/module/settings-list/SettingsList.js +6 -4
  102. package/lib/module/settings-list/SettingsList.js.map +1 -1
  103. package/lib/module/slider/Slider.js +12 -9
  104. package/lib/module/slider/Slider.js.map +1 -1
  105. package/lib/module/styles/adopt-style-sheet.js +121 -0
  106. package/lib/module/styles/adopt-style-sheet.js.map +1 -0
  107. package/lib/module/switch/Switch.js +9 -5
  108. package/lib/module/switch/Switch.js.map +1 -1
  109. package/lib/module/tab-bar/TabBarBase.js +6 -4
  110. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  111. package/lib/typescript/commonjs/accordion/Accordion.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/button/Button.web.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/checkbox/Checkbox.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/chip/Chip.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/collapsible/Collapsible.d.ts.map +1 -1
  118. package/lib/typescript/commonjs/composition-bar/CompositionBar.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/connection-dots/ConnectionDots.web.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/design-tokens/index.d.ts +2 -0
  121. package/lib/typescript/commonjs/design-tokens/index.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/design-tokens/tokens-json.d.ts +85 -0
  123. package/lib/typescript/commonjs/design-tokens/tokens-json.d.ts.map +1 -0
  124. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  125. package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/fonts/apply-font-faces.web.d.ts +5 -3
  127. package/lib/typescript/commonjs/fonts/apply-font-faces.web.d.ts.map +1 -1
  128. package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.web.d.ts.map +1 -1
  130. package/lib/typescript/commonjs/input-group/InputGroup.d.ts.map +1 -1
  131. package/lib/typescript/commonjs/item/Item.d.ts.map +1 -1
  132. package/lib/typescript/commonjs/loading/SpinnerIcon.web.d.ts.map +1 -1
  133. package/lib/typescript/commonjs/segmented-control/index.d.ts.map +1 -1
  134. package/lib/typescript/commonjs/select/index.d.ts.map +1 -1
  135. package/lib/typescript/commonjs/select/index.web.d.ts.map +1 -1
  136. package/lib/typescript/commonjs/settings-list/SettingsList.d.ts.map +1 -1
  137. package/lib/typescript/commonjs/slider/Slider.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/styles/adopt-style-sheet.d.ts +22 -0
  139. package/lib/typescript/commonjs/styles/adopt-style-sheet.d.ts.map +1 -0
  140. package/lib/typescript/commonjs/switch/Switch.d.ts.map +1 -1
  141. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  142. package/lib/typescript/module/accordion/Accordion.d.ts.map +1 -1
  143. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  144. package/lib/typescript/module/button/Button.d.ts.map +1 -1
  145. package/lib/typescript/module/button/Button.web.d.ts.map +1 -1
  146. package/lib/typescript/module/checkbox/Checkbox.d.ts.map +1 -1
  147. package/lib/typescript/module/chip/Chip.d.ts.map +1 -1
  148. package/lib/typescript/module/collapsible/Collapsible.d.ts.map +1 -1
  149. package/lib/typescript/module/composition-bar/CompositionBar.d.ts.map +1 -1
  150. package/lib/typescript/module/connection-dots/ConnectionDots.web.d.ts.map +1 -1
  151. package/lib/typescript/module/design-tokens/index.d.ts +2 -0
  152. package/lib/typescript/module/design-tokens/index.d.ts.map +1 -1
  153. package/lib/typescript/module/design-tokens/tokens-json.d.ts +85 -0
  154. package/lib/typescript/module/design-tokens/tokens-json.d.ts.map +1 -0
  155. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  156. package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
  157. package/lib/typescript/module/fonts/apply-font-faces.web.d.ts +5 -3
  158. package/lib/typescript/module/fonts/apply-font-faces.web.d.ts.map +1 -1
  159. package/lib/typescript/module/frosted-icon-button/FrostedIconButton.d.ts.map +1 -1
  160. package/lib/typescript/module/frosted-icon-button/FrostedIconButton.web.d.ts.map +1 -1
  161. package/lib/typescript/module/input-group/InputGroup.d.ts.map +1 -1
  162. package/lib/typescript/module/item/Item.d.ts.map +1 -1
  163. package/lib/typescript/module/loading/SpinnerIcon.web.d.ts.map +1 -1
  164. package/lib/typescript/module/segmented-control/index.d.ts.map +1 -1
  165. package/lib/typescript/module/select/index.d.ts.map +1 -1
  166. package/lib/typescript/module/select/index.web.d.ts.map +1 -1
  167. package/lib/typescript/module/settings-list/SettingsList.d.ts.map +1 -1
  168. package/lib/typescript/module/slider/Slider.d.ts.map +1 -1
  169. package/lib/typescript/module/styles/adopt-style-sheet.d.ts +22 -0
  170. package/lib/typescript/module/styles/adopt-style-sheet.d.ts.map +1 -0
  171. package/lib/typescript/module/switch/Switch.d.ts.map +1 -1
  172. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  173. package/package.json +4 -2
  174. package/src/__tests__/Button.test.tsx +11 -3
  175. package/src/__tests__/InputGroup.test.tsx +4 -1
  176. package/src/__tests__/Item.test.tsx +5 -1
  177. package/src/__tests__/Slider.test.tsx +11 -2
  178. package/src/__tests__/TabBar.test.tsx +9 -1
  179. package/src/__tests__/TabBarNoSelection.test.tsx +3 -1
  180. package/src/__tests__/adopt-style-sheet.node.test.ts +21 -0
  181. package/src/__tests__/adopt-style-sheet.test.ts +165 -0
  182. package/src/__tests__/apply-font-faces.test.ts +32 -0
  183. package/src/__tests__/aria-state-web.test.tsx +466 -0
  184. package/src/__tests__/design-tokens-json.test.ts +112 -0
  185. package/src/__tests__/exports-map-contract.test.ts +5 -1
  186. package/src/__tests__/support/constructed-style-sheets.ts +68 -0
  187. package/src/accordion/Accordion.tsx +5 -1
  188. package/src/bottom-sheet/BottomSheetBase.tsx +10 -14
  189. package/src/button/Button.tsx +6 -1
  190. package/src/button/Button.web.tsx +2 -6
  191. package/src/checkbox/Checkbox.tsx +8 -1
  192. package/src/chip/Chip.tsx +8 -0
  193. package/src/collapsible/Collapsible.tsx +3 -1
  194. package/src/composition-bar/CompositionBar.tsx +4 -0
  195. package/src/connection-dots/ConnectionDots.web.tsx +2 -6
  196. package/src/design-tokens/index.ts +16 -0
  197. package/src/design-tokens/tokens-json.ts +214 -0
  198. package/src/design-tokens/tokens.json +6681 -0
  199. package/src/dialog/Dialog.web.tsx +2 -6
  200. package/src/fab/Fab.web.tsx +2 -6
  201. package/src/fonts/apply-font-faces.web.ts +11 -11
  202. package/src/frosted-icon-button/FrostedIconButton.tsx +5 -0
  203. package/src/frosted-icon-button/FrostedIconButton.web.tsx +2 -6
  204. package/src/input-group/InputGroup.tsx +4 -1
  205. package/src/item/Item.tsx +24 -2
  206. package/src/loading/SpinnerIcon.web.tsx +2 -6
  207. package/src/segmented-control/index.tsx +8 -1
  208. package/src/select/index.tsx +4 -1
  209. package/src/select/index.web.tsx +4 -1
  210. package/src/settings-list/SettingsList.tsx +4 -1
  211. package/src/slider/Slider.tsx +10 -2
  212. package/src/styles/adopt-style-sheet.ts +131 -0
  213. package/src/switch/Switch.tsx +7 -1
  214. 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
+ });
@@ -0,0 +1,112 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import {
5
+ bloomDesignTokens,
6
+ renderBloomTokensJson,
7
+ resolvedColorToHex,
8
+ type PresetGroup,
9
+ } from '../design-tokens/tokens-json';
10
+ import { APP_COLOR_NAMES, APP_COLOR_PRESETS } from '../theme/color-presets';
11
+ import { CANONICAL_TOKENS, getResolvedTokens } from '../theme/token-registry';
12
+
13
+ const TOKENS_PATH = join(__dirname, '..', 'design-tokens', 'tokens.json');
14
+
15
+ describe('the shipped tokens.json is generated, not maintained', () => {
16
+ it('is byte-identical to a fresh render', () => {
17
+ // The whole guarantee: a token changed in the engine and not regenerated
18
+ // fails here rather than shipping a JSON that describes a palette Bloom no
19
+ // longer paints.
20
+ expect(readFileSync(TOKENS_PATH, 'utf8')).toBe(renderBloomTokensJson());
21
+ });
22
+
23
+ it('says so in its own $description', () => {
24
+ expect(bloomDesignTokens().$description).toContain('AUTO-GENERATED');
25
+ });
26
+ });
27
+
28
+ describe('tokens.json shape contract', () => {
29
+ /* Astro codegens C++ SkColor tables from these paths, so the shape is a
30
+ * published interface: new groups may be added, existing paths may not move. */
31
+ const tokens = bloomDesignTokens();
32
+
33
+ it('covers every preset Bloom ships, and nothing else', () => {
34
+ const presets = Object.keys(tokens.color).filter((key) => !key.startsWith('$'));
35
+ expect(presets.sort()).toEqual([...APP_COLOR_NAMES].sort());
36
+ });
37
+
38
+ it('carries both schemes and every canonical token, for every preset', () => {
39
+ for (const name of APP_COLOR_NAMES) {
40
+ const preset = tokens.color[name] as PresetGroup;
41
+ for (const scheme of ['light', 'dark'] as const) {
42
+ expect(Object.keys(preset[scheme]).sort()).toEqual([...CANONICAL_TOKENS].sort());
43
+ }
44
+ }
45
+ });
46
+
47
+ it('names tokens as the CSS custom property without the leading --', () => {
48
+ // What makes the mapping mechanical for a consumer. A token renamed on one
49
+ // side only would break it silently.
50
+ const preset = tokens.color.oxy as PresetGroup;
51
+ for (const token of Object.keys(preset.light)) {
52
+ expect(token.startsWith('--')).toBe(false);
53
+ expect(getResolvedTokens('oxy', 'light')[`--${token}`]).toBeDefined();
54
+ }
55
+ });
56
+
57
+ it('resolves every colour to an sRGB hex string', () => {
58
+ for (const name of APP_COLOR_NAMES) {
59
+ const preset = tokens.color[name] as PresetGroup;
60
+ for (const scheme of ['light', 'dark'] as const) {
61
+ for (const [token, entry] of Object.entries(preset[scheme])) {
62
+ expect(`${name}.${scheme}.${token} = ${String(entry.$value)}`).toMatch(
63
+ /= #[0-9a-f]{6}([0-9a-f]{2})?$/,
64
+ );
65
+ }
66
+ }
67
+ }
68
+ });
69
+
70
+ it('records each preset’s seed and variant', () => {
71
+ for (const name of APP_COLOR_NAMES) {
72
+ const preset = tokens.color[name] as PresetGroup;
73
+ expect(preset.$extensions['so.oxy.bloom']).toMatchObject({
74
+ seed: APP_COLOR_PRESETS[name].hex,
75
+ variant: APP_COLOR_PRESETS[name].variant,
76
+ });
77
+ }
78
+ });
79
+
80
+ it('emits the numeric scales as px dimensions', () => {
81
+ expect(tokens.spacing['space-8']).toEqual({ $value: '8px' });
82
+ expect(tokens.radius['radius-max']).toEqual({ $value: '9999px' });
83
+ expect(tokens.borderWidth['hairline']).toEqual({ $value: '0.5px' });
84
+ expect(tokens.typography['body']).toMatchObject({
85
+ fontSize: { $value: '15px' },
86
+ fontWeight: { $value: 400 },
87
+ });
88
+ });
89
+ });
90
+
91
+ describe('resolvedColorToHex', () => {
92
+ it('converts the engine’s two output forms', () => {
93
+ expect(resolvedColorToHex('rgb(255 239 253)')).toBe('#ffeffd');
94
+ expect(resolvedColorToHex('rgb(0 0 0)')).toBe('#000000');
95
+ // 0.13 × 255 = 33.15 → 33 → 0x21. An 8-bit alpha is exactly what an SkColor
96
+ // holds, so nothing is lost downstream.
97
+ expect(resolvedColorToHex('rgba(213, 86, 255, 0.13)')).toBe('#d556ff21');
98
+ });
99
+
100
+ it('drops a fully opaque alpha rather than emitting #rrggbbff', () => {
101
+ expect(resolvedColorToHex('rgba(1, 2, 3, 1)')).toBe('#010203');
102
+ });
103
+
104
+ /* The engine emitting a third form — an oklch(), a bare hex, a named colour —
105
+ * would otherwise reach a C++ colour table as an unparseable string. Failing
106
+ * at generation time is the only place anyone would notice. */
107
+ it('throws on anything that is not an rgb()/rgba() colour', () => {
108
+ expect(() => resolvedColorToHex('oklch(0.7 0.1 300)')).toThrow(/not an rgb/);
109
+ expect(() => resolvedColorToHex('#ffeffd')).toThrow(/not an rgb/);
110
+ expect(() => resolvedColorToHex('0.5rem')).toThrow(/not an rgb/);
111
+ });
112
+ });
@@ -37,7 +37,11 @@ const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8')) as {
37
37
  const exportsMap = pkg.exports;
38
38
 
39
39
  /** Non-subpath entries that are legitimately plain strings (static assets). */
40
- const STRING_ENTRIES = ['./design-tokens/theme.css', './package.json'];
40
+ const STRING_ENTRIES = [
41
+ './design-tokens/theme.css',
42
+ './design-tokens/tokens.json',
43
+ './package.json',
44
+ ];
41
45
 
42
46
  /**
43
47
  * Vacuity floor. `SUBPATHS` in the generator is 86 entries today; a traversal
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A spec-shaped stand-in for constructed stylesheets, for the jsdom suites.
3
+ *
4
+ * jsdom 26 ships the `CSSStyleSheet` CONSTRUCTOR but neither `replaceSync` nor
5
+ * `document.adoptedStyleSheets`, so every jsdom test takes Bloom's `<style>`
6
+ * fallback. That makes the whole CSP-safe path — the reason
7
+ * `styles/adopt-style-sheet.ts` exists — invisible to jest unless a test
8
+ * installs the API itself. Without this, a mutation that deleted the adoption
9
+ * branch entirely would leave the suite green.
10
+ *
11
+ * Not collected as a suite: jest's `testMatch` wants `*.test.ts` / `*.spec.ts`.
12
+ */
13
+
14
+ export interface FakeStyleSheet {
15
+ /** The CSS most recently handed to `replaceSync`. */
16
+ cssText: string;
17
+ /** How many times the sheet has been re-parsed, so a test can prove it wasn't. */
18
+ replaceSyncCalls: number;
19
+ replaceSync(css: string): void;
20
+ }
21
+
22
+ export interface ConstructedStyleSheetsHarness {
23
+ /** The document's adopted sheets, read live (the code reassigns the array). */
24
+ adopted(): readonly FakeStyleSheet[];
25
+ /** Drop the API again, restoring jsdom's own `CSSStyleSheet`. */
26
+ uninstall(): void;
27
+ }
28
+
29
+ export function installConstructedStyleSheets(): ConstructedStyleSheetsHarness {
30
+ class FakeCSSStyleSheet implements FakeStyleSheet {
31
+ cssText = '';
32
+ replaceSyncCalls = 0;
33
+
34
+ replaceSync(css: string): void {
35
+ this.cssText = css;
36
+ this.replaceSyncCalls += 1;
37
+ }
38
+ }
39
+
40
+ const previousConstructor = Object.getOwnPropertyDescriptor(
41
+ globalThis,
42
+ 'CSSStyleSheet',
43
+ );
44
+
45
+ Object.defineProperty(globalThis, 'CSSStyleSheet', {
46
+ value: FakeCSSStyleSheet,
47
+ writable: true,
48
+ configurable: true,
49
+ });
50
+ Object.defineProperty(document, 'adoptedStyleSheets', {
51
+ value: [],
52
+ writable: true,
53
+ configurable: true,
54
+ });
55
+
56
+ return {
57
+ adopted: () =>
58
+ document.adoptedStyleSheets as unknown as readonly FakeStyleSheet[],
59
+ uninstall: () => {
60
+ Reflect.deleteProperty(document, 'adoptedStyleSheets');
61
+ if (previousConstructor) {
62
+ Object.defineProperty(globalThis, 'CSSStyleSheet', previousConstructor);
63
+ } else {
64
+ Reflect.deleteProperty(globalThis, 'CSSStyleSheet');
65
+ }
66
+ },
67
+ };
68
+ }
@@ -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 }}>