@oxyhq/bloom 1.0.6 → 1.2.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 (220) hide show
  1. package/docs/level-picker.mdx +105 -0
  2. package/docs/media-flight.mdx +272 -0
  3. package/docs/migrating-the-media-gallery.mdx +103 -0
  4. package/docs/migrating-to-1.0.mdx +1 -1
  5. package/docs/slider.mdx +3 -1
  6. package/docs/zoomable-media-gallery.mdx +116 -0
  7. package/lib/commonjs/floating/index.js.map +1 -1
  8. package/lib/commonjs/image-aspect-ratio-cache/aspect-ratio-cache.js +1 -1
  9. package/lib/commonjs/index.js +8 -0
  10. package/lib/commonjs/index.js.map +1 -1
  11. package/lib/commonjs/index.web.js +33 -25
  12. package/lib/commonjs/index.web.js.map +1 -1
  13. package/lib/commonjs/level-picker/LevelPicker.js +349 -0
  14. package/lib/commonjs/level-picker/LevelPicker.js.map +1 -0
  15. package/lib/commonjs/level-picker/LevelPicker.web.js +346 -0
  16. package/lib/commonjs/level-picker/LevelPicker.web.js.map +1 -0
  17. package/lib/commonjs/level-picker/constants.js +137 -0
  18. package/lib/commonjs/level-picker/constants.js.map +1 -0
  19. package/lib/commonjs/level-picker/index.js +13 -0
  20. package/lib/commonjs/level-picker/index.js.map +1 -0
  21. package/lib/commonjs/level-picker/index.web.js +13 -0
  22. package/lib/commonjs/level-picker/index.web.js.map +1 -0
  23. package/lib/commonjs/level-picker/types.js +6 -0
  24. package/lib/commonjs/level-picker/types.js.map +1 -0
  25. package/lib/commonjs/media-flight/MediaFlightLayer.js +151 -0
  26. package/lib/commonjs/media-flight/MediaFlightLayer.js.map +1 -0
  27. package/lib/commonjs/media-flight/MediaSurface.js +188 -0
  28. package/lib/commonjs/media-flight/MediaSurface.js.map +1 -0
  29. package/lib/commonjs/media-flight/constants.js +39 -0
  30. package/lib/commonjs/media-flight/constants.js.map +1 -0
  31. package/lib/commonjs/media-flight/expo-video-module.js +148 -0
  32. package/lib/commonjs/media-flight/expo-video-module.js.map +1 -0
  33. package/lib/commonjs/media-flight/index.js +102 -0
  34. package/lib/commonjs/media-flight/index.js.map +1 -0
  35. package/lib/commonjs/media-flight/store.js +457 -0
  36. package/lib/commonjs/media-flight/store.js.map +1 -0
  37. package/lib/commonjs/media-flight/types.js +6 -0
  38. package/lib/commonjs/media-flight/types.js.map +1 -0
  39. package/lib/commonjs/media-flight/use-media-flight.js +41 -0
  40. package/lib/commonjs/media-flight/use-media-flight.js.map +1 -0
  41. package/lib/commonjs/tab-bar/TabBarBase.js +1 -1
  42. package/lib/commonjs/{zoomable-image-gallery/ZoomableImageGallery.js → zoomable-media-gallery/ZoomableMediaGallery.js} +185 -131
  43. package/lib/commonjs/zoomable-media-gallery/ZoomableMediaGallery.js.map +1 -0
  44. package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/constants.js.map +1 -1
  45. package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/index.js +5 -5
  46. package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/index.js.map +1 -1
  47. package/lib/commonjs/zoomable-media-gallery/types.js +6 -0
  48. package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/types.js.map +1 -1
  49. package/lib/module/floating/index.js +5 -0
  50. package/lib/module/floating/index.js.map +1 -1
  51. package/lib/module/image-aspect-ratio-cache/aspect-ratio-cache.js +1 -1
  52. package/lib/module/index.js +3 -1
  53. package/lib/module/index.js.map +1 -1
  54. package/lib/module/index.web.js +3 -1
  55. package/lib/module/index.web.js.map +1 -1
  56. package/lib/module/level-picker/LevelPicker.js +343 -0
  57. package/lib/module/level-picker/LevelPicker.js.map +1 -0
  58. package/lib/module/level-picker/LevelPicker.web.js +340 -0
  59. package/lib/module/level-picker/LevelPicker.web.js.map +1 -0
  60. package/lib/module/level-picker/constants.js +131 -0
  61. package/lib/module/level-picker/constants.js.map +1 -0
  62. package/lib/module/level-picker/index.js +4 -0
  63. package/lib/module/level-picker/index.js.map +1 -0
  64. package/lib/module/level-picker/index.web.js +4 -0
  65. package/lib/module/level-picker/index.web.js.map +1 -0
  66. package/lib/module/level-picker/types.js +4 -0
  67. package/lib/module/level-picker/types.js.map +1 -0
  68. package/lib/module/media-flight/MediaFlightLayer.js +146 -0
  69. package/lib/module/media-flight/MediaFlightLayer.js.map +1 -0
  70. package/lib/module/media-flight/MediaSurface.js +180 -0
  71. package/lib/module/media-flight/MediaSurface.js.map +1 -0
  72. package/lib/module/media-flight/constants.js +35 -0
  73. package/lib/module/media-flight/constants.js.map +1 -0
  74. package/lib/module/media-flight/expo-video-module.js +142 -0
  75. package/lib/module/media-flight/expo-video-module.js.map +1 -0
  76. package/lib/module/media-flight/index.js +9 -0
  77. package/lib/module/media-flight/index.js.map +1 -0
  78. package/lib/module/media-flight/store.js +439 -0
  79. package/lib/module/media-flight/store.js.map +1 -0
  80. package/lib/module/media-flight/types.js +4 -0
  81. package/lib/module/media-flight/types.js.map +1 -0
  82. package/lib/module/media-flight/use-media-flight.js +36 -0
  83. package/lib/module/media-flight/use-media-flight.js.map +1 -0
  84. package/lib/module/tab-bar/TabBarBase.js +1 -1
  85. package/lib/module/{zoomable-image-gallery/ZoomableImageGallery.js → zoomable-media-gallery/ZoomableMediaGallery.js} +184 -130
  86. package/lib/module/zoomable-media-gallery/ZoomableMediaGallery.js.map +1 -0
  87. package/lib/module/{zoomable-image-gallery → zoomable-media-gallery}/constants.js.map +1 -1
  88. package/lib/module/zoomable-media-gallery/index.js +5 -0
  89. package/lib/module/zoomable-media-gallery/index.js.map +1 -0
  90. package/lib/module/zoomable-media-gallery/types.js +4 -0
  91. package/lib/module/{zoomable-image-gallery → zoomable-media-gallery}/types.js.map +1 -1
  92. package/lib/typescript/commonjs/floating/index.d.ts +5 -0
  93. package/lib/typescript/commonjs/floating/index.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/index.d.ts +2 -0
  95. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/index.web.d.ts +2 -0
  97. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/level-picker/LevelPicker.d.ts +6 -0
  99. package/lib/typescript/commonjs/level-picker/LevelPicker.d.ts.map +1 -0
  100. package/lib/typescript/commonjs/level-picker/LevelPicker.web.d.ts +6 -0
  101. package/lib/typescript/commonjs/level-picker/LevelPicker.web.d.ts.map +1 -0
  102. package/lib/typescript/commonjs/level-picker/constants.d.ts +93 -0
  103. package/lib/typescript/commonjs/level-picker/constants.d.ts.map +1 -0
  104. package/lib/typescript/commonjs/level-picker/index.d.ts +3 -0
  105. package/lib/typescript/commonjs/level-picker/index.d.ts.map +1 -0
  106. package/lib/typescript/commonjs/level-picker/index.web.d.ts +3 -0
  107. package/lib/typescript/commonjs/level-picker/index.web.d.ts.map +1 -0
  108. package/lib/typescript/commonjs/level-picker/types.d.ts +63 -0
  109. package/lib/typescript/commonjs/level-picker/types.d.ts.map +1 -0
  110. package/lib/typescript/commonjs/media-flight/MediaFlightLayer.d.ts +11 -0
  111. package/lib/typescript/commonjs/media-flight/MediaFlightLayer.d.ts.map +1 -0
  112. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts +106 -0
  113. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts.map +1 -0
  114. package/lib/typescript/commonjs/media-flight/constants.d.ts +18 -0
  115. package/lib/typescript/commonjs/media-flight/constants.d.ts.map +1 -0
  116. package/lib/typescript/commonjs/media-flight/expo-video-module.d.ts +103 -0
  117. package/lib/typescript/commonjs/media-flight/expo-video-module.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/media-flight/index.d.ts +9 -0
  119. package/lib/typescript/commonjs/media-flight/index.d.ts.map +1 -0
  120. package/lib/typescript/commonjs/media-flight/store.d.ts +132 -0
  121. package/lib/typescript/commonjs/media-flight/store.d.ts.map +1 -0
  122. package/lib/typescript/commonjs/media-flight/types.d.ts +195 -0
  123. package/lib/typescript/commonjs/media-flight/types.d.ts.map +1 -0
  124. package/lib/typescript/commonjs/media-flight/use-media-flight.d.ts +4 -0
  125. package/lib/typescript/commonjs/media-flight/use-media-flight.d.ts.map +1 -0
  126. package/lib/typescript/commonjs/zoomable-media-gallery/ZoomableMediaGallery.d.ts +5 -0
  127. package/lib/typescript/commonjs/zoomable-media-gallery/ZoomableMediaGallery.d.ts.map +1 -0
  128. package/lib/typescript/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/constants.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/zoomable-media-gallery/index.d.ts +4 -0
  130. package/lib/typescript/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/index.d.ts.map +1 -1
  131. package/lib/typescript/commonjs/zoomable-media-gallery/types.d.ts +90 -0
  132. package/lib/typescript/commonjs/zoomable-media-gallery/types.d.ts.map +1 -0
  133. package/lib/typescript/module/floating/index.d.ts +5 -0
  134. package/lib/typescript/module/floating/index.d.ts.map +1 -1
  135. package/lib/typescript/module/index.d.ts +2 -0
  136. package/lib/typescript/module/index.d.ts.map +1 -1
  137. package/lib/typescript/module/index.web.d.ts +2 -0
  138. package/lib/typescript/module/index.web.d.ts.map +1 -1
  139. package/lib/typescript/module/level-picker/LevelPicker.d.ts +6 -0
  140. package/lib/typescript/module/level-picker/LevelPicker.d.ts.map +1 -0
  141. package/lib/typescript/module/level-picker/LevelPicker.web.d.ts +6 -0
  142. package/lib/typescript/module/level-picker/LevelPicker.web.d.ts.map +1 -0
  143. package/lib/typescript/module/level-picker/constants.d.ts +93 -0
  144. package/lib/typescript/module/level-picker/constants.d.ts.map +1 -0
  145. package/lib/typescript/module/level-picker/index.d.ts +3 -0
  146. package/lib/typescript/module/level-picker/index.d.ts.map +1 -0
  147. package/lib/typescript/module/level-picker/index.web.d.ts +3 -0
  148. package/lib/typescript/module/level-picker/index.web.d.ts.map +1 -0
  149. package/lib/typescript/module/level-picker/types.d.ts +63 -0
  150. package/lib/typescript/module/level-picker/types.d.ts.map +1 -0
  151. package/lib/typescript/module/media-flight/MediaFlightLayer.d.ts +11 -0
  152. package/lib/typescript/module/media-flight/MediaFlightLayer.d.ts.map +1 -0
  153. package/lib/typescript/module/media-flight/MediaSurface.d.ts +106 -0
  154. package/lib/typescript/module/media-flight/MediaSurface.d.ts.map +1 -0
  155. package/lib/typescript/module/media-flight/constants.d.ts +18 -0
  156. package/lib/typescript/module/media-flight/constants.d.ts.map +1 -0
  157. package/lib/typescript/module/media-flight/expo-video-module.d.ts +103 -0
  158. package/lib/typescript/module/media-flight/expo-video-module.d.ts.map +1 -0
  159. package/lib/typescript/module/media-flight/index.d.ts +9 -0
  160. package/lib/typescript/module/media-flight/index.d.ts.map +1 -0
  161. package/lib/typescript/module/media-flight/store.d.ts +132 -0
  162. package/lib/typescript/module/media-flight/store.d.ts.map +1 -0
  163. package/lib/typescript/module/media-flight/types.d.ts +195 -0
  164. package/lib/typescript/module/media-flight/types.d.ts.map +1 -0
  165. package/lib/typescript/module/media-flight/use-media-flight.d.ts +4 -0
  166. package/lib/typescript/module/media-flight/use-media-flight.d.ts.map +1 -0
  167. package/lib/typescript/module/zoomable-media-gallery/ZoomableMediaGallery.d.ts +5 -0
  168. package/lib/typescript/module/zoomable-media-gallery/ZoomableMediaGallery.d.ts.map +1 -0
  169. package/lib/typescript/module/{zoomable-image-gallery → zoomable-media-gallery}/constants.d.ts.map +1 -1
  170. package/lib/typescript/module/zoomable-media-gallery/index.d.ts +4 -0
  171. package/lib/typescript/module/{zoomable-image-gallery → zoomable-media-gallery}/index.d.ts.map +1 -1
  172. package/lib/typescript/module/zoomable-media-gallery/types.d.ts +90 -0
  173. package/lib/typescript/module/zoomable-media-gallery/types.d.ts.map +1 -0
  174. package/package.json +61 -8
  175. package/src/floating/index.ts +5 -0
  176. package/src/image-aspect-ratio-cache/aspect-ratio-cache.ts +1 -1
  177. package/src/index.ts +4 -1
  178. package/src/index.web.ts +4 -1
  179. package/src/level-picker/LevelPicker.tsx +365 -0
  180. package/src/level-picker/LevelPicker.web.tsx +374 -0
  181. package/src/level-picker/constants.ts +131 -0
  182. package/src/level-picker/index.ts +2 -0
  183. package/src/level-picker/index.web.ts +2 -0
  184. package/src/level-picker/types.ts +63 -0
  185. package/src/media-flight/MediaFlightLayer.tsx +144 -0
  186. package/src/media-flight/MediaSurface.tsx +233 -0
  187. package/src/media-flight/constants.ts +32 -0
  188. package/src/media-flight/expo-video-module.ts +181 -0
  189. package/src/media-flight/index.ts +33 -0
  190. package/src/media-flight/store.ts +497 -0
  191. package/src/media-flight/types.ts +210 -0
  192. package/src/media-flight/use-media-flight.ts +42 -0
  193. package/src/tab-bar/TabBarBase.tsx +1 -1
  194. package/src/{zoomable-image-gallery/ZoomableImageGallery.tsx → zoomable-media-gallery/ZoomableMediaGallery.tsx} +192 -117
  195. package/src/zoomable-media-gallery/index.ts +11 -0
  196. package/src/zoomable-media-gallery/types.ts +100 -0
  197. package/docs/zoomable-image-gallery.mdx +0 -66
  198. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +0 -1
  199. package/lib/commonjs/zoomable-image-gallery/types.js +0 -2
  200. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +0 -1
  201. package/lib/module/zoomable-image-gallery/index.js +0 -5
  202. package/lib/module/zoomable-image-gallery/index.js.map +0 -1
  203. package/lib/module/zoomable-image-gallery/types.js +0 -2
  204. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts +0 -5
  205. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +0 -1
  206. package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts +0 -4
  207. package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts +0 -56
  208. package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts.map +0 -1
  209. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts +0 -5
  210. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +0 -1
  211. package/lib/typescript/module/zoomable-image-gallery/index.d.ts +0 -4
  212. package/lib/typescript/module/zoomable-image-gallery/types.d.ts +0 -56
  213. package/lib/typescript/module/zoomable-image-gallery/types.d.ts.map +0 -1
  214. package/src/zoomable-image-gallery/index.ts +0 -9
  215. package/src/zoomable-image-gallery/types.ts +0 -59
  216. /package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/constants.js +0 -0
  217. /package/lib/module/{zoomable-image-gallery → zoomable-media-gallery}/constants.js +0 -0
  218. /package/lib/typescript/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/constants.d.ts +0 -0
  219. /package/lib/typescript/module/{zoomable-image-gallery → zoomable-media-gallery}/constants.d.ts +0 -0
  220. /package/src/{zoomable-image-gallery → zoomable-media-gallery}/constants.ts +0 -0
@@ -0,0 +1,374 @@
1
+ /**
2
+ * `LevelPicker` — WEB. The same control as `LevelPicker.tsx`, built out of the
3
+ * three things a browser has and React Native does not, which is what makes
4
+ * this a fork rather than a branch:
5
+ *
6
+ * 1. **Pointer capture.** `setPointerCapture` keeps the drag on the rail once
7
+ * the pointer leaves it, so a user can slam left or right past the end of
8
+ * the track and still be steering. Nothing in the RN responder system is
9
+ * equivalent on the platform where a pointer exists at all.
10
+ * 2. **`inert` and a real focus model.** A collapsed region is `inert` — its
11
+ * rows leave the tab order and the accessibility tree together — and the
12
+ * rail is a focusable `role="slider"` with arrow, Home and End keys, which
13
+ * an `adjustable` on native exposes as increment/decrement ACTIONS instead.
14
+ * 3. **`calc()`.** A stop's position is a percentage of the rail plus a pixel
15
+ * inset, which CSS resolves per frame; the native fork has to measure the
16
+ * rail with `onLayout` and multiply. Both read {@link levelStopPosition}, so
17
+ * the formula is one thing rendered two ways rather than two formulas.
18
+ *
19
+ * The rows themselves are NOT forked: the summary row is `floating/shared`'s
20
+ * `MenuRowShell`, the same one the sub-menu triggers in the details region are
21
+ * built from, so the disclosure and the rows it reveals cannot disagree about a
22
+ * row's inset, highlight or height.
23
+ */
24
+ import React, { useCallback, useRef, useState, type PointerEvent } from 'react';
25
+
26
+ import { PANEL_MOTION_DURATION, PANEL_MOTION_EASING, ROW_ICON_SIZE } from '../floating/constants';
27
+ import { cx, MenuRowChevron, MenuRowShell, SUB_TRIGGER_CLASS } from '../floating/shared';
28
+ import { useControllableState } from '../hooks/use-controllable-state';
29
+ import { useInteractionState } from '../hooks/use-interaction-state';
30
+ import { ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon } from '../icons/Chevron';
31
+ import { flattenWebStyle } from '../styles/flatten-web-style';
32
+ import type { WebCssStyle } from '../styles/web-view-style';
33
+ import { useTheme } from '../theme/use-theme';
34
+ import {
35
+ LEVEL_CAPTION_CLASS,
36
+ LEVEL_FILL_CLASS,
37
+ LEVEL_ROW_INSET,
38
+ LEVEL_SEPARATOR_CLASS,
39
+ LEVEL_SLIDER_ROW_HEIGHT,
40
+ LEVEL_STOP_CLASS,
41
+ LEVEL_STOP_REACHED_CLASS,
42
+ LEVEL_STOP_SIZE,
43
+ LEVEL_THUMB_CLASS,
44
+ LEVEL_THUMB_SIZE,
45
+ LEVEL_TRACK_CLASS,
46
+ LEVEL_TRACK_HEIGHT,
47
+ levelFromOffset,
48
+ levelStopPosition,
49
+ } from './constants';
50
+ import type { LevelPickerProps } from './types';
51
+
52
+ /**
53
+ * The menu's own motion, as CSS. `FloatingPanel` runs its enter and exit on the
54
+ * same duration and curve, so a picker changing shape inside a panel reads as
55
+ * one surface moving rather than two things animating at each other.
56
+ *
57
+ * Spelled inline from the constants rather than as `duration-200 ease-out`
58
+ * classes: a class would be a second copy of the number, free to drift from the
59
+ * one the panel uses.
60
+ */
61
+ const MOTION = {
62
+ transitionDuration: `${PANEL_MOTION_DURATION}ms`,
63
+ transitionTimingFunction: `cubic-bezier(${PANEL_MOTION_EASING.join(', ')})`,
64
+ } as const;
65
+
66
+ /** The rail's own chrome — everything about it that is not a colour role. */
67
+ const TRACK_CLASS =
68
+ 'w-full touch-none cursor-pointer outline-none ' +
69
+ 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover';
70
+
71
+ export function LevelPicker({
72
+ levels,
73
+ value,
74
+ onValueChange,
75
+ accessibilityLabel,
76
+ minLabel,
77
+ maxLabel,
78
+ detailsLabel = 'Details',
79
+ expanded,
80
+ defaultExpanded = false,
81
+ onExpandedChange,
82
+ children,
83
+ className,
84
+ style,
85
+ testID,
86
+ }: LevelPickerProps) {
87
+ const theme = useTheme();
88
+ const [isExpanded, setExpanded] = useControllableState<boolean>({
89
+ value: expanded,
90
+ defaultValue: defaultExpanded,
91
+ onChange: onExpandedChange,
92
+ });
93
+ const { state: pointerOver, onIn: enterPointer, onOut: leavePointer } = useInteractionState();
94
+ const { state: focused, onIn: focusTrack, onOut: blurTrack } = useInteractionState();
95
+ const [dragging, setDragging] = useState(false);
96
+ const [detailsHeight, setDetailsHeight] = useState(0);
97
+ const trackRef = useRef<HTMLDivElement | null>(null);
98
+
99
+ /** The slider is being WORKED — hovered, focused or dragged. */
100
+ const active = pointerOver || focused || dragging;
101
+
102
+ /**
103
+ * The details region's natural height, observed rather than read once: a
104
+ * sub-menu row inside it can change size after the reveal has already run,
105
+ * and a height measured only on mount would clip it.
106
+ *
107
+ * `ResizeObserver` is guarded because this fork also runs where there is no
108
+ * layout to observe — an SSR pass, and jsdom under jest. There the region
109
+ * falls back to its natural height (`maxHeight: undefined` below), which is
110
+ * the safe direction: unclipped rather than collapsed.
111
+ */
112
+ const measureDetails = useCallback((node: HTMLDivElement | null) => {
113
+ if (node === null) return;
114
+ const read = (): void => {
115
+ const next = node.offsetHeight;
116
+ setDetailsHeight((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
117
+ };
118
+ read();
119
+ if (typeof ResizeObserver === 'undefined') return;
120
+ const observer = new ResizeObserver(read);
121
+ observer.observe(node);
122
+ return () => observer.disconnect();
123
+ }, []);
124
+
125
+ const commitPointer = useCallback(
126
+ (event: PointerEvent<HTMLDivElement>) => {
127
+ const rect = trackRef.current?.getBoundingClientRect();
128
+ if (rect === undefined) return;
129
+ const next = levelFromOffset(event.clientX - rect.left, rect.width, levels.length);
130
+ if (next !== value) onValueChange(next);
131
+ },
132
+ [levels.length, onValueChange, value],
133
+ );
134
+
135
+ const step = useCallback(
136
+ (to: number) => {
137
+ const next = Math.max(0, Math.min(levels.length - 1, to));
138
+ if (next !== value) onValueChange(next);
139
+ },
140
+ [levels.length, onValueChange, value],
141
+ );
142
+
143
+ const onKeyDown = useCallback(
144
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
145
+ switch (event.key) {
146
+ case 'ArrowRight':
147
+ case 'ArrowUp':
148
+ event.preventDefault();
149
+ step(value + 1);
150
+ break;
151
+ case 'ArrowLeft':
152
+ case 'ArrowDown':
153
+ event.preventDefault();
154
+ step(value - 1);
155
+ break;
156
+ case 'Home':
157
+ event.preventDefault();
158
+ step(0);
159
+ break;
160
+ case 'End':
161
+ event.preventDefault();
162
+ step(levels.length - 1);
163
+ break;
164
+ default:
165
+ break;
166
+ }
167
+ },
168
+ [levels.length, step, value],
169
+ );
170
+
171
+ /** A stop's centre: a share of the rail, plus the inset that keeps the knob on it. */
172
+ const positionOf = (index: number): string => {
173
+ const { percent, offset } = levelStopPosition(index, levels.length);
174
+ return `calc(${percent}% + ${offset}px)`;
175
+ };
176
+
177
+ const hasCaptions = minLabel !== undefined || maxLabel !== undefined;
178
+ /**
179
+ * The row's fade, on the ROW rather than on a wrapper around it: an inline
180
+ * style outranks a class on web whatever the order, so this is the one place
181
+ * the opacity can live without competing with `MenuRowShell`'s own chrome —
182
+ * and it is one fewer node between the caller's layout and the row.
183
+ *
184
+ * `pointerEvents` follows the POINTER, not `active`, and the difference is not
185
+ * a nicety. A rail keeps focus after it is clicked, so a row that stopped
186
+ * taking clicks whenever `active` was true would be unclickable for as long as
187
+ * the rail was focused — measured: the click falls through to the container,
188
+ * which blurs the rail, and only the SECOND click reaches the row. Fading a
189
+ * row the pointer is nowhere near is worth it; swallowing its click is not.
190
+ */
191
+ const pointerEngaged = pointerOver || dragging;
192
+ const summaryStyle: WebCssStyle = {
193
+ ...MOTION,
194
+ transitionProperty: 'opacity',
195
+ opacity: active ? 0 : 1,
196
+ pointerEvents: pointerEngaged ? 'none' : 'auto',
197
+ };
198
+ const childTestId = (part: string): string | undefined =>
199
+ testID === undefined ? undefined : `${testID}-${part}`;
200
+
201
+ return (
202
+ <div
203
+ className={cx('flex w-full flex-col', className)}
204
+ style={flattenWebStyle(style)}
205
+ data-testid={testID}>
206
+ {/* The rail, collapsing to nothing when the details take its place. */}
207
+ <div
208
+ inert={isExpanded}
209
+ aria-hidden={isExpanded}
210
+ className="overflow-hidden"
211
+ style={{
212
+ ...MOTION,
213
+ transitionProperty: 'max-height, opacity',
214
+ maxHeight: isExpanded ? 0 : LEVEL_SLIDER_ROW_HEIGHT,
215
+ opacity: isExpanded ? 0 : 1,
216
+ }}
217
+ data-testid={childTestId('slider')}>
218
+ <div
219
+ className="flex items-center"
220
+ style={{
221
+ height: LEVEL_SLIDER_ROW_HEIGHT,
222
+ paddingLeft: LEVEL_ROW_INSET,
223
+ paddingRight: LEVEL_ROW_INSET,
224
+ }}>
225
+ <div
226
+ ref={trackRef}
227
+ role="slider"
228
+ tabIndex={isExpanded ? -1 : 0}
229
+ aria-label={accessibilityLabel}
230
+ aria-valuemin={0}
231
+ aria-valuemax={levels.length - 1}
232
+ aria-valuenow={value}
233
+ aria-valuetext={levels[value]}
234
+ onPointerEnter={enterPointer}
235
+ onPointerLeave={() => {
236
+ if (!dragging) leavePointer();
237
+ }}
238
+ onPointerDown={(event) => {
239
+ event.currentTarget.setPointerCapture(event.pointerId);
240
+ setDragging(true);
241
+ enterPointer();
242
+ commitPointer(event);
243
+ }}
244
+ onPointerMove={(event) => {
245
+ if (dragging) commitPointer(event);
246
+ }}
247
+ onPointerUp={(event) => {
248
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
249
+ event.currentTarget.releasePointerCapture(event.pointerId);
250
+ }
251
+ setDragging(false);
252
+ }}
253
+ onPointerCancel={() => setDragging(false)}
254
+ onFocus={focusTrack}
255
+ onBlur={blurTrack}
256
+ onKeyDown={onKeyDown}
257
+ className={cx(LEVEL_TRACK_CLASS, TRACK_CLASS)}
258
+ style={{ height: LEVEL_TRACK_HEIGHT }}
259
+ data-testid={childTestId('track')}>
260
+ <span
261
+ aria-hidden="true"
262
+ className={LEVEL_FILL_CLASS}
263
+ style={{ top: 0, bottom: 0, width: positionOf(value) }}
264
+ />
265
+ {levels.map((label, index) => (
266
+ <span
267
+ key={label}
268
+ aria-hidden="true"
269
+ className={index <= value ? LEVEL_STOP_REACHED_CLASS : LEVEL_STOP_CLASS}
270
+ style={{
271
+ width: LEVEL_STOP_SIZE,
272
+ height: LEVEL_STOP_SIZE,
273
+ top: '50%',
274
+ left: positionOf(index),
275
+ transform: 'translate(-50%, -50%)',
276
+ }}
277
+ />
278
+ ))}
279
+ <span
280
+ aria-hidden="true"
281
+ className={LEVEL_THUMB_CLASS}
282
+ style={{
283
+ width: LEVEL_THUMB_SIZE,
284
+ height: LEVEL_THUMB_SIZE,
285
+ top: '50%',
286
+ left: positionOf(value),
287
+ transform: 'translate(-50%, -50%)',
288
+ }}
289
+ data-testid={childTestId('thumb')}
290
+ />
291
+ </div>
292
+ </div>
293
+ </div>
294
+
295
+ {/* The summary row, with the end captions riding over it: one is showing
296
+ whenever the other is not. NOT `inert` while the captions are up —
297
+ the row is only out of the POINTER's way, and a screen-reader user is
298
+ not the one hovering the rail. */}
299
+ <div className="relative">
300
+ <MenuRowShell
301
+ role="menuitem"
302
+ expanded={isExpanded}
303
+ disabled={false}
304
+ title={detailsLabel}
305
+ trailing={
306
+ <MenuRowChevron>
307
+ <span
308
+ className="flex items-center justify-center"
309
+ style={{
310
+ ...MOTION,
311
+ transitionProperty: 'transform',
312
+ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
313
+ }}>
314
+ <ChevronRightIcon
315
+ width={ROW_ICON_SIZE}
316
+ height={ROW_ICON_SIZE}
317
+ fill={theme.colors.textSecondary}
318
+ />
319
+ </span>
320
+ </MenuRowChevron>
321
+ }
322
+ onPress={() => setExpanded(!isExpanded)}
323
+ className={SUB_TRIGGER_CLASS}
324
+ style={summaryStyle}
325
+ testID={childTestId('summary')}
326
+ />
327
+ {hasCaptions ? (
328
+ <div
329
+ aria-hidden="true"
330
+ className="pointer-events-none absolute inset-0 flex flex-row items-center justify-between"
331
+ style={{
332
+ ...MOTION,
333
+ transitionProperty: 'opacity',
334
+ opacity: active ? 1 : 0,
335
+ paddingLeft: LEVEL_ROW_INSET,
336
+ paddingRight: LEVEL_ROW_INSET,
337
+ }}
338
+ data-testid={childTestId('captions')}>
339
+ <span className={LEVEL_CAPTION_CLASS}>{minLabel}</span>
340
+ <span className={LEVEL_CAPTION_CLASS}>{maxLabel}</span>
341
+ </div>
342
+ ) : null}
343
+ </div>
344
+
345
+ {/* The details. `inert` while collapsed, so a sub-menu row inside a
346
+ zero-height clip is not still tabbable. */}
347
+ <div
348
+ inert={!isExpanded}
349
+ aria-hidden={!isExpanded}
350
+ className="overflow-hidden"
351
+ style={{
352
+ ...MOTION,
353
+ transitionProperty: 'max-height, opacity',
354
+ maxHeight: isExpanded ? (detailsHeight === 0 ? undefined : detailsHeight) : 0,
355
+ opacity: isExpanded ? 1 : 0,
356
+ }}
357
+ data-testid={childTestId('details')}>
358
+ <div ref={measureDetails}>
359
+ <div
360
+ className={LEVEL_SEPARATOR_CLASS}
361
+ style={{
362
+ marginLeft: LEVEL_ROW_INSET,
363
+ marginRight: LEVEL_ROW_INSET,
364
+ marginBottom: 4,
365
+ }}
366
+ />
367
+ {children}
368
+ </div>
369
+ </div>
370
+ </div>
371
+ );
372
+ }
373
+
374
+ LevelPicker.displayName = 'LevelPicker';
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The level picker's SPEC: the geometry both forks lay out from, the colour
3
+ * roles as Tailwind classes, and the two functions that map a stop to a
4
+ * position and a pointer back to a stop.
5
+ *
6
+ * ── WHY GEOMETRY IS NUMBERS HERE AND COLOUR IS CLASSES ───────────────────────
7
+ *
8
+ * `floating/constants.ts` states the menu vocabulary as class strings, so a
9
+ * consumer can restyle a row with a utility. That reasoning applies to the
10
+ * parts a consumer addresses — and none of these are: the rail, its stops and
11
+ * its knob are one control's internals, and the caller's `className` lands on
12
+ * the picker's ROOT.
13
+ *
14
+ * What it buys instead is the property that matters most for a two-fork
15
+ * component: the web fork positions a stop with `calc()` and the native fork
16
+ * with a measured pixel offset, and BOTH read the numbers below and call
17
+ * {@link levelStopPosition}. Two forks that agreed only by having the same
18
+ * literals typed into them is exactly the drift `MENU_SUB_SIDE_OFFSET` and
19
+ * `SUB_TRIGGER_CLASS` exist to prevent one level up.
20
+ *
21
+ * Colour stays in classes, because that is what the surrounding menu rows are
22
+ * painted with and because a token is never derived from — `bg-muted-foreground/25`
23
+ * is the opacity UTILITY (`color-mix`, resolved by the build), never an alpha
24
+ * appended to a resolved `rgb(...)`, which parses back opaque.
25
+ */
26
+
27
+ /* -------------------------------------------------------------------------- */
28
+ /* Geometry — numbers, because both forks compute with them */
29
+ /* -------------------------------------------------------------------------- */
30
+
31
+ /** The rail: a 24px pill, tall enough to read as a track rather than a line. */
32
+ export const LEVEL_TRACK_HEIGHT = 24;
33
+
34
+ /**
35
+ * The knob, LARGER than the rail on purpose (30 against 24), so it reads as a
36
+ * control riding the track rather than a dot inside it. It therefore overhangs
37
+ * the rail by 3px top and bottom, and by 2px past each end once
38
+ * {@link LEVEL_STOP_INSET} is applied — which is why nothing between the rail
39
+ * and the picker's root may clip.
40
+ */
41
+ export const LEVEL_THUMB_SIZE = 30;
42
+
43
+ /** A stop: a 4px dot, drawn under the knob. */
44
+ export const LEVEL_STOP_SIZE = 4;
45
+
46
+ /**
47
+ * How far the FIRST and LAST stop sit in from the rail's ends.
48
+ *
49
+ * Not a cosmetic margin: it is what stops the knob from hanging half off the
50
+ * rail at either extreme, so it is also the value {@link levelFromOffset} has
51
+ * to subtract before it can read a fraction — a hit map that ignores it snaps
52
+ * to the wrong stop for the first and last {@link LEVEL_STOP_INSET} pixels,
53
+ * which is precisely where a user aiming for "least" or "most" clicks.
54
+ */
55
+ export const LEVEL_STOP_INSET = 13;
56
+
57
+ /** The row the rail sits in — one Bloom row taller than the rail itself. */
58
+ export const LEVEL_SLIDER_ROW_HEIGHT = 40;
59
+
60
+ /**
61
+ * The horizontal inset of everything in the picker that is not a menu row,
62
+ * matching `floating/constants`' `ROW_CLASS` (`px-space-8`) so the rail and the
63
+ * end captions line up with the rows above and below them.
64
+ */
65
+ export const LEVEL_ROW_INSET = 8;
66
+
67
+ /* -------------------------------------------------------------------------- */
68
+ /* Colour roles — classes, as the surrounding menu rows are */
69
+ /* -------------------------------------------------------------------------- */
70
+
71
+ /** The unfilled rail. */
72
+ export const LEVEL_TRACK_CLASS = 'relative rounded-full bg-muted-foreground/25';
73
+
74
+ /** The filled part, which ends under the knob rather than at the rail's end. */
75
+ export const LEVEL_FILL_CLASS = 'absolute left-0 rounded-full bg-primary';
76
+
77
+ /** A stop the level has NOT reached: a dot on the bare rail. */
78
+ export const LEVEL_STOP_CLASS = 'absolute rounded-full bg-muted-foreground/65';
79
+
80
+ /** A stop the level HAS reached: the same dot, on the fill, in its on-colour. */
81
+ export const LEVEL_STOP_REACHED_CLASS = 'absolute rounded-full bg-primary-foreground/35';
82
+
83
+ /** The knob. `shadow-s` is Bloom's control-raise role, the same one `Slider` takes. */
84
+ export const LEVEL_THUMB_CLASS = 'absolute rounded-full bg-background shadow-s';
85
+
86
+ /** The two end captions, one de-emphasis step back, as a menu shortcut is. */
87
+ export const LEVEL_CAPTION_CLASS = 'text-xs text-muted-foreground';
88
+
89
+ /** The hairline between the summary row and the details region. */
90
+ export const LEVEL_SEPARATOR_CLASS = 'h-px bg-border';
91
+
92
+ /* -------------------------------------------------------------------------- */
93
+ /* The two mappings, written once for both forks */
94
+ /* -------------------------------------------------------------------------- */
95
+
96
+ /**
97
+ * Where a stop sits along the rail, as a PERCENTAGE of the rail plus a pixel
98
+ * OFFSET — the one form both forks can render from.
99
+ *
100
+ * The web fork spells it `calc(P% + Opx)` and never measures; the native fork
101
+ * multiplies the percentage by a laid-out width. Returning the pair rather than
102
+ * a finished value is what lets one formula serve both: a shared function that
103
+ * returned pixels would force the web fork to measure, and one that returned a
104
+ * CSS string would be unusable on native.
105
+ */
106
+ export function levelStopPosition(
107
+ index: number,
108
+ count: number,
109
+ ): { percent: number; offset: number } {
110
+ const fraction = count <= 1 ? 0 : index / (count - 1);
111
+ return {
112
+ percent: fraction * 100,
113
+ offset: LEVEL_STOP_INSET - fraction * 2 * LEVEL_STOP_INSET,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Which stop a pointer at `offsetX` from the rail's left edge is asking for.
119
+ *
120
+ * The inverse of {@link levelStopPosition}, and it undoes the inset for the
121
+ * reason spelled out there. `trackWidth` is the rail's own width — the caller
122
+ * measures it (`getBoundingClientRect().width` on web, `onLayout` on native),
123
+ * so this stays pure and testable.
124
+ */
125
+ export function levelFromOffset(offsetX: number, trackWidth: number, count: number): number {
126
+ if (count <= 1) return 0;
127
+ const usable = trackWidth - 2 * LEVEL_STOP_INSET;
128
+ const fraction = usable <= 0 ? 0 : (offsetX - LEVEL_STOP_INSET) / usable;
129
+ const index = Math.round(fraction * (count - 1));
130
+ return Math.max(0, Math.min(count - 1, index));
131
+ }
@@ -0,0 +1,2 @@
1
+ export { LevelPicker } from './LevelPicker';
2
+ export type { LevelPickerProps } from './types';
@@ -0,0 +1,2 @@
1
+ export { LevelPicker } from './LevelPicker.web';
2
+ export type { LevelPickerProps } from './types';
@@ -0,0 +1,63 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
3
+
4
+ export interface LevelPickerProps {
5
+ /**
6
+ * The levels, in order, low to high. Its LENGTH is the number of stops and
7
+ * each entry is that stop's own name — read out as the slider's value
8
+ * (`aria-valuetext`), so it is what a screen reader says instead of an index.
9
+ *
10
+ * Two levels is the floor at which the control means anything; one renders a
11
+ * rail with a single stop and no travel.
12
+ */
13
+ levels: readonly string[];
14
+ /** The selected level, as an index into {@link levels}. Controlled. */
15
+ value: number;
16
+ /** Fired with the new index as the user drags, clicks or steps the rail. */
17
+ onValueChange: (value: number) => void;
18
+ /**
19
+ * The slider's accessible NAME — WHAT the level applies to.
20
+ *
21
+ * Required, not optional. A rail with a knob renders no text of its own, so
22
+ * ARIA can compute no name from its contents and there is nothing for the
23
+ * value to be announced against: without this the control says "slider, High"
24
+ * and never what is High. The end captions and the summary row beside it are
25
+ * SIBLINGS, not labels.
26
+ */
27
+ accessibilityLabel: string;
28
+ /**
29
+ * Captions for the two ends of the rail, shown while the slider is active
30
+ * (hovered, focused or being dragged) and faded out otherwise, in the place
31
+ * the summary row occupies.
32
+ *
33
+ * Both are optional and independent of {@link levels}: they name the
34
+ * DIRECTION of the scale ("Cheaper"/"Sharper"), which is a different claim
35
+ * from what any one stop is called, and a picker whose levels speak for
36
+ * themselves can leave them off entirely.
37
+ */
38
+ minLabel?: string;
39
+ maxLabel?: string;
40
+ /**
41
+ * The summary row's label — the disclosure that reveals {@link children}.
42
+ * Defaults to `Details`, matching the `<details>`/`<summary>` vocabulary this
43
+ * is the same shape as.
44
+ */
45
+ detailsLabel?: string;
46
+ /** Whether the details region is showing. Controlled. */
47
+ expanded?: boolean;
48
+ /** Its initial state when uncontrolled. Defaults to `false`. */
49
+ defaultExpanded?: boolean;
50
+ onExpandedChange?: (expanded: boolean) => void;
51
+ /**
52
+ * The details region's contents — the rows the summary row reveals.
53
+ *
54
+ * Ordinary menu rows: `DropdownMenuSub` triggers, `DropdownMenuItem`s,
55
+ * anything the surrounding surface publishes. The picker owns the reveal, the
56
+ * spacing and the hairline above them, and nothing about what they are.
57
+ */
58
+ children?: ReactNode;
59
+ /** Appended to the root's own classes, never substituted for them. */
60
+ className?: string;
61
+ style?: StyleProp<ViewStyle>;
62
+ testID?: string;
63
+ }