@oxy.so/bloom 2.12.4 → 3.0.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 (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 +46 -0
  6. package/docs/layout.mdx +72 -4
  7. package/docs/migrating-to-3.0.mdx +169 -0
  8. package/docs/page-header.mdx +192 -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
@@ -0,0 +1,110 @@
1
+ /**
2
+ * FIELD MEMBERSHIP — what a `Field` tells the control inside it, so the label,
3
+ * the description and the error are wired to it rather than merely stacked
4
+ * above and below it.
5
+ *
6
+ * ## What was missing
7
+ *
8
+ * `Field` drew the three pieces and rendered `children` untouched. Every
9
+ * association — the id the label points at, the `aria-describedby` that makes a
10
+ * screen reader read the error, the invalid state, the disabled state — was the
11
+ * app's to wire by hand, on every field, and the failure when it was not wired
12
+ * is silent: the field LOOKS associated, because the parts are in the right
13
+ * places and the error is red.
14
+ *
15
+ * ## The contract
16
+ *
17
+ * `Field` publishes ids and state; a control that can accept them calls
18
+ * {@link useFieldControl} and applies them. Opt-IN, not magic: an arbitrary
19
+ * child cannot be given behaviour by being wrapped, and a container that
20
+ * inspected its children's types to decide what to do with them would be the
21
+ * thing `docs/composition.mdx` argues against. A control that does not read the
22
+ * context renders exactly as it did before.
23
+ *
24
+ * ## The ids are stable across SSR
25
+ *
26
+ * From `useId`, seeded by the caller's `nativeID` when it gave one. A
27
+ * module-scope counter would number the server's render and the client's
28
+ * hydration differently, so the `for` attribute React sent and the `id` it
29
+ * re-rendered would stop matching — which is invisible until someone clicks a
30
+ * label.
31
+ *
32
+ * ## One control or several
33
+ *
34
+ * A field with ONE control gives it the field's id, and the error is described
35
+ * BY that control. A field with several (a radio set, a date's three inputs)
36
+ * must not: three inputs sharing one id is invalid HTML, and an error attached
37
+ * to each of them is read three times. `multiple` switches the field to a
38
+ * labelled `group` and publishes no control id at all — the group carries the
39
+ * name and the description, and each control keeps its own.
40
+ *
41
+ * ## What does NOT travel here
42
+ *
43
+ * The VALUE, the validation and the submission. Bloom owns presentation and
44
+ * association; an app's form library owns the data. A context that also carried
45
+ * the value would make `Field` a form framework, which is the one thing this is
46
+ * not.
47
+ */
48
+ import { createContext, useContext } from 'react';
49
+
50
+ export interface FieldControlValue {
51
+ /**
52
+ * The id the single control should carry, so the label's `for` points at it.
53
+ * `undefined` in a `multiple` field, where each control keeps its own.
54
+ */
55
+ controlId?: string;
56
+ /** The label's own id — for a control that names itself by reference. */
57
+ labelId?: string;
58
+ /**
59
+ * The id list for `aria-describedby`: the description, or the error when
60
+ * there is one. `undefined` when the field has neither.
61
+ */
62
+ describedBy?: string;
63
+ /**
64
+ * The label as TEXT, when the field's label is a string.
65
+ *
66
+ * This is the cross-platform half of naming. On web a `<label for>` is a real
67
+ * association and the control needs no name of its own; React Native has no
68
+ * equivalent, so the control has to carry the name, and the name it should
69
+ * carry is the one the user can see. A control that has both its own label
70
+ * prop and this should prefer this — otherwise the visible label and the
71
+ * announced name can disagree, which is worse than either alone.
72
+ */
73
+ labelText?: string;
74
+ /** Whether the field is currently in its error state. */
75
+ invalid: boolean;
76
+ /** Whether the field is marked required. */
77
+ required: boolean;
78
+ /**
79
+ * The field's disabled state.
80
+ *
81
+ * A CONSTRAINT, not a default: a control inside a disabled field must not be
82
+ * able to re-enable itself, so a consumer applies this with `||` against its
83
+ * own prop rather than with `??`.
84
+ */
85
+ disabled: boolean;
86
+ }
87
+
88
+ const FieldControlContext = createContext<FieldControlValue | null>(null);
89
+ FieldControlContext.displayName = 'BloomFieldControlContext';
90
+
91
+ /** @internal The provider `Field` renders. */
92
+ export const FieldControlProvider = FieldControlContext.Provider;
93
+
94
+ /**
95
+ * The enclosing `Field`'s association, or `null` outside one.
96
+ *
97
+ * ```tsx
98
+ * const field = useFieldControl();
99
+ * <TextInput
100
+ * nativeID={field?.controlId ?? nativeID}
101
+ * aria-describedby={field?.describedBy}
102
+ * aria-invalid={field?.invalid || isInvalid || undefined}
103
+ * accessibilityLabel={field?.labelText ?? label}
104
+ * editable={!(field?.disabled || disabled)}
105
+ * />
106
+ * ```
107
+ */
108
+ export function useFieldControl(): FieldControlValue | null {
109
+ return useContext(FieldControlContext);
110
+ }
@@ -1,2 +1,5 @@
1
1
  export { Field } from './Field';
2
+ export { useFieldControl } from './context';
3
+
4
+ export type { FieldControlValue } from './context';
2
5
  export type { FieldProps } from './types';
@@ -14,11 +14,30 @@ export interface FieldProps {
14
14
  error?: string | null;
15
15
  /** Mark the field's label with a required asterisk. */
16
16
  required?: boolean;
17
- /** Disabled styling for the label/description. */
17
+ /**
18
+ * Disables the label/description styling AND, for a control that reads the
19
+ * field context, the control itself.
20
+ *
21
+ * It is a CONSTRAINT rather than a default: a control inside a disabled field
22
+ * combines it with `||`, so nothing inside can re-enable itself.
23
+ */
18
24
  disabled?: boolean;
19
25
  /**
20
- * `nativeID` wired to the label's `htmlFor` (web) so a label click focuses
21
- * the control. Set the same id on the child control's `nativeID`.
26
+ * The field holds SEVERAL controls — a radio set, a date's three boxes.
27
+ *
28
+ * It becomes a labelled `group` and publishes no control id, because one id
29
+ * shared by three inputs is invalid HTML and one error described by three
30
+ * inputs is announced three times. Each control keeps its own id and name.
31
+ */
32
+ multiple?: boolean;
33
+ /**
34
+ * The control's id, wired to the label's `htmlFor` on web so a label click
35
+ * focuses it.
36
+ *
37
+ * Optional now: omitted, the field generates one from `useId` and hands it to
38
+ * a control that reads `useFieldControl()`. Pass it when the id has to be a
39
+ * particular string — an app's form library keyed by field name, or a control
40
+ * that does not read the context and needs the id written on it by hand.
22
41
  */
23
42
  nativeID?: string;
24
43
  style?: StyleProp<ViewStyle>;
@@ -22,7 +22,7 @@
22
22
  * `alignSelf: 'flex-start'` keeps that wrapper from stretching across its
23
23
  * parent, which would anchor the surface to the row rather than to the control.
24
24
  */
25
- import React, { Children, cloneElement, isValidElement, useRef } from 'react';
25
+ import React, { Children, cloneElement, Fragment, isValidElement, useRef } from 'react';
26
26
  import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
27
27
 
28
28
  import { useRestoreAccessibilityFocus } from '../hooks/use-accessibility-focus';
@@ -50,6 +50,16 @@ export interface TriggerSlotProps {
50
50
  * several children, which is the right failure: `asChild` has no meaning for a
51
51
  * fragment, and silently picking the first child is how a trigger ends up
52
52
  * un-pressable with nothing logged.
53
+ *
54
+ * ── A FRAGMENT PASSES `isValidElement` AND DROPS EVERY PROP ─────────────────
55
+ *
56
+ * `Children.only` accepts `<>{child}</>` — a fragment IS one child, and it IS a
57
+ * valid element — and `cloneElement` on it succeeds, returning a fragment
58
+ * carrying an `onPress` and an `aria-expanded` that React then discards,
59
+ * because a fragment renders no host node to put them on. Nothing throws. The
60
+ * trigger is simply dead, and the caller's most likely next move is to wrap it
61
+ * in another fragment. So it is rejected here, by name, with the fix in the
62
+ * message.
53
63
  */
54
64
  function cloneTrigger(
55
65
  children: React.ReactNode,
@@ -61,6 +71,11 @@ function cloneTrigger(
61
71
  'A trigger with `asChild` needs a single React element child. Drop `asChild` to let Bloom render the pressable.',
62
72
  );
63
73
  }
74
+ if (child.type === Fragment) {
75
+ throw new Error(
76
+ 'A trigger with `asChild` cannot clone a fragment — it renders no element, so the press handler and the a11y state would be dropped silently. Pass the control itself as the single child.',
77
+ );
78
+ }
64
79
  const childProps = child.props;
65
80
  // A disabled trigger must not OPEN, and the child's own `disabled` is where a
66
81
  // caller most naturally writes that — a disabled `Pressable` child rather than
@@ -73,10 +88,28 @@ function cloneTrigger(
73
88
  ...handle,
74
89
  // Compose rather than overwrite: the child's own handler runs first, then
75
90
  // ours opens the surface.
91
+ //
92
+ // ── AND THE CHILD'S HANDLER CAN CANCEL THE OPEN ─────────────────────────
93
+ //
94
+ // `event.preventDefault()` in the child's own handler stops the surface
95
+ // from opening. Composition needs an answer to "what if the first handler
96
+ // decides this press should not do the second thing" — a trigger that has
97
+ // just discovered the form is dirty, or that the row it belongs to was
98
+ // removed — and without one the only way to express it is to stop using
99
+ // `asChild` and rebuild the control.
100
+ //
101
+ // It is `preventDefault` rather than a Bloom-specific return value because
102
+ // the event already carries it on BOTH platforms and both mean the same
103
+ // thing by it: on web `onPress` is handed React's synthetic `click` event,
104
+ // and on native the responder event is a React synthetic event too. One
105
+ // consequence worth knowing on web: the same call also suppresses the
106
+ // browser's own default for that click, so cancelling the open on an
107
+ // anchor child cancels its navigation as well.
76
108
  onPress: isDisabled
77
109
  ? childProps.onPress
78
110
  : (event) => {
79
111
  childProps.onPress?.(event);
112
+ if (event?.defaultPrevented) return;
80
113
  handle.onPress(event);
81
114
  },
82
115
  onLongPress:
@@ -84,6 +117,7 @@ function cloneTrigger(
84
117
  ? childProps.onLongPress
85
118
  : (event) => {
86
119
  childProps.onLongPress?.(event);
120
+ if (event?.defaultPrevented) return;
87
121
  handle.onLongPress?.(event);
88
122
  },
89
123
  // A label the caller already wrote is more specific than the family's.
@@ -0,0 +1,109 @@
1
+ import React, { memo, useMemo } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+
4
+ import { ControlSurface } from '../control-surface';
5
+ import { bloomShadowStyle } from '../design-tokens/shadows';
6
+ import { resolveSurfaceLevel } from '../styles/surface-levels';
7
+ import { borderRadius } from '../styles/tokens';
8
+ import { resolveChromeGlassColors } from '../theme/glass-colors';
9
+ import { useTheme } from '../theme/use-theme';
10
+ import { GlassSurface } from './GlassSurface';
11
+ import type { GlassIslandProps } from './types';
12
+
13
+ /**
14
+ * An ISLAND: one translucent, rounded container holding one or more controls
15
+ * that float over content the container does not own.
16
+ *
17
+ * ── WHY THIS EXISTS RATHER THAN A SECOND MATERIAL ───────────────────────────
18
+ *
19
+ * `GlassSurface` is the material — blur, tint, sheen, lit rim — and by its own
20
+ * contract it does NOT own the geometry, the hairline or the shadow: it clips
21
+ * itself, and a shadow drawn on a clipping node is clipped away on iOS. So
22
+ * every caller that wants a floating pane has to assemble the same four things
23
+ * in the same order. Two callers now want exactly that assembly — a
24
+ * `ButtonGroup` in its glass variant, and a page header's back capsule — which
25
+ * is the second real need that justifies writing it once.
26
+ *
27
+ * ── THE FILL IS THE LADDER'S, NOT A LOCAL PALETTE ───────────────────────────
28
+ *
29
+ * The island paints rung 1 of `styles/surface-levels` — byte-for-byte the fill
30
+ * a card and a menu panel already paint — at the CHROME alpha. That is the
31
+ * whole reason it reads as Bloom rather than as a fourth material invented for
32
+ * headers, and it is why a preset change moves islands with everything else.
33
+ *
34
+ * ── WHAT IT TELLS ITS CHILDREN ──────────────────────────────────────────────
35
+ *
36
+ * It mounts a `ControlSurface` with `material: 'glass'`, so a control inside it
37
+ * paints flush — no second fill, no second blur — without the caller writing
38
+ * `variant="glass"` on every one. The nesting rule is the material's, not a
39
+ * preference: a translucent pane inside a translucent pane composites two
40
+ * alphas and two blurs for one pane's worth of depth, and on Android the second
41
+ * blur is not available at all.
42
+ *
43
+ * It does NOT publish a `SurfaceLevelProvider`. A published surface fill is a
44
+ * promise that a descendant can read the pixel it lands on, and the pixel here
45
+ * depends on content Bloom cannot see.
46
+ *
47
+ * ── NO `overflow: hidden` ───────────────────────────────────────────────────
48
+ *
49
+ * Deliberately absent, twice over: `GlassSurface` clips its own layers, and
50
+ * `clipsToBounds` on iOS would clip this box's drop shadow away. Controls
51
+ * inside round their own press highlight instead of relying on the island to
52
+ * cut it.
53
+ */
54
+ const GlassIslandComponent: React.FC<GlassIslandProps> = ({
55
+ children,
56
+ radius = borderRadius.full,
57
+ role,
58
+ accessibilityLabel,
59
+ sheen = true,
60
+ style,
61
+ testID,
62
+ }) => {
63
+ const theme = useTheme();
64
+ const paint = useMemo(() => {
65
+ const level = resolveSurfaceLevel(theme, 1);
66
+ return { fill: level.background, glass: resolveChromeGlassColors(level.background, level.border, theme.isDark) };
67
+ }, [theme]);
68
+
69
+ return (
70
+ <ControlSurface material="glass">
71
+ <View
72
+ role={role}
73
+ accessibilityLabel={accessibilityLabel}
74
+ testID={testID}
75
+ style={[
76
+ styles.island,
77
+ {
78
+ borderRadius: radius,
79
+ borderWidth: paint.glass.hairlineWidth,
80
+ borderColor: paint.glass.hairline,
81
+ },
82
+ bloomShadowStyle('glass'),
83
+ style,
84
+ ]}
85
+ >
86
+ <GlassSurface
87
+ fill={paint.fill}
88
+ material="chrome"
89
+ radius={radius}
90
+ sheen={sheen}
91
+ testID={testID ? `${testID}-material` : undefined}
92
+ />
93
+ {children}
94
+ </View>
95
+ </ControlSurface>
96
+ );
97
+ };
98
+
99
+ const styles = StyleSheet.create({
100
+ island: {
101
+ flexDirection: 'row',
102
+ alignItems: 'stretch',
103
+ alignSelf: 'flex-start',
104
+ position: 'relative',
105
+ },
106
+ });
107
+
108
+ export const GlassIsland = memo(GlassIslandComponent);
109
+ GlassIsland.displayName = 'GlassIsland';
@@ -8,6 +8,7 @@ import {
8
8
  GLASS_BLUR_INTENSITY,
9
9
  GLASS_RIM_HIGHLIGHT,
10
10
  GLASS_SHEEN,
11
+ resolveChromeGlassColors,
11
12
  resolveGlassColors,
12
13
  } from '../theme/glass-colors';
13
14
  import type { GlassSurfaceProps } from './types';
@@ -54,12 +55,19 @@ let glassSheenIdCounter = 0;
54
55
  const GlassSurfaceComponent: React.FC<GlassSurfaceProps> = ({
55
56
  fill,
56
57
  radius,
58
+ material = 'accent',
57
59
  sheen = true,
58
60
  style,
59
61
  testID,
60
62
  }) => {
61
63
  const theme = useTheme();
62
- const glass = useMemo(() => resolveGlassColors(fill), [fill]);
64
+ const glass = useMemo(
65
+ () =>
66
+ material === 'chrome'
67
+ ? resolveChromeGlassColors(fill, fill, theme.isDark)
68
+ : resolveGlassColors(fill),
69
+ [fill, material, theme.isDark],
70
+ );
63
71
  // Per instance, because two panes in one document would otherwise share an id
64
72
  // and the survivor of an unmount would reference a gradient that is gone. Same
65
73
  // counter shape as `AvatarRing`.
@@ -104,8 +112,24 @@ const GlassSurfaceComponent: React.FC<GlassSurfaceProps> = ({
104
112
  content Bloom does not own.
105
113
  */}
106
114
  <View style={[StyleSheet.absoluteFill, { backgroundColor: glass.fill }]} />
115
+ {/*
116
+ The sheen's `width`/`height` are load-bearing beside `absoluteFill`: an
117
+ `<svg>` is a REPLACED element, so `width: auto` resolves to its intrinsic
118
+ size and an SVG without one falls back to CSS's 300 x 150. `left: 0;
119
+ right: 0` does not stretch it — the `right` is over-constrained and
120
+ dropped. On a surface narrower than 300 the sheen still covers, but its
121
+ gradient is scaled over 150px instead of the surface's own height, so
122
+ only the top of the ramp is ever visible; on a wider one it stops dead
123
+ at 300px. Native lays the host view out with flexbox and is unaffected.
124
+ Gate: `src/__tests__/svg-absolute-fill-size.test.ts`.
125
+ */}
107
126
  {sheen ? (
108
- <Svg style={StyleSheet.absoluteFill} testID={testID ? `${testID}-sheen` : undefined}>
127
+ <Svg
128
+ width="100%"
129
+ height="100%"
130
+ style={StyleSheet.absoluteFill}
131
+ testID={testID ? `${testID}-sheen` : undefined}
132
+ >
109
133
  <Defs>
110
134
  {/* Top to bottom: simulated light from above. */}
111
135
  {/*
@@ -1,3 +1,4 @@
1
+ export { GlassIsland } from './GlassIsland';
1
2
  export { GlassSurface } from './GlassSurface';
2
3
  export {
3
4
  GlassBlurTargetProvider,
@@ -5,6 +6,8 @@ export {
5
6
  useWindowedBlurTarget,
6
7
  } from './blur-target';
7
8
  export type {
9
+ GlassIslandProps,
10
+ GlassMaterial,
8
11
  GlassSurfaceProps,
9
12
  GlassBlurTargetProviderProps,
10
13
  GlassBlurWindowProps,
@@ -14,6 +14,20 @@ export interface GlassBlurWindowProps {
14
14
  children?: ReactNode;
15
15
  }
16
16
 
17
+ /**
18
+ * Which of the two glass ROLES a pane paints.
19
+ *
20
+ * `accent` is a brand fill over one of Bloom's own neutral surfaces — a
21
+ * `primary` control, labelled with that fill's own on-colour. `chrome` is a
22
+ * neutral surface off the ladder (`styles/surface-levels`) over content Bloom
23
+ * does NOT own — a page header's island — labelled with the theme's own `text`.
24
+ *
25
+ * They carry different alphas because they are priced against different
26
+ * backdrops, and in opposite directions; the measurement is on
27
+ * {@link ../theme/glass-colors#GLASS_CHROME_ALPHA}.
28
+ */
29
+ export type GlassMaterial = 'accent' | 'chrome';
30
+
17
31
  export interface GlassSurfaceProps {
18
32
  /**
19
33
  * The OPAQUE brand fill the pane is tinted with — `theme.colors.primary`,
@@ -35,6 +49,14 @@ export interface GlassSurfaceProps {
35
49
  * hides until someone uses a different shape.
36
50
  */
37
51
  radius: number;
52
+ /**
53
+ * Which role's alpha to apply to `fill`. Default `accent`.
54
+ *
55
+ * A pane cannot infer this from the colour it is given — `chrome`'s fill is a
56
+ * neutral that in light mode IS `colors.card`, which an accent pane could
57
+ * legitimately be asked to paint too.
58
+ */
59
+ material?: GlassMaterial;
38
60
  /**
39
61
  * Whether to paint the sheen — the top-lit gradient that separates glass from
40
62
  * flat tinted plastic.
@@ -49,3 +71,26 @@ export interface GlassSurfaceProps {
49
71
  style?: StyleProp<ViewStyle>;
50
72
  testID?: string;
51
73
  }
74
+
75
+ export interface GlassIslandProps {
76
+ children?: ReactNode;
77
+ /**
78
+ * Corner radius. A full pill by default — an island is a capsule, and a
79
+ * capsule of icon-only controls is what makes the row read as separated
80
+ * groups rather than as one bar.
81
+ */
82
+ radius?: number;
83
+ /**
84
+ * `'group'` when the island holds several RELATED actions the consumer has
85
+ * declared as one group. Omitted for a single control in its own capsule: a
86
+ * group of one is noise for a screen reader, and the control already names
87
+ * itself.
88
+ */
89
+ role?: 'group';
90
+ /** Names the group. Required by `role="group"` to be worth announcing. */
91
+ accessibilityLabel?: string;
92
+ /** Forwarded to the material. See {@link GlassSurfaceProps.sheen}. */
93
+ sheen?: boolean;
94
+ style?: StyleProp<ViewStyle>;
95
+ testID?: string;
96
+ }
package/src/index.ts CHANGED
@@ -155,12 +155,18 @@ export type {
155
155
  } from './surfaces';
156
156
  export * from './button';
157
157
  export * from './button-group';
158
+ export * from './control-surface';
158
159
  export {
159
160
  EDGE_GAP,
160
161
  windowEdgeGap,
161
162
  BottomEdgeProvider,
162
163
  useBottomEdgeInset,
163
164
  useClaimBottomEdge,
165
+ TopEdgeProvider,
166
+ useClaimTopEdge,
167
+ useTopEdgeInset,
168
+ ScrollOffsetProvider,
169
+ useScrollOffset,
164
170
  } from './layout';
165
171
  export { Fab } from './fab';
166
172
  export type { FabProps, FabVariant, FabSize, FabPlacement } from './fab';
package/src/index.web.ts CHANGED
@@ -160,12 +160,18 @@ export type {
160
160
  } from './surfaces/index.web';
161
161
  export * from './button/index.web';
162
162
  export * from './button-group';
163
+ export * from './control-surface';
163
164
  export {
164
165
  EDGE_GAP,
165
166
  windowEdgeGap,
166
167
  BottomEdgeProvider,
167
168
  useBottomEdgeInset,
168
169
  useClaimBottomEdge,
170
+ TopEdgeProvider,
171
+ useClaimTopEdge,
172
+ useTopEdgeInset,
173
+ ScrollOffsetProvider,
174
+ useScrollOffset,
169
175
  } from './layout';
170
176
  export { Fab } from './fab/index.web';
171
177
  export type { FabProps, FabVariant, FabSize, FabPlacement } from './fab/index.web';
@@ -40,8 +40,10 @@ const LabelComponent = function Label({
40
40
  }: LabelProps) {
41
41
  const palette = useTextFieldPalette();
42
42
 
43
+ // `null` is DISTINCT from omitted: omitted falls back to the control's id,
44
+ // `null` says the label names a group and points at nothing.
43
45
  const webProps: Record<string, unknown> =
44
- IS_WEB ? { htmlFor: htmlFor ?? nativeID } : {};
46
+ IS_WEB ? { htmlFor: htmlFor === null ? undefined : (htmlFor ?? nativeID) } : {};
45
47
 
46
48
  return (
47
49
  <Text
@@ -11,8 +11,14 @@ export interface LabelProps {
11
11
  /**
12
12
  * Web-only convenience: the `id` of the form control this label is for.
13
13
  * Defaults to `nativeID` when omitted. Ignored on native.
14
+ *
15
+ * `null` means there is NO single control to point at — a label naming a
16
+ * GROUP of them, which is `Field`'s `multiple` case. Without a way to say
17
+ * that, the `nativeID` fallback makes the label its own target: a
18
+ * `<label for>` pointing at the label, which focuses nothing and which
19
+ * nothing reports.
14
20
  */
15
- htmlFor?: string;
21
+ htmlFor?: string | null;
16
22
  /**
17
23
  * Render a required marker (an asterisk in the theme's negative color)
18
24
  * after the label text. Defaults to `false`.
@@ -67,57 +67,9 @@ import {
67
67
  type PropsWithChildren,
68
68
  } from 'react';
69
69
 
70
- interface BottomEdgeStore {
71
- subscribe: (onChange: () => void) => () => void;
72
- /**
73
- * The cached total. Returns the SAME number until a claim actually changes it
74
- * — `useSyncExternalStore` re-renders forever if the snapshot is recomputed
75
- * per call.
76
- */
77
- getInset: () => number;
78
- claim: (id: string, height: number) => void;
79
- release: (id: string) => void;
80
- }
81
-
82
- function createBottomEdgeStore(): BottomEdgeStore {
83
- const claims = new Map<string, number>();
84
- const listeners = new Set<() => void>();
85
- let inset = 0;
86
-
87
- const recompute = () => {
88
- let next = 0;
89
- for (const height of claims.values()) {
90
- if (height > next) next = height;
91
- }
92
- // Bail before notifying: a re-registration at an unchanged height (every
93
- // render of a claimant whose footprint did not move) must not re-render
94
- // every reader.
95
- if (next === inset) return;
96
- inset = next;
97
- for (const listener of listeners) listener();
98
- };
70
+ import { createEdgeStore, NO_INSET, NO_SUBSCRIPTION, type EdgeStore } from './edge-store';
99
71
 
100
- return {
101
- subscribe(onChange) {
102
- listeners.add(onChange);
103
- return () => {
104
- listeners.delete(onChange);
105
- };
106
- },
107
- getInset: () => inset,
108
- claim(id, height) {
109
- if (claims.get(id) === height) return;
110
- claims.set(id, height);
111
- recompute();
112
- },
113
- release(id) {
114
- if (!claims.delete(id)) return;
115
- recompute();
116
- },
117
- };
118
- }
119
-
120
- const BottomEdgeContext = createContext<BottomEdgeStore | null>(null);
72
+ const BottomEdgeContext = createContext<EdgeStore | null>(null);
121
73
 
122
74
  /**
123
75
  * Wrap the app once — `BloomProvider` already does. Rendering a second one
@@ -126,17 +78,12 @@ const BottomEdgeContext = createContext<BottomEdgeStore | null>(null);
126
78
  * the tab bar the modal is covering.
127
79
  */
128
80
  export function BottomEdgeProvider({ children }: PropsWithChildren) {
129
- const [store] = useState(createBottomEdgeStore);
81
+ const [store] = useState(createEdgeStore);
130
82
  return <BottomEdgeContext.Provider value={store}>{children}</BottomEdgeContext.Provider>;
131
83
  }
132
84
 
133
85
  BottomEdgeProvider.displayName = 'BottomEdgeProvider';
134
86
 
135
- // Stable module-scope identities for the no-provider path. Fresh closures here
136
- // would resubscribe `useSyncExternalStore` on every render.
137
- const NO_SUBSCRIPTION = () => () => {};
138
- const NO_INSET = () => 0;
139
-
140
87
  /**
141
88
  * How much of the bottom edge is already occupied, in px.
142
89
  *