@oxy.so/bloom 2.12.4 → 3.0.1

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 (285) hide show
  1. package/README.md +4 -4
  2. package/docs/button-group.mdx +46 -4
  3. package/docs/composition.mdx +159 -0
  4. package/docs/field.mdx +39 -1
  5. package/docs/glass.mdx +61 -0
  6. package/docs/layout.mdx +72 -4
  7. package/docs/migrating-to-3.0.mdx +169 -0
  8. package/docs/page-header.mdx +237 -36
  9. package/lib/commonjs/button/Button.js +2 -0
  10. package/lib/commonjs/button/Button.js.map +1 -1
  11. package/lib/commonjs/button-group/ButtonGroup.js +124 -19
  12. package/lib/commonjs/button-group/ButtonGroup.js.map +1 -1
  13. package/lib/commonjs/control-surface/ControlSurface.js +46 -0
  14. package/lib/commonjs/control-surface/ControlSurface.js.map +1 -0
  15. package/lib/commonjs/control-surface/context.js +84 -0
  16. package/lib/commonjs/control-surface/context.js.map +1 -0
  17. package/lib/commonjs/control-surface/index.js +26 -0
  18. package/lib/commonjs/control-surface/index.js.map +1 -0
  19. package/lib/commonjs/control-surface/types.js +6 -0
  20. package/lib/commonjs/control-surface/types.js.map +1 -0
  21. package/lib/commonjs/field/Field.js +71 -26
  22. package/lib/commonjs/field/Field.js.map +1 -1
  23. package/lib/commonjs/field/context.js +80 -0
  24. package/lib/commonjs/field/context.js.map +1 -0
  25. package/lib/commonjs/field/index.js +7 -0
  26. package/lib/commonjs/field/index.js.map +1 -1
  27. package/lib/commonjs/floating/TriggerSlot.js +32 -0
  28. package/lib/commonjs/floating/TriggerSlot.js.map +1 -1
  29. package/lib/commonjs/glass/GlassIsland.js +107 -0
  30. package/lib/commonjs/glass/GlassIsland.js.map +1 -0
  31. package/lib/commonjs/glass/GlassSurface.js +4 -1
  32. package/lib/commonjs/glass/GlassSurface.js.map +1 -1
  33. package/lib/commonjs/glass/index.js +7 -0
  34. package/lib/commonjs/glass/index.js.map +1 -1
  35. package/lib/commonjs/index.js +564 -517
  36. package/lib/commonjs/index.js.map +1 -1
  37. package/lib/commonjs/index.web.js +564 -517
  38. package/lib/commonjs/index.web.js.map +1 -1
  39. package/lib/commonjs/label/Label.js +4 -1
  40. package/lib/commonjs/label/Label.js.map +1 -1
  41. package/lib/commonjs/layout/bottom-edge.js +3 -42
  42. package/lib/commonjs/layout/bottom-edge.js.map +1 -1
  43. package/lib/commonjs/layout/edge-store.js +65 -0
  44. package/lib/commonjs/layout/edge-store.js.map +1 -0
  45. package/lib/commonjs/layout/index.js +32 -0
  46. package/lib/commonjs/layout/index.js.map +1 -1
  47. package/lib/commonjs/layout/scroll-offset.js +83 -0
  48. package/lib/commonjs/layout/scroll-offset.js.map +1 -0
  49. package/lib/commonjs/layout/top-edge.js +109 -0
  50. package/lib/commonjs/layout/top-edge.js.map +1 -0
  51. package/lib/commonjs/page-header/EdgeScrim.js +136 -0
  52. package/lib/commonjs/page-header/EdgeScrim.js.map +1 -0
  53. package/lib/commonjs/page-header/PageHeader.js +236 -61
  54. package/lib/commonjs/page-header/PageHeader.js.map +1 -1
  55. package/lib/commonjs/provider/BloomProvider.js +5 -2
  56. package/lib/commonjs/provider/BloomProvider.js.map +1 -1
  57. package/lib/commonjs/social-button/OxyMark.js +2 -2
  58. package/lib/commonjs/social-button/OxyMark.js.map +1 -1
  59. package/lib/commonjs/social-button/SocialButton.js +2 -2
  60. package/lib/commonjs/social-button/SocialButton.js.map +1 -1
  61. package/lib/commonjs/styles/svg-id.js +29 -0
  62. package/lib/commonjs/styles/svg-id.js.map +1 -0
  63. package/lib/commonjs/text-field/TextField.js +39 -6
  64. package/lib/commonjs/text-field/TextField.js.map +1 -1
  65. package/lib/commonjs/theme/glass-colors.js +81 -1
  66. package/lib/commonjs/theme/glass-colors.js.map +1 -1
  67. package/lib/module/button/Button.js +2 -0
  68. package/lib/module/button/Button.js.map +1 -1
  69. package/lib/module/button-group/ButtonGroup.js +124 -19
  70. package/lib/module/button-group/ButtonGroup.js.map +1 -1
  71. package/lib/module/control-surface/ControlSurface.js +41 -0
  72. package/lib/module/control-surface/ControlSurface.js.map +1 -0
  73. package/lib/module/control-surface/context.js +77 -0
  74. package/lib/module/control-surface/context.js.map +1 -0
  75. package/lib/module/control-surface/index.js +5 -0
  76. package/lib/module/control-surface/index.js.map +1 -0
  77. package/lib/module/control-surface/types.js +4 -0
  78. package/lib/module/control-surface/types.js.map +1 -0
  79. package/lib/module/field/Field.js +72 -27
  80. package/lib/module/field/Field.js.map +1 -1
  81. package/lib/module/field/context.js +74 -0
  82. package/lib/module/field/context.js.map +1 -0
  83. package/lib/module/field/index.js +1 -0
  84. package/lib/module/field/index.js.map +1 -1
  85. package/lib/module/floating/TriggerSlot.js +33 -1
  86. package/lib/module/floating/TriggerSlot.js.map +1 -1
  87. package/lib/module/glass/GlassIsland.js +102 -0
  88. package/lib/module/glass/GlassIsland.js.map +1 -0
  89. package/lib/module/glass/GlassSurface.js +5 -2
  90. package/lib/module/glass/GlassSurface.js.map +1 -1
  91. package/lib/module/glass/index.js +1 -0
  92. package/lib/module/glass/index.js.map +1 -1
  93. package/lib/module/index.js +2 -1
  94. package/lib/module/index.js.map +1 -1
  95. package/lib/module/index.web.js +2 -1
  96. package/lib/module/index.web.js.map +1 -1
  97. package/lib/module/label/Label.js +4 -1
  98. package/lib/module/label/Label.js.map +1 -1
  99. package/lib/module/layout/bottom-edge.js +2 -41
  100. package/lib/module/layout/bottom-edge.js.map +1 -1
  101. package/lib/module/layout/edge-store.js +58 -0
  102. package/lib/module/layout/edge-store.js.map +1 -0
  103. package/lib/module/layout/index.js +8 -3
  104. package/lib/module/layout/index.js.map +1 -1
  105. package/lib/module/layout/scroll-offset.js +77 -0
  106. package/lib/module/layout/scroll-offset.js.map +1 -0
  107. package/lib/module/layout/top-edge.js +102 -0
  108. package/lib/module/layout/top-edge.js.map +1 -0
  109. package/lib/module/page-header/EdgeScrim.js +132 -0
  110. package/lib/module/page-header/EdgeScrim.js.map +1 -0
  111. package/lib/module/page-header/PageHeader.js +233 -58
  112. package/lib/module/page-header/PageHeader.js.map +1 -1
  113. package/lib/module/provider/BloomProvider.js +5 -2
  114. package/lib/module/provider/BloomProvider.js.map +1 -1
  115. package/lib/module/social-button/OxyMark.js +1 -1
  116. package/lib/module/social-button/OxyMark.js.map +1 -1
  117. package/lib/module/social-button/SocialButton.js +1 -1
  118. package/lib/module/social-button/SocialButton.js.map +1 -1
  119. package/lib/module/styles/svg-id.js +26 -0
  120. package/lib/module/styles/svg-id.js.map +1 -0
  121. package/lib/module/text-field/TextField.js +39 -6
  122. package/lib/module/text-field/TextField.js.map +1 -1
  123. package/lib/module/theme/glass-colors.js +79 -0
  124. package/lib/module/theme/glass-colors.js.map +1 -1
  125. package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/button-group/ButtonGroup.d.ts.map +1 -1
  127. package/lib/typescript/commonjs/button-group/types.d.ts +61 -5
  128. package/lib/typescript/commonjs/button-group/types.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/control-surface/ControlSurface.d.ts +4 -0
  130. package/lib/typescript/commonjs/control-surface/ControlSurface.d.ts.map +1 -0
  131. package/lib/typescript/commonjs/control-surface/context.d.ts +21 -0
  132. package/lib/typescript/commonjs/control-surface/context.d.ts.map +1 -0
  133. package/lib/typescript/commonjs/control-surface/index.d.ts +4 -0
  134. package/lib/typescript/commonjs/control-surface/index.d.ts.map +1 -0
  135. package/lib/typescript/commonjs/control-surface/types.d.ts +37 -0
  136. package/lib/typescript/commonjs/control-surface/types.d.ts.map +1 -0
  137. package/lib/typescript/commonjs/field/Field.d.ts +1 -1
  138. package/lib/typescript/commonjs/field/Field.d.ts.map +1 -1
  139. package/lib/typescript/commonjs/field/context.d.ts +55 -0
  140. package/lib/typescript/commonjs/field/context.d.ts.map +1 -0
  141. package/lib/typescript/commonjs/field/index.d.ts +2 -0
  142. package/lib/typescript/commonjs/field/index.d.ts.map +1 -1
  143. package/lib/typescript/commonjs/field/types.d.ts +22 -3
  144. package/lib/typescript/commonjs/field/types.d.ts.map +1 -1
  145. package/lib/typescript/commonjs/floating/TriggerSlot.d.ts.map +1 -1
  146. package/lib/typescript/commonjs/glass/GlassIsland.d.ts +4 -0
  147. package/lib/typescript/commonjs/glass/GlassIsland.d.ts.map +1 -0
  148. package/lib/typescript/commonjs/glass/GlassSurface.d.ts.map +1 -1
  149. package/lib/typescript/commonjs/glass/index.d.ts +2 -1
  150. package/lib/typescript/commonjs/glass/index.d.ts.map +1 -1
  151. package/lib/typescript/commonjs/glass/types.d.ts +43 -0
  152. package/lib/typescript/commonjs/glass/types.d.ts.map +1 -1
  153. package/lib/typescript/commonjs/index.d.ts +2 -1
  154. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  155. package/lib/typescript/commonjs/index.web.d.ts +2 -1
  156. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  157. package/lib/typescript/commonjs/label/Label.d.ts.map +1 -1
  158. package/lib/typescript/commonjs/label/types.d.ts +7 -1
  159. package/lib/typescript/commonjs/label/types.d.ts.map +1 -1
  160. package/lib/typescript/commonjs/layout/bottom-edge.d.ts.map +1 -1
  161. package/lib/typescript/commonjs/layout/edge-store.d.ts +31 -0
  162. package/lib/typescript/commonjs/layout/edge-store.d.ts.map +1 -0
  163. package/lib/typescript/commonjs/layout/index.d.ts +8 -3
  164. package/lib/typescript/commonjs/layout/index.d.ts.map +1 -1
  165. package/lib/typescript/commonjs/layout/scroll-offset.d.ts +16 -0
  166. package/lib/typescript/commonjs/layout/scroll-offset.d.ts.map +1 -0
  167. package/lib/typescript/commonjs/layout/top-edge.d.ts +77 -0
  168. package/lib/typescript/commonjs/layout/top-edge.d.ts.map +1 -0
  169. package/lib/typescript/commonjs/page-header/EdgeScrim.d.ts +95 -0
  170. package/lib/typescript/commonjs/page-header/EdgeScrim.d.ts.map +1 -0
  171. package/lib/typescript/commonjs/page-header/PageHeader.d.ts +1 -1
  172. package/lib/typescript/commonjs/page-header/PageHeader.d.ts.map +1 -1
  173. package/lib/typescript/commonjs/page-header/index.d.ts +1 -1
  174. package/lib/typescript/commonjs/page-header/index.d.ts.map +1 -1
  175. package/lib/typescript/commonjs/page-header/types.d.ts +97 -10
  176. package/lib/typescript/commonjs/page-header/types.d.ts.map +1 -1
  177. package/lib/typescript/commonjs/provider/BloomProvider.d.ts.map +1 -1
  178. package/lib/typescript/commonjs/styles/svg-id.d.ts +20 -0
  179. package/lib/typescript/commonjs/styles/svg-id.d.ts.map +1 -0
  180. package/lib/typescript/commonjs/text-field/TextField.d.ts.map +1 -1
  181. package/lib/typescript/commonjs/theme/glass-colors.d.ts +67 -0
  182. package/lib/typescript/commonjs/theme/glass-colors.d.ts.map +1 -1
  183. package/lib/typescript/module/button/Button.d.ts.map +1 -1
  184. package/lib/typescript/module/button-group/ButtonGroup.d.ts.map +1 -1
  185. package/lib/typescript/module/button-group/types.d.ts +61 -5
  186. package/lib/typescript/module/button-group/types.d.ts.map +1 -1
  187. package/lib/typescript/module/control-surface/ControlSurface.d.ts +4 -0
  188. package/lib/typescript/module/control-surface/ControlSurface.d.ts.map +1 -0
  189. package/lib/typescript/module/control-surface/context.d.ts +21 -0
  190. package/lib/typescript/module/control-surface/context.d.ts.map +1 -0
  191. package/lib/typescript/module/control-surface/index.d.ts +4 -0
  192. package/lib/typescript/module/control-surface/index.d.ts.map +1 -0
  193. package/lib/typescript/module/control-surface/types.d.ts +37 -0
  194. package/lib/typescript/module/control-surface/types.d.ts.map +1 -0
  195. package/lib/typescript/module/field/Field.d.ts +1 -1
  196. package/lib/typescript/module/field/Field.d.ts.map +1 -1
  197. package/lib/typescript/module/field/context.d.ts +55 -0
  198. package/lib/typescript/module/field/context.d.ts.map +1 -0
  199. package/lib/typescript/module/field/index.d.ts +2 -0
  200. package/lib/typescript/module/field/index.d.ts.map +1 -1
  201. package/lib/typescript/module/field/types.d.ts +22 -3
  202. package/lib/typescript/module/field/types.d.ts.map +1 -1
  203. package/lib/typescript/module/floating/TriggerSlot.d.ts.map +1 -1
  204. package/lib/typescript/module/glass/GlassIsland.d.ts +4 -0
  205. package/lib/typescript/module/glass/GlassIsland.d.ts.map +1 -0
  206. package/lib/typescript/module/glass/GlassSurface.d.ts.map +1 -1
  207. package/lib/typescript/module/glass/index.d.ts +2 -1
  208. package/lib/typescript/module/glass/index.d.ts.map +1 -1
  209. package/lib/typescript/module/glass/types.d.ts +43 -0
  210. package/lib/typescript/module/glass/types.d.ts.map +1 -1
  211. package/lib/typescript/module/index.d.ts +2 -1
  212. package/lib/typescript/module/index.d.ts.map +1 -1
  213. package/lib/typescript/module/index.web.d.ts +2 -1
  214. package/lib/typescript/module/index.web.d.ts.map +1 -1
  215. package/lib/typescript/module/label/Label.d.ts.map +1 -1
  216. package/lib/typescript/module/label/types.d.ts +7 -1
  217. package/lib/typescript/module/label/types.d.ts.map +1 -1
  218. package/lib/typescript/module/layout/bottom-edge.d.ts.map +1 -1
  219. package/lib/typescript/module/layout/edge-store.d.ts +31 -0
  220. package/lib/typescript/module/layout/edge-store.d.ts.map +1 -0
  221. package/lib/typescript/module/layout/index.d.ts +8 -3
  222. package/lib/typescript/module/layout/index.d.ts.map +1 -1
  223. package/lib/typescript/module/layout/scroll-offset.d.ts +16 -0
  224. package/lib/typescript/module/layout/scroll-offset.d.ts.map +1 -0
  225. package/lib/typescript/module/layout/top-edge.d.ts +77 -0
  226. package/lib/typescript/module/layout/top-edge.d.ts.map +1 -0
  227. package/lib/typescript/module/page-header/EdgeScrim.d.ts +95 -0
  228. package/lib/typescript/module/page-header/EdgeScrim.d.ts.map +1 -0
  229. package/lib/typescript/module/page-header/PageHeader.d.ts +1 -1
  230. package/lib/typescript/module/page-header/PageHeader.d.ts.map +1 -1
  231. package/lib/typescript/module/page-header/index.d.ts +1 -1
  232. package/lib/typescript/module/page-header/index.d.ts.map +1 -1
  233. package/lib/typescript/module/page-header/types.d.ts +97 -10
  234. package/lib/typescript/module/page-header/types.d.ts.map +1 -1
  235. package/lib/typescript/module/provider/BloomProvider.d.ts.map +1 -1
  236. package/lib/typescript/module/styles/svg-id.d.ts +20 -0
  237. package/lib/typescript/module/styles/svg-id.d.ts.map +1 -0
  238. package/lib/typescript/module/text-field/TextField.d.ts.map +1 -1
  239. package/lib/typescript/module/theme/glass-colors.d.ts +67 -0
  240. package/lib/typescript/module/theme/glass-colors.d.ts.map +1 -1
  241. package/package.json +15 -1
  242. package/src/button/Button.tsx +10 -1
  243. package/src/button-group/ButtonGroup.tsx +146 -19
  244. package/src/button-group/types.ts +68 -5
  245. package/src/control-surface/ControlSurface.tsx +41 -0
  246. package/src/control-surface/context.ts +81 -0
  247. package/src/control-surface/index.ts +4 -0
  248. package/src/control-surface/types.ts +40 -0
  249. package/src/field/Field.tsx +84 -27
  250. package/src/field/context.ts +110 -0
  251. package/src/field/index.ts +3 -0
  252. package/src/field/types.ts +22 -3
  253. package/src/floating/TriggerSlot.tsx +35 -1
  254. package/src/glass/GlassIsland.tsx +109 -0
  255. package/src/glass/GlassSurface.tsx +26 -2
  256. package/src/glass/index.ts +3 -0
  257. package/src/glass/types.ts +45 -0
  258. package/src/index.ts +6 -0
  259. package/src/index.web.ts +6 -0
  260. package/src/label/Label.tsx +3 -1
  261. package/src/label/types.ts +7 -1
  262. package/src/layout/bottom-edge.tsx +3 -56
  263. package/src/layout/edge-store.ts +69 -0
  264. package/src/layout/index.ts +8 -3
  265. package/src/layout/scroll-offset.ts +76 -0
  266. package/src/layout/top-edge.tsx +109 -0
  267. package/src/page-header/EdgeScrim.tsx +118 -0
  268. package/src/page-header/PageHeader.tsx +253 -61
  269. package/src/page-header/index.ts +9 -1
  270. package/src/page-header/types.ts +103 -10
  271. package/src/provider/BloomProvider.tsx +12 -1
  272. package/src/social-button/OxyMark.tsx +1 -1
  273. package/src/social-button/SocialButton.tsx +1 -1
  274. package/src/styles/svg-id.ts +23 -0
  275. package/src/text-field/TextField.tsx +44 -5
  276. package/src/theme/glass-colors.ts +80 -0
  277. package/lib/commonjs/social-button/use-svg-id.js +0 -16
  278. package/lib/commonjs/social-button/use-svg-id.js.map +0 -1
  279. package/lib/module/social-button/use-svg-id.js +0 -13
  280. package/lib/module/social-button/use-svg-id.js.map +0 -1
  281. package/lib/typescript/commonjs/social-button/use-svg-id.d.ts +0 -7
  282. package/lib/typescript/commonjs/social-button/use-svg-id.d.ts.map +0 -1
  283. package/lib/typescript/module/social-button/use-svg-id.d.ts +0 -7
  284. package/lib/typescript/module/social-button/use-svg-id.d.ts.map +0 -1
  285. package/src/social-button/use-svg-id.ts +0 -10
@@ -1,10 +1,14 @@
1
1
  import React, { Children, createContext, Fragment, isValidElement, memo, useContext, useMemo } from 'react';
2
2
  import { Platform, Pressable, View, type TextStyle } from 'react-native';
3
3
 
4
+ import { useInheritedControl } from '../control-surface';
5
+ import { GlassIsland } from '../glass';
4
6
  import { useTheme } from '../theme/use-theme';
5
7
  import { Text } from '../typography/Typography';
6
8
  import { useInteractionState } from '../hooks/use-interaction-state';
9
+ import { resolveSurfaceLevel } from '../styles/surface-levels';
7
10
  import { NOT_DISABLED, interactiveWebCss, useInteractiveWebCss } from '../styles/interactive-web-css';
11
+ import { withAlpha } from '../theme/color-utils';
8
12
  import type { WebCssStyle } from '../styles/web-view-style';
9
13
  import {
10
14
  BUTTON_RADIUS,
@@ -13,14 +17,30 @@ import {
13
17
  mixColor,
14
18
  resolveButtonRamps,
15
19
  } from '../button/shared';
20
+ import type { ControlMaterial } from '../control-surface/types';
16
21
  import type { Theme } from '../theme/types';
17
22
  import type { ButtonGroupItemProps, ButtonGroupProps, ButtonGroupSize } from './types';
18
23
 
19
24
  /**
20
- * A group of secondary-style buttons fused into one control — one bordered,
21
- * shadowed container, items divided by 1px hairlines, only the outer ends
22
- * rounded. Colours come from the same Bloom-derived ramps as `Button`
23
- * (`button/shared.ts`), and like `Button` the container is a full pill.
25
+ * A group of buttons the consumer has declared as ONE control.
26
+ *
27
+ * ── TWO MATERIALS, ONE GROUP ────────────────────────────────────────────────
28
+ *
29
+ * `solid` is the original: one bordered, shadowed container, items divided by
30
+ * 1px hairlines, only the outer ends rounded, colours from the same
31
+ * Bloom-derived ramps as `Button` (`button/shared.ts`).
32
+ *
33
+ * `glass` is the same group as a floating ISLAND — one translucent pane
34
+ * (`glass/GlassIsland`) that the items sit FLUSH on. The group owns the
35
+ * material and the items paint no fill of their own at rest; a second fill
36
+ * under a translucent pane is the thing that makes a row of controls read as a
37
+ * row of cards rather than as one island, and a second BLUR is not available at
38
+ * all on Android.
39
+ *
40
+ * Neither is a decoration a caller has to ask for by name. The variant is
41
+ * INHERITED from the nearest `ControlSurface`, so `<ButtonGroup>` inside a
42
+ * floating `PageHeader` is glass and the same group inside a card is solid,
43
+ * with no prop either way. An explicit `variant` still wins.
24
44
  *
25
45
  * medium small
26
46
  * item h 34 30 (plus the container's 1px border = 36 / 32)
@@ -75,6 +95,43 @@ function resolveGroupPalette(theme: Theme): GroupPalette {
75
95
  };
76
96
  }
77
97
 
98
+ /**
99
+ * The items' paint on a GLASS island.
100
+ *
101
+ * Every state that is not "at rest" is an alpha of the theme's own `text` — an
102
+ * ink wash, not a fill. That is the one recipe that works on a pane whose
103
+ * composited colour Bloom cannot predict: a fixed neutral tuned against the
104
+ * island's own fill goes invisible the moment a photograph behind it moves the
105
+ * pane 40 levels, while a wash of the label's colour keeps the same relation to
106
+ * the label at every composite.
107
+ *
108
+ * `transparent` at rest is load-bearing rather than tidy. It is what makes the
109
+ * island ONE pane: give the items a fill and the group is a row of chips inside
110
+ * a capsule, which is the shape this variant exists to stop.
111
+ *
112
+ * The disabled label is read off the island's own fill with `surfaceTextOn`
113
+ * rather than picked from the ramp, because a ramp stop measured against the
114
+ * page is not measured against this surface.
115
+ */
116
+ function resolveGlassItemPalette(theme: Theme): GroupPalette {
117
+ const { accent } = resolveButtonRamps(theme);
118
+ const level = resolveSurfaceLevel(theme, 1);
119
+ const ink = theme.colors.text;
120
+ const wash = theme.isDark ? { hover: 0.12, active: 0.2 } : { hover: 0.08, active: 0.14 };
121
+ return {
122
+ // The island owns the box: the group draws neither border nor divider.
123
+ groupBorder: 'transparent',
124
+ divider: withAlpha(ink, theme.isDark ? 0.16 : 0.12),
125
+ background: 'transparent',
126
+ hover: withAlpha(ink, wash.hover),
127
+ active: withAlpha(ink, wash.active),
128
+ disabledBackground: 'transparent',
129
+ foreground: ink,
130
+ disabledForeground: level.textGraphical,
131
+ ring: accent[500],
132
+ };
133
+ }
134
+
78
135
  // ---------------------------------------------------------------------------
79
136
  // Keyboard focus on web — an inset `ring-2`. Items are react-native-web
80
137
  // `Pressable`s, so the rules hang off a `dataSet` attribute (a class never
@@ -110,10 +167,18 @@ ${SELECTOR}${NOT_DISABLED}:focus-visible {
110
167
 
111
168
  const IS_WEB = Platform.OS === 'web';
112
169
 
113
- const GroupContext = createContext<{ size: ButtonGroupSize; palette: GroupPalette } | null>(null);
170
+ interface GroupContextValue {
171
+ size: ButtonGroupSize;
172
+ material: ControlMaterial;
173
+ palette: GroupPalette;
174
+ }
175
+
176
+ const GroupContext = createContext<GroupContextValue | null>(null);
114
177
 
115
178
  const ButtonGroupComponent: React.FC<ButtonGroupProps> = ({
116
- size = 'medium',
179
+ variant,
180
+ size: sizeProp,
181
+ dividers,
117
182
  children,
118
183
  accessibilityLabel,
119
184
  style,
@@ -121,9 +186,48 @@ const ButtonGroupComponent: React.FC<ButtonGroupProps> = ({
121
186
  }) => {
122
187
  const theme = useTheme();
123
188
  useInteractiveWebCss(STYLE_ID, BUTTON_GROUP_CSS);
124
- const palette = useMemo(() => resolveGroupPalette(theme), [theme]);
125
- const context = useMemo(() => ({ size, palette }), [size, palette]);
189
+ const material = useInheritedControl('material', variant, 'solid');
190
+ const size = useInheritedControl('density', sizeProp, 'medium');
191
+ const palette = useMemo(
192
+ () => (material === 'glass' ? resolveGlassItemPalette(theme) : resolveGroupPalette(theme)),
193
+ [material, theme],
194
+ );
195
+ const context = useMemo<GroupContextValue>(
196
+ () => ({ size, material, palette }),
197
+ [size, material, palette],
198
+ );
126
199
  const items = Children.toArray(children).filter(isValidElement);
200
+ const showDividers = dividers ?? material === 'solid';
201
+
202
+ const row = items.map((item, index) => (
203
+ <Fragment key={item.key ?? index}>
204
+ {/* `divide-x`: a hairline BETWEEN items, never at the ends. */}
205
+ {showDividers && index > 0 ? (
206
+ <View style={{ width: 1, backgroundColor: palette.divider }} />
207
+ ) : null}
208
+ {item}
209
+ </Fragment>
210
+ ));
211
+
212
+ if (material === 'glass') {
213
+ return (
214
+ <GroupContext.Provider value={context}>
215
+ {/*
216
+ The island mounts its own `ControlSurface` with `material: 'glass'`,
217
+ so an item rendered through a trigger's `asChild` — outside this
218
+ group's own children, in a caller's tree — still paints flush.
219
+ */}
220
+ <GlassIsland
221
+ role="group"
222
+ accessibilityLabel={accessibilityLabel}
223
+ style={style}
224
+ testID={testID}
225
+ >
226
+ {row}
227
+ </GlassIsland>
228
+ </GroupContext.Provider>
229
+ );
230
+ }
127
231
 
128
232
  return (
129
233
  <GroupContext.Provider value={context}>
@@ -146,13 +250,7 @@ const ButtonGroupComponent: React.FC<ButtonGroupProps> = ({
146
250
  style,
147
251
  ]}
148
252
  >
149
- {items.map((item, index) => (
150
- <Fragment key={item.key ?? index}>
151
- {/* `divide-x`: a hairline BETWEEN items, never at the ends. */}
152
- {index > 0 ? <View style={{ width: 1, backgroundColor: palette.divider }} /> : null}
153
- {item}
154
- </Fragment>
155
- ))}
253
+ {row}
156
254
  </View>
157
255
  </GroupContext.Provider>
158
256
  );
@@ -160,21 +258,42 @@ const ButtonGroupComponent: React.FC<ButtonGroupProps> = ({
160
258
 
161
259
  const ButtonGroupItemComponent: React.FC<ButtonGroupItemProps> = ({
162
260
  onPress,
261
+ onLongPress,
163
262
  children,
164
263
  size: sizeProp,
264
+ variant,
165
265
  selected = false,
166
266
  disabled = false,
167
267
  iconOnly = false,
168
268
  leadingIcon: LeadingIcon,
169
269
  trailingIcon: TrailingIcon,
170
270
  accessibilityLabel,
271
+ accessibilityRole = 'button',
272
+ hitSlop,
273
+ 'aria-expanded': ariaExpanded,
274
+ 'aria-haspopup': ariaHasPopup,
171
275
  style,
172
276
  testID,
173
277
  }) => {
174
278
  const theme = useTheme();
175
279
  const group = useContext(GroupContext);
176
- const palette = useMemo(() => group?.palette ?? resolveGroupPalette(theme), [group, theme]);
177
- const size = sizeProp ?? group?.size ?? 'medium';
280
+ // An item can be rendered OUTSIDE its group's children — an anchored family's
281
+ // `asChild` trigger clones it into the caller's tree — so the material falls
282
+ // back to the inherited `ControlSurface` (which `GlassIsland` mounts) before
283
+ // it falls back to `solid`.
284
+ const inheritedMaterial = useInheritedControl('material', undefined, 'solid');
285
+ const inheritedDensity = useInheritedControl('density', undefined, 'medium');
286
+ const material = variant ?? group?.material ?? inheritedMaterial;
287
+ const palette = useMemo(
288
+ () =>
289
+ group && group.material === material
290
+ ? group.palette
291
+ : material === 'glass'
292
+ ? resolveGlassItemPalette(theme)
293
+ : resolveGroupPalette(theme),
294
+ [group, material, theme],
295
+ );
296
+ const size = sizeProp ?? group?.size ?? inheritedDensity;
178
297
  const geometry = GEOMETRY[size];
179
298
  const { state: hovered, onIn: onHoverIn, onOut: onHoverOut } = useInteractionState();
180
299
  const { state: pressed, onIn: onPressIn, onOut: onPressOut } = useInteractionState();
@@ -198,6 +317,10 @@ const ButtonGroupItemComponent: React.FC<ButtonGroupItemProps> = ({
198
317
  justifyContent: 'center',
199
318
  gap: 4,
200
319
  backgroundColor: background,
320
+ // On glass the island does not clip (a clipping node loses its iOS drop
321
+ // shadow), so the item rounds its OWN highlight. On solid the container
322
+ // clips and a square item is what makes the fused ends read as one pill.
323
+ borderRadius: material === 'glass' ? BUTTON_RADIUS : undefined,
201
324
  '--bloom-button-group-ring': palette.ring,
202
325
  };
203
326
 
@@ -208,17 +331,21 @@ const ButtonGroupItemComponent: React.FC<ButtonGroupItemProps> = ({
208
331
  {...(IS_WEB ? ({ dataSet: { bloomButtonGroupItem: '' } } as Record<string, unknown>) : {})}
209
332
  style={[containerStyle, style]}
210
333
  onPress={disabled ? undefined : onPress}
334
+ onLongPress={disabled ? undefined : onLongPress}
211
335
  onPressIn={disabled ? undefined : onPressIn}
212
336
  onPressOut={disabled ? undefined : onPressOut}
213
337
  onHoverIn={onHoverIn}
214
338
  onHoverOut={onHoverOut}
339
+ hitSlop={hitSlop}
215
340
  disabled={disabled}
216
- accessibilityRole="button"
341
+ accessibilityRole={accessibilityRole}
217
342
  accessibilityLabel={accessibilityLabel}
218
343
  // Both spellings: react-native-web reads only `aria-pressed`, React Native
219
344
  // has no `aria-pressed` and reads `accessibilityState`.
220
- accessibilityState={{ disabled, selected }}
345
+ accessibilityState={{ disabled, selected, expanded: ariaExpanded }}
221
346
  aria-pressed={selected}
347
+ aria-expanded={ariaExpanded}
348
+ aria-haspopup={ariaHasPopup}
222
349
  testID={testID}
223
350
  >
224
351
  {LeadingIcon ? (
@@ -1,27 +1,59 @@
1
- import type { StyleProp, ViewStyle } from 'react-native';
1
+ import type { GestureResponderEvent, StyleProp, ViewStyle } from 'react-native';
2
2
 
3
+ import type { ControlMaterial } from '../control-surface/types';
3
4
  import type { ButtonIconComponent } from '../button/types';
5
+ import type { WebAriaProps } from '../styles/styled-primitives';
4
6
 
5
7
  /** The two group sizes: 34px (`medium`) and 30px (`small`) items. */
6
8
  export type ButtonGroupSize = 'medium' | 'small';
7
9
 
8
10
  export interface ButtonGroupProps {
9
- /** Size for every item that does not set its own. */
11
+ /**
12
+ * The material the group paints: `solid` (an opaque bordered pill, Bloom's
13
+ * default chrome) or `glass` (one translucent island the items sit flush on).
14
+ *
15
+ * Omitted, it is INHERITED from the nearest `ControlSurface` — which is what a
16
+ * `PageHeader`'s action slot mounts — and falls back to `solid`. So a group in
17
+ * a floating header needs no variant written on it, and the same group in a
18
+ * card is solid without one either. Precedence and its limits:
19
+ * `docs/composition.mdx`.
20
+ */
21
+ variant?: ControlMaterial;
22
+ /**
23
+ * Size for every item that does not set its own. Inherited from the nearest
24
+ * `ControlSurface` when omitted, then `medium`.
25
+ */
10
26
  size?: ButtonGroupSize;
11
- /** `ButtonGroupItem` children. Hairlines are drawn between them. */
27
+ /**
28
+ * Whether to draw the 1px hairlines BETWEEN items.
29
+ *
30
+ * Defaults to `true` for `solid` and `false` for `glass`. A divider is how a
31
+ * solid pill says "these are separate controls, fused"; on a translucent
32
+ * island the press highlight already says it, and a hairline over a moving
33
+ * backdrop reads as an artifact of the material.
34
+ */
35
+ dividers?: boolean;
36
+ /** `ButtonGroupItem` children. */
12
37
  children: React.ReactNode;
13
- /** Names the group for assistive tech. */
38
+ /** Names the group. */
14
39
  accessibilityLabel?: string;
15
40
  style?: StyleProp<ViewStyle>;
16
41
  testID?: string;
17
42
  }
18
43
 
19
44
  export interface ButtonGroupItemProps {
20
- onPress?: () => void;
45
+ /**
46
+ * Press handler. Receives the event, so a handler composed onto this item by
47
+ * an anchored family's `asChild` trigger can `preventDefault()` it — see
48
+ * `floating/TriggerSlot.tsx`.
49
+ */
50
+ onPress?: (event: GestureResponderEvent) => void;
21
51
  /** Label. Ignored when `iconOnly`. */
22
52
  children?: React.ReactNode;
23
53
  /** Overrides the group's size. */
24
54
  size?: ButtonGroupSize;
55
+ /** Overrides the group's material. Rarely needed; the group owns it. */
56
+ variant?: ControlMaterial;
25
57
  /** Highlights the item like its hover state and announces it as pressed. */
26
58
  selected?: boolean;
27
59
  disabled?: boolean;
@@ -32,6 +64,37 @@ export interface ButtonGroupItemProps {
32
64
  /** Icon component after the label. */
33
65
  trailingIcon?: ButtonIconComponent;
34
66
  accessibilityLabel?: string;
67
+
68
+ // ── The trigger contract ────────────────────────────────────────────────
69
+ //
70
+ // Declared, not spread. An anchored family's `asChild` trigger merges
71
+ // `TriggerHandleProps` onto its child, and a child whose props are a closed
72
+ // list DROPS the ones it does not declare — silently, since every one of them
73
+ // is optional. Naming them is what makes `<PopoverTrigger asChild>` around an
74
+ // item announce its own expanded state instead of looking like a plain
75
+ // button. Gate: `button-group-trigger.test.tsx`.
76
+
77
+ /** Long-press handler, composed by a `ContextMenu` trigger. */
78
+ onLongPress?: (event: GestureResponderEvent) => void;
79
+ /** Set by an anchored family: whether the surface this opens is showing. */
80
+ 'aria-expanded'?: boolean;
81
+ /** Set by an anchored family: what kind of surface this opens. */
82
+ 'aria-haspopup'?: WebAriaProps['aria-haspopup'];
83
+ /** Set by an anchored family. The item is a `button` either way. */
84
+ accessibilityRole?: 'button';
85
+
86
+ /**
87
+ * Extra touch area around the item, in px.
88
+ *
89
+ * NOT set by the group, and deliberately not defaulted: items in a group are
90
+ * adjacent, so slop on one overlaps its neighbour and the press lands on
91
+ * whichever the platform happens to hit-test first. It is for an item that
92
+ * stands ALONE in its own island — a page header's back capsule, where 4px on
93
+ * each side turns the 36pt capsule into a 44pt target with nothing beside it
94
+ * to steal from.
95
+ */
96
+ hitSlop?: number | { top?: number; bottom?: number; left?: number; right?: number };
97
+
35
98
  style?: StyleProp<ViewStyle>;
36
99
  testID?: string;
37
100
  }
@@ -0,0 +1,41 @@
1
+ import React, { memo, useMemo } from 'react';
2
+
3
+ import { ControlSurfaceProvider, useControlSurface } from './context';
4
+ import type { ControlSurfaceProps } from './types';
5
+
6
+ /**
7
+ * Declare the presentation the controls inside should default to.
8
+ *
9
+ * Renders nothing of its own — no view, no layout node — so it can be dropped
10
+ * anywhere without moving anything. Bloom's own containers (`ButtonGroup`'s
11
+ * glass variant, `PageHeader`'s islands) mount it for you; this is the explicit
12
+ * entry point for an app that wants the same effect around its own controls.
13
+ *
14
+ * ```tsx
15
+ * <ControlSurface material="glass">
16
+ * <ButtonGroup accessibilityLabel="Playback">…</ButtonGroup>
17
+ * </ControlSurface>
18
+ * ```
19
+ *
20
+ * A field left `undefined` INHERITS rather than resetting, so a nested
21
+ * `<ControlSurface density="small">` inside a glass island stays glass. The
22
+ * full precedence rule is in `control-surface/context.ts`.
23
+ */
24
+ const ControlSurfaceComponent: React.FC<ControlSurfaceProps> = ({
25
+ material,
26
+ density,
27
+ children,
28
+ }) => {
29
+ const inherited = useControlSurface();
30
+ const value = useMemo(
31
+ () => ({
32
+ material: material ?? inherited?.material ?? 'solid',
33
+ density: density ?? inherited?.density ?? 'medium',
34
+ }),
35
+ [material, density, inherited?.material, inherited?.density],
36
+ );
37
+ return <ControlSurfaceProvider value={value}>{children}</ControlSurfaceProvider>;
38
+ };
39
+
40
+ export const ControlSurface = memo(ControlSurfaceComponent);
41
+ ControlSurface.displayName = 'ControlSurface';
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The inherited CONTROL PRESENTATION contract — the third of Bloom's visual
3
+ * contexts, beside the theme (`theme/use-theme`) and the surface ladder
4
+ * (`styles/surface-levels`).
5
+ *
6
+ * ## What it is for
7
+ *
8
+ * A container that owns a material has to be able to say so once, rather than
9
+ * have every control inside it repeat the material as a prop. A page header's
10
+ * action island owns its glass; the `ButtonGroup` inside it should not need
11
+ * `variant="glass"` written on it, and the items inside THAT should not need it
12
+ * either. Written as props it is three places to keep in step, and the app is
13
+ * the one that pays when they drift.
14
+ *
15
+ * ## Precedence, in one rule
16
+ *
17
+ * an explicit prop > the nearest container > the component's default
18
+ *
19
+ * {@link useInheritedControl} is that rule, and it is a function rather than a
20
+ * convention so the three consumers cannot each interpret it slightly
21
+ * differently. `undefined` is the only value that defers: passing
22
+ * `material="solid"` inside a glass island paints a solid control, which is a
23
+ * decision the caller is allowed to make.
24
+ *
25
+ * ## What deliberately does NOT travel through it
26
+ *
27
+ * Only VISUAL CONFIGURATION. A constraint — `disabled`, a modal's inertness,
28
+ * the user's reduce-transparency preference — is not a default, and the rule
29
+ * above is exactly wrong for one: a descendant must not be able to re-enable an
30
+ * action its group disabled by passing a prop. Constraints keep their own
31
+ * channels (a group's `disabled` is applied by the group when it renders its
32
+ * items; accessibility preferences are read from the OS at the point of use).
33
+ *
34
+ * Nor does STATE. `checked`, `selected`, a field's value and any business datum
35
+ * are owned by the component that holds them; a visual context that also
36
+ * carried state would make every container a place state could leak from.
37
+ *
38
+ * ## Scope
39
+ *
40
+ * One context, two fields, both of which change rarely (a container sets them
41
+ * once at mount). Scroll offsets, focus, form state and the overlay stack each
42
+ * have their own context for the opposite reason — they change often, and a
43
+ * consumer of one must not re-render for a change in another.
44
+ */
45
+ import { createContext, useContext } from 'react';
46
+
47
+ import type { ControlSurfaceValue } from './types';
48
+
49
+ const ControlSurfaceContext = createContext<ControlSurfaceValue | null>(null);
50
+ ControlSurfaceContext.displayName = 'BloomControlSurfaceContext';
51
+
52
+ /** @internal The provider `ControlSurface` renders. */
53
+ export const ControlSurfaceProvider = ControlSurfaceContext.Provider;
54
+
55
+ /**
56
+ * The nearest container's presentation, or `null` when there is none.
57
+ *
58
+ * `null` rather than a default object on purpose: a control's own default is
59
+ * the control's to choose (`ButtonGroup` defaults to `solid`, a media overlay's
60
+ * toolbar could default to `glass`), and a context that invented one here would
61
+ * take that choice away by looking like an answer.
62
+ */
63
+ export function useControlSurface(): ControlSurfaceValue | null {
64
+ return useContext(ControlSurfaceContext);
65
+ }
66
+
67
+ /**
68
+ * Resolve one field by the precedence rule: `explicit` → container → `fallback`.
69
+ *
70
+ * ```ts
71
+ * const material = useInheritedControl('material', variant, 'solid');
72
+ * ```
73
+ */
74
+ export function useInheritedControl<K extends keyof ControlSurfaceValue>(
75
+ key: K,
76
+ explicit: ControlSurfaceValue[K] | undefined,
77
+ fallback: ControlSurfaceValue[K],
78
+ ): ControlSurfaceValue[K] {
79
+ const inherited = useContext(ControlSurfaceContext);
80
+ return explicit ?? inherited?.[key] ?? fallback;
81
+ }
@@ -0,0 +1,4 @@
1
+ export { ControlSurface } from './ControlSurface';
2
+ export { useControlSurface, useInheritedControl } from './context';
3
+
4
+ export type { ControlDensity, ControlMaterial, ControlSurfaceProps, ControlSurfaceValue } from './types';
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ /**
4
+ * The MATERIAL a control container paints itself in.
5
+ *
6
+ * `solid` is Bloom's default chrome: an opaque fill off the surface ladder, a
7
+ * hairline and a shadow. `glass` is the translucent chrome a container paints
8
+ * when it floats over content the container does not own — a page header's
9
+ * islands, a media overlay's toolbar.
10
+ *
11
+ * It names the container's SURFACE, never the action's meaning. A destructive
12
+ * action stays destructive in either material; see `docs/composition.mdx`.
13
+ */
14
+ export type ControlMaterial = 'solid' | 'glass';
15
+
16
+ /** The size a container asks the controls inside it for. */
17
+ export type ControlDensity = 'medium' | 'small';
18
+
19
+ /**
20
+ * What a container tells the controls inside it about presentation — and
21
+ * nothing else.
22
+ *
23
+ * Both fields are VISUAL CONFIGURATION: a descendant may override either with
24
+ * an explicit prop. Restrictions do not travel here, deliberately. `disabled`,
25
+ * a modal's inertness and the effective accessibility preferences are
26
+ * constraints rather than defaults, and a descendant must not be able to
27
+ * re-enable itself by passing a prop — so they stay on their own channels.
28
+ */
29
+ export interface ControlSurfaceValue {
30
+ material: ControlMaterial;
31
+ density: ControlDensity;
32
+ }
33
+
34
+ export interface ControlSurfaceProps {
35
+ /** The material controls inside should default to. Inherited when omitted. */
36
+ material?: ControlMaterial;
37
+ /** The density controls inside should default to. Inherited when omitted. */
38
+ density?: ControlDensity;
39
+ children?: ReactNode;
40
+ }
@@ -1,10 +1,11 @@
1
- import React, { memo, useMemo } from 'react';
1
+ import React, { memo, useId, useMemo } from 'react';
2
2
  import { View } from 'react-native';
3
3
 
4
4
  import { Text } from '../typography';
5
5
  import { Label } from '../label';
6
6
  import { atoms as a } from '../styles';
7
7
  import { TEXT_FIELD_STACK_GAP, useTextFieldPalette } from '../text-field/shared';
8
+ import { FieldControlProvider, type FieldControlValue } from './context';
8
9
  import type { FieldProps } from './types';
9
10
 
10
11
  /**
@@ -15,6 +16,20 @@ import type { FieldProps } from './types';
15
16
  * Bloom's `Label`. Unlike `TextField` — which bakes its label/error into the
16
17
  * input chrome — `Field` wraps any arbitrary control (a `Switch`, a
17
18
  * `SegmentedControl`, a custom picker, etc.).
19
+ *
20
+ * ── IT ASSOCIATES, IT DOES NOT ONLY STACK ───────────────────────────────────
21
+ *
22
+ * The three pieces are wired to the control through `field/context.ts`: the
23
+ * control's id, `aria-describedby` for whichever of description/error is
24
+ * showing, the invalid state, the required state and the disabled state. A
25
+ * control opts in by reading `useFieldControl()`; one that does not renders
26
+ * exactly as before. Ids are generated from `useId` when the caller gives no
27
+ * `nativeID`, so they survive SSR and hydration.
28
+ *
29
+ * `multiple` is the several-controls case — a radio set, a date's three boxes.
30
+ * The field becomes a labelled `group` and stops handing out a control id,
31
+ * because one id on three inputs is invalid and one error described by three
32
+ * inputs is read three times.
18
33
  */
19
34
  /** `HintText`: 4px under the control (`gap-1`) plus its own 1px `pt-px`. */
20
35
  const hintStyle = { marginTop: TEXT_FIELD_STACK_GAP, paddingTop: 1 } as const;
@@ -26,42 +41,84 @@ const FieldComponent = function Field({
26
41
  error,
27
42
  required = false,
28
43
  disabled = false,
44
+ multiple = false,
29
45
  nativeID,
30
46
  style,
31
47
  testID,
32
48
  }: FieldProps) {
33
49
  const palette = useTextFieldPalette();
34
50
  const hasError = typeof error === 'string' && error.length > 0;
35
- const descriptionID = nativeID ? `${nativeID}-description` : undefined;
36
- const errorID = nativeID ? `${nativeID}-error` : undefined;
51
+ // `useId` is the seed only when the caller gave no id, so an explicit
52
+ // `nativeID` still owns every derived id and a consumer that wired its own
53
+ // `aria-describedby` keeps working.
54
+ const generated = useId().replace(/[^a-zA-Z0-9]/g, '');
55
+ const base = nativeID ?? `bloom-field-${generated}`;
56
+ const labelID = `${base}-label`;
57
+ const descriptionID = `${base}-description`;
58
+ const errorID = `${base}-error`;
59
+ const hasDescription = !hasError && description != null;
60
+
61
+ const field = useMemo<FieldControlValue>(
62
+ () => ({
63
+ controlId: multiple ? undefined : base,
64
+ labelId: label != null ? labelID : undefined,
65
+ // In a `multiple` field the GROUP is described, not each control: an
66
+ // error pointed at by three inputs is announced three times.
67
+ describedBy: multiple
68
+ ? undefined
69
+ : hasError
70
+ ? errorID
71
+ : hasDescription
72
+ ? descriptionID
73
+ : undefined,
74
+ labelText: typeof label === 'string' ? label : undefined,
75
+ invalid: hasError,
76
+ required,
77
+ disabled,
78
+ }),
79
+ [multiple, base, label, labelID, hasError, errorID, hasDescription, descriptionID, required, disabled],
80
+ );
37
81
 
38
82
  return (
39
- <View style={[a.w_full, style]} testID={testID}>
40
- {label != null ? (
41
- <Label htmlFor={nativeID} required={required} disabled={disabled}>
42
- {label}
43
- </Label>
44
- ) : null}
83
+ <FieldControlProvider value={field}>
84
+ <View
85
+ style={[a.w_full, style]}
86
+ testID={testID}
87
+ // A group only when it holds several controls. A `group` around one
88
+ // control is a wrapper a screen reader has to step into for nothing,
89
+ // and it competes with the control's own name.
90
+ role={multiple ? 'group' : undefined}
91
+ aria-labelledby={multiple && label != null ? labelID : undefined}
92
+ aria-describedby={
93
+ multiple ? (hasError ? errorID : hasDescription ? descriptionID : undefined) : undefined
94
+ }
95
+ >
96
+ {label != null ? (
97
+ <Label nativeID={labelID} htmlFor={multiple ? null : base} required={required} disabled={disabled}>
98
+ {label}
99
+ </Label>
100
+ ) : null}
45
101
 
46
- {children}
102
+ {children}
47
103
 
48
- {hasError ? (
49
- <Text
50
- variant="caption-1-medium"
51
- nativeID={errorID}
52
- accessibilityRole="alert"
53
- style={[hintStyle, { color: palette.error }]}>
54
- {error}
55
- </Text>
56
- ) : description != null ? (
57
- <Text
58
- variant="caption-1-medium"
59
- nativeID={descriptionID}
60
- style={[hintStyle, { color: disabled ? palette.placeholder : palette.hint }]}>
61
- {description}
62
- </Text>
63
- ) : null}
64
- </View>
104
+ {hasError ? (
105
+ <Text
106
+ variant="caption-1-medium"
107
+ nativeID={errorID}
108
+ accessibilityRole="alert"
109
+ style={[hintStyle, { color: palette.error }]}>
110
+ {error}
111
+ </Text>
112
+ ) : description != null ? (
113
+ <Text
114
+ variant="caption-1-medium"
115
+ nativeID={descriptionID}
116
+ style={[hintStyle, { color: disabled ? palette.placeholder : palette.hint }]}>
117
+ {description}
118
+ </Text>
119
+ ) : null}
120
+ </View>
121
+ </FieldControlProvider>
65
122
  );
66
123
  };
67
124