@oxyhq/bloom 1.0.5 → 1.1.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 (97) hide show
  1. package/docs/level-picker.mdx +105 -0
  2. package/docs/slider.mdx +3 -1
  3. package/lib/commonjs/floating/index.js.map +1 -1
  4. package/lib/commonjs/floating/menu-sub-flyout.js +77 -5
  5. package/lib/commonjs/floating/menu-sub-flyout.js.map +1 -1
  6. package/lib/commonjs/index.js +8 -0
  7. package/lib/commonjs/index.js.map +1 -1
  8. package/lib/commonjs/index.web.js +33 -25
  9. package/lib/commonjs/index.web.js.map +1 -1
  10. package/lib/commonjs/level-picker/LevelPicker.js +349 -0
  11. package/lib/commonjs/level-picker/LevelPicker.js.map +1 -0
  12. package/lib/commonjs/level-picker/LevelPicker.web.js +346 -0
  13. package/lib/commonjs/level-picker/LevelPicker.web.js.map +1 -0
  14. package/lib/commonjs/level-picker/constants.js +137 -0
  15. package/lib/commonjs/level-picker/constants.js.map +1 -0
  16. package/lib/commonjs/level-picker/index.js +13 -0
  17. package/lib/commonjs/level-picker/index.js.map +1 -0
  18. package/lib/commonjs/level-picker/index.web.js +13 -0
  19. package/lib/commonjs/level-picker/index.web.js.map +1 -0
  20. package/lib/commonjs/level-picker/types.js +6 -0
  21. package/lib/commonjs/level-picker/types.js.map +1 -0
  22. package/lib/module/floating/index.js +5 -0
  23. package/lib/module/floating/index.js.map +1 -1
  24. package/lib/module/floating/menu-sub-flyout.js +77 -5
  25. package/lib/module/floating/menu-sub-flyout.js.map +1 -1
  26. package/lib/module/index.js +1 -0
  27. package/lib/module/index.js.map +1 -1
  28. package/lib/module/index.web.js +1 -0
  29. package/lib/module/index.web.js.map +1 -1
  30. package/lib/module/level-picker/LevelPicker.js +343 -0
  31. package/lib/module/level-picker/LevelPicker.js.map +1 -0
  32. package/lib/module/level-picker/LevelPicker.web.js +340 -0
  33. package/lib/module/level-picker/LevelPicker.web.js.map +1 -0
  34. package/lib/module/level-picker/constants.js +131 -0
  35. package/lib/module/level-picker/constants.js.map +1 -0
  36. package/lib/module/level-picker/index.js +4 -0
  37. package/lib/module/level-picker/index.js.map +1 -0
  38. package/lib/module/level-picker/index.web.js +4 -0
  39. package/lib/module/level-picker/index.web.js.map +1 -0
  40. package/lib/module/level-picker/types.js +4 -0
  41. package/lib/module/level-picker/types.js.map +1 -0
  42. package/lib/typescript/commonjs/floating/index.d.ts +5 -0
  43. package/lib/typescript/commonjs/floating/index.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/floating/menu-sub-flyout.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/index.d.ts +2 -0
  46. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/index.web.d.ts +2 -0
  48. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/level-picker/LevelPicker.d.ts +6 -0
  50. package/lib/typescript/commonjs/level-picker/LevelPicker.d.ts.map +1 -0
  51. package/lib/typescript/commonjs/level-picker/LevelPicker.web.d.ts +6 -0
  52. package/lib/typescript/commonjs/level-picker/LevelPicker.web.d.ts.map +1 -0
  53. package/lib/typescript/commonjs/level-picker/constants.d.ts +93 -0
  54. package/lib/typescript/commonjs/level-picker/constants.d.ts.map +1 -0
  55. package/lib/typescript/commonjs/level-picker/index.d.ts +3 -0
  56. package/lib/typescript/commonjs/level-picker/index.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/level-picker/index.web.d.ts +3 -0
  58. package/lib/typescript/commonjs/level-picker/index.web.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/level-picker/types.d.ts +63 -0
  60. package/lib/typescript/commonjs/level-picker/types.d.ts.map +1 -0
  61. package/lib/typescript/module/floating/index.d.ts +5 -0
  62. package/lib/typescript/module/floating/index.d.ts.map +1 -1
  63. package/lib/typescript/module/floating/menu-sub-flyout.d.ts.map +1 -1
  64. package/lib/typescript/module/index.d.ts +2 -0
  65. package/lib/typescript/module/index.d.ts.map +1 -1
  66. package/lib/typescript/module/index.web.d.ts +2 -0
  67. package/lib/typescript/module/index.web.d.ts.map +1 -1
  68. package/lib/typescript/module/level-picker/LevelPicker.d.ts +6 -0
  69. package/lib/typescript/module/level-picker/LevelPicker.d.ts.map +1 -0
  70. package/lib/typescript/module/level-picker/LevelPicker.web.d.ts +6 -0
  71. package/lib/typescript/module/level-picker/LevelPicker.web.d.ts.map +1 -0
  72. package/lib/typescript/module/level-picker/constants.d.ts +93 -0
  73. package/lib/typescript/module/level-picker/constants.d.ts.map +1 -0
  74. package/lib/typescript/module/level-picker/index.d.ts +3 -0
  75. package/lib/typescript/module/level-picker/index.d.ts.map +1 -0
  76. package/lib/typescript/module/level-picker/index.web.d.ts +3 -0
  77. package/lib/typescript/module/level-picker/index.web.d.ts.map +1 -0
  78. package/lib/typescript/module/level-picker/types.d.ts +63 -0
  79. package/lib/typescript/module/level-picker/types.d.ts.map +1 -0
  80. package/package.json +20 -1
  81. package/src/floating/index.ts +5 -0
  82. package/src/floating/menu-sub-flyout.tsx +97 -6
  83. package/src/index.ts +2 -0
  84. package/src/index.web.ts +2 -0
  85. package/src/level-picker/LevelPicker.tsx +365 -0
  86. package/src/level-picker/LevelPicker.web.tsx +374 -0
  87. package/src/level-picker/constants.ts +131 -0
  88. package/src/level-picker/index.ts +2 -0
  89. package/src/level-picker/index.web.ts +2 -0
  90. package/src/level-picker/types.ts +63 -0
  91. package/src/__tests__/support/collision-fixture-barrel.ts +0 -20
  92. package/src/__tests__/support/constructed-style-sheets.ts +0 -68
  93. package/src/__tests__/support/press-host.ts +0 -30
  94. package/src/__tests__/support/rendered-style.ts +0 -99
  95. package/src/__tests__/support/unread-hook-fixture.ts +0 -33
  96. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +0 -7682
  97. package/src/theme/__tests__/fixtures/color-engine-golden.json +0 -1
@@ -117,9 +117,55 @@ function isHoverPointer(event: Event): boolean {
117
117
  return pointerType === 'mouse' || pointerType === 'pen';
118
118
  }
119
119
 
120
+ /** Every role a menu ROW can carry — the rows a pointer can actually land on. */
121
+ const MENU_ITEM_SELECTOR =
122
+ '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="checkbox"], [role="radio"]';
123
+
124
+ /**
125
+ * Did the pointer leave for another ROW, rather than for the gap this sub's
126
+ * grace delay exists to cover?
127
+ *
128
+ * {@link CLOSE_DELAY_MS} buys time for ONE journey: the diagonal from a trigger
129
+ * row to its own flyout, across a strip where neither surface is hovered. Every
130
+ * other departure is unambiguous the moment it happens, and the most visible one
131
+ * is a SIBLING sub-trigger: two subs in a panel each own their state and their
132
+ * own timer, so the one being left served out its full delay while the one being
133
+ * entered had already opened — 300ms of two overlapping flyouts, which is not a
134
+ * grace period but a bug.
135
+ *
136
+ * "Another row" means the pointer landed on an actual menu ITEM that belongs to
137
+ * neither this sub's trigger nor its flyout. Testing for an ITEM rather than for
138
+ * a `[role="menu"]` ancestor is the whole subtlety: the diagonal to the flyout
139
+ * crosses the parent panel's own padding, which IS inside a `[role="menu"]` but
140
+ * is not a row — measured, a panel-ancestor test dismissed the flyout mid-journey
141
+ * and broke the very case the delay exists for.
142
+ *
143
+ * Both exclusions are load-bearing. Without the flyout check, arriving at the
144
+ * panel's first row would close the panel the move was aimed at. Without the
145
+ * trigger check, moving from the panel back onto its own trigger would close and
146
+ * immediately re-open, which flickers.
147
+ *
148
+ * A departure with no `relatedTarget` — off the window, or the node under the
149
+ * pointer being torn down — is NOT another row, so it keeps the delay and
150
+ * behaviour there is what it was.
151
+ */
152
+ function leavesForAnotherRow(
153
+ event: Event,
154
+ ownFlyout: DomNode | null,
155
+ ownTrigger: DomNode | null,
156
+ ): boolean {
157
+ const next = (event as PointerEvent).relatedTarget;
158
+ if (!(next instanceof Element)) return false;
159
+ const row = next.closest(MENU_ITEM_SELECTOR);
160
+ if (!row) return false;
161
+ if (ownFlyout?.contains(row as HTMLElement)) return false;
162
+ if (ownTrigger?.contains(row as HTMLElement)) return false;
163
+ return true;
164
+ }
165
+
120
166
  /** The first focusable descendant, so Right lands ON the panel's first row. */
121
167
  function focusFirstItem(node: DomNode | null): void {
122
- const first = node?.querySelector<HTMLElement>('[role="menuitem"], [role="menuitemcheckbox"], [role="checkbox"], [role="radio"]');
168
+ const first = node?.querySelector<HTMLElement>(MENU_ITEM_SELECTOR);
123
169
  first?.focus?.();
124
170
  }
125
171
 
@@ -235,10 +281,22 @@ interface SubFlyoutContextValue {
235
281
  setOpen: (next: boolean) => void;
236
282
  /** The trigger row's wrapper, measured to anchor the panel. */
237
283
  triggerRef: React.RefObject<View | null>;
284
+ /**
285
+ * The flyout's scroller, so the TRIGGER can tell a pointer heading for its own
286
+ * panel from one heading for a sibling row. Written by `MenuSubContent`; null
287
+ * whenever the panel is closed, which is exactly when there is nothing to
288
+ * cross to.
289
+ */
290
+ contentRef: React.RefObject<View | null>;
238
291
  /** Cancel a scheduled close — the pointer arrived on one of the two surfaces. */
239
292
  keepOpen: () => void;
240
293
  /** Schedule a close — the pointer left one of them, and may be crossing to the other. */
241
294
  closeSoon: () => void;
295
+ /**
296
+ * Close immediately, WITHOUT moving focus — the pointer has already landed on
297
+ * another row, and refocusing this trigger would take focus off it.
298
+ */
299
+ closeNow: () => void;
242
300
  /** Close now and put focus back on the trigger row (Left, Escape). */
243
301
  closeAndRefocus: () => void;
244
302
  }
@@ -263,6 +321,7 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
263
321
  onChange: onOpenChange,
264
322
  });
265
323
  const triggerRef = useRef<View | null>(null);
324
+ const contentRef = useRef<View | null>(null);
266
325
  const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
267
326
 
268
327
  const cancelTimer = useCallback(() => {
@@ -291,6 +350,11 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
291
350
  }, CLOSE_DELAY_MS);
292
351
  }, [cancelTimer, setOpen]);
293
352
 
353
+ const closeNow = useCallback(() => {
354
+ cancelTimer();
355
+ setOpen(false);
356
+ }, [cancelTimer, setOpen]);
357
+
294
358
  const closeAndRefocus = useCallback(() => {
295
359
  cancelTimer();
296
360
  setOpen(false);
@@ -299,8 +363,8 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
299
363
  }, [cancelTimer, setOpen]);
300
364
 
301
365
  const context = useMemo<SubFlyoutContextValue>(
302
- () => ({ open: isOpen, setOpen, triggerRef, keepOpen, closeSoon, closeAndRefocus }),
303
- [isOpen, setOpen, keepOpen, closeSoon, closeAndRefocus],
366
+ () => ({ open: isOpen, setOpen, triggerRef, contentRef, keepOpen, closeSoon, closeNow, closeAndRefocus }),
367
+ [isOpen, setOpen, keepOpen, closeSoon, closeNow, closeAndRefocus],
304
368
  );
305
369
 
306
370
  return <SubFlyoutContext.Provider value={context}>{children}</SubFlyoutContext.Provider>;
@@ -344,7 +408,14 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
344
408
  if (isHoverPointer(event)) sub.keepOpen();
345
409
  };
346
410
  const onLeave = (event: Event) => {
347
- if (isHoverPointer(event)) sub.closeSoon();
411
+ if (!isHoverPointer(event)) return;
412
+ // Straight onto another row of the parent panel — a sibling trigger, or
413
+ // any plain item — is not the diagonal the delay exists for.
414
+ if (leavesForAnotherRow(event, domNode(sub.contentRef.current), element)) {
415
+ sub.closeNow();
416
+ return;
417
+ }
418
+ sub.closeSoon();
348
419
  };
349
420
  const onKeyDown = (event: Event) => {
350
421
  const key = (event as KeyboardEvent).key;
@@ -418,6 +489,18 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
418
489
  const anchor = useFlyoutAnchor(sub.triggerRef, sub.open);
419
490
  const [node, setNode] = useState<View | null>(null);
420
491
 
492
+ // State for this component's own effects, a ref for the TRIGGER's leave
493
+ // handler: that handler runs from a DOM listener, outside React's render,
494
+ // so it needs a value it can read at event time rather than one captured
495
+ // when the listener was attached.
496
+ const attachContent = useCallback(
497
+ (value: View | null) => {
498
+ setNode(value);
499
+ sub.contentRef.current = value;
500
+ },
501
+ [sub.contentRef],
502
+ );
503
+
421
504
  useEffect(() => {
422
505
  const element = domNode(node);
423
506
  if (!element || !sub.open) return;
@@ -426,7 +509,15 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
426
509
  if (isHoverPointer(event)) sub.keepOpen();
427
510
  };
428
511
  const onLeave = (event: Event) => {
429
- if (isHoverPointer(event)) sub.closeSoon();
512
+ if (!isHoverPointer(event)) return;
513
+ // Leaving the flyout for a row of the parent panel is just as
514
+ // unambiguous as leaving the trigger for one: the pointer has arrived
515
+ // somewhere, and it is not this sub.
516
+ if (leavesForAnotherRow(event, element, domNode(sub.triggerRef.current))) {
517
+ sub.closeNow();
518
+ return;
519
+ }
520
+ sub.closeSoon();
430
521
  };
431
522
  const onKeyDown = (event: Event) => {
432
523
  const key = (event as KeyboardEvent).key;
@@ -498,7 +589,7 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
498
589
  flyout's pointer hit box — without it that 4px ring is a place where
499
590
  the pointer is over the panel but over nothing listening, and a
500
591
  pointer resting there would schedule a close. */}
501
- <StyledView ref={setNode} className={MENU_SUB_SCROLL_CLASS}>
592
+ <StyledView ref={attachContent} className={MENU_SUB_SCROLL_CLASS}>
502
593
  {children}
503
594
  </StyledView>
504
595
  </FloatingPanel>
package/src/index.ts CHANGED
@@ -233,6 +233,8 @@ export { InputGroup, InputGroupAddon } from './input-group';
233
233
  export type { InputGroupProps, InputGroupAddonProps } from './input-group';
234
234
  export { Slider } from './slider';
235
235
  export type { SliderProps } from './slider';
236
+ export { LevelPicker } from './level-picker';
237
+ export type { LevelPickerProps } from './level-picker';
236
238
  export { Combobox } from './combobox';
237
239
  export type { ComboboxProps, ComboboxOption } from './combobox';
238
240
 
package/src/index.web.ts CHANGED
@@ -238,6 +238,8 @@ export { InputGroup, InputGroupAddon } from './input-group';
238
238
  export type { InputGroupProps, InputGroupAddonProps } from './input-group';
239
239
  export { Slider } from './slider';
240
240
  export type { SliderProps } from './slider';
241
+ export { LevelPicker } from './level-picker/index.web';
242
+ export type { LevelPickerProps } from './level-picker/index.web';
241
243
  export { Combobox } from './combobox/index.web';
242
244
  export type { ComboboxProps, ComboboxOption } from './combobox/index.web';
243
245
 
@@ -0,0 +1,365 @@
1
+ /**
2
+ * `LevelPicker` — NATIVE. A stepped level slider over a disclosure that reveals
3
+ * a region of detail rows, laid out for the surface a native menu IS: a sheet.
4
+ *
5
+ * The web fork (`LevelPicker.web.tsx`) is the second half, and the split is not
6
+ * cosmetic. Three things differ, and each of them is the platform:
7
+ *
8
+ * 1. **The details region holds sub-menus, and a native sub-menu is an INLINE
9
+ * disclosure** (`floating/menu-sub-inline.tsx`) that grows the region when
10
+ * it opens. The web fork can therefore lay its regions out against a height
11
+ * it computes; this one cannot, so the picker is a plain column and every
12
+ * reveal is measured (`onLayout`) rather than assumed.
13
+ * 2. **There is no hover.** The end captions swap in while the rail is being
14
+ * DRAGGED here, which is the only "the user is working the slider" signal a
15
+ * touch screen has; the web fork adds pointer-over and keyboard focus.
16
+ * 3. **The gesture is a `PanResponder`**, not pointer capture, and the keyboard
17
+ * is an accessibility ACTION (`increment`/`decrement`) rather than arrow
18
+ * keys, because that is what an `adjustable` role exposes to VoiceOver and
19
+ * TalkBack.
20
+ *
21
+ * Everything both forks agree about — the stops, the inset, the hit mapping,
22
+ * the colour roles — is in `constants.ts` and is read from there by both, so
23
+ * the two cannot drift into disagreeing about the same control.
24
+ */
25
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
26
+ import {
27
+ Animated,
28
+ PanResponder,
29
+ type AccessibilityActionEvent,
30
+ type GestureResponderEvent,
31
+ type LayoutChangeEvent,
32
+ type PanResponderGestureState,
33
+ } from 'react-native';
34
+
35
+ import { ROW_ICON_SIZE } from '../floating/constants';
36
+ import { cx, MenuRowChevron, MenuRowShell, SUB_TRIGGER_CLASS } from '../floating/shared';
37
+ import { useControllableState } from '../hooks/use-controllable-state';
38
+ import { ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon } from '../icons/Chevron';
39
+ import { StyledText, StyledView } from '../styles/styled-primitives';
40
+ import { animation } from '../styles/tokens';
41
+ import { useTheme } from '../theme/use-theme';
42
+ import {
43
+ LEVEL_CAPTION_CLASS,
44
+ LEVEL_FILL_CLASS,
45
+ LEVEL_ROW_INSET,
46
+ LEVEL_SEPARATOR_CLASS,
47
+ LEVEL_SLIDER_ROW_HEIGHT,
48
+ LEVEL_STOP_CLASS,
49
+ LEVEL_STOP_REACHED_CLASS,
50
+ LEVEL_STOP_SIZE,
51
+ LEVEL_THUMB_CLASS,
52
+ LEVEL_THUMB_SIZE,
53
+ LEVEL_TRACK_CLASS,
54
+ LEVEL_TRACK_HEIGHT,
55
+ levelFromOffset,
56
+ levelStopPosition,
57
+ } from './constants';
58
+ import type { LevelPickerProps } from './types';
59
+
60
+ /**
61
+ * Built at MODULE scope, from `StyledView`, so one node carries the transform,
62
+ * the class and the style — `button/Button.tsx`'s rule, for its reasons: an
63
+ * element type constructed during render remounts its subtree every frame, and
64
+ * a second layout node would make LAYOUT classes inert on native.
65
+ */
66
+ const AnimatedStyledView = Animated.createAnimatedComponent(StyledView);
67
+
68
+ /**
69
+ * Drive a 0→1 `Animated.Value` from a boolean.
70
+ *
71
+ * `useNativeDriver` is off because one of the two callers animates `maxHeight`,
72
+ * which the native driver cannot own; the other animates opacity and could,
73
+ * but a single shape for both is worth more than the frame it costs on a
74
+ * cross-fade. The spring is Bloom's `gentle`, the same one `Accordion`'s reveal
75
+ * takes — the web fork's `cubic-bezier` is a CSS spelling with no RN equivalent,
76
+ * so the two curves are deliberately each their platform's own.
77
+ */
78
+ function useRevealProgress(on: boolean): Animated.Value {
79
+ const progress = useRef(new Animated.Value(on ? 1 : 0)).current;
80
+ useEffect(() => {
81
+ Animated.spring(progress, {
82
+ toValue: on ? 1 : 0,
83
+ useNativeDriver: false,
84
+ ...animation.spring.gentle,
85
+ }).start();
86
+ }, [on, progress]);
87
+ return progress;
88
+ }
89
+
90
+ /**
91
+ * What an `adjustable` exposes to VoiceOver and TalkBack. Declared once at
92
+ * module scope: a fresh array each render is a new prop value every time.
93
+ */
94
+ const ADJUST_ACTIONS = [{ name: 'increment' }, { name: 'decrement' }] as const;
95
+
96
+ export function LevelPicker({
97
+ levels,
98
+ value,
99
+ onValueChange,
100
+ accessibilityLabel,
101
+ minLabel,
102
+ maxLabel,
103
+ detailsLabel = 'Details',
104
+ expanded,
105
+ defaultExpanded = false,
106
+ onExpandedChange,
107
+ children,
108
+ className,
109
+ style,
110
+ testID,
111
+ }: LevelPickerProps) {
112
+ const theme = useTheme();
113
+ const [isExpanded, setExpanded] = useControllableState<boolean>({
114
+ value: expanded,
115
+ defaultValue: defaultExpanded,
116
+ onChange: onExpandedChange,
117
+ });
118
+ const [trackWidth, setTrackWidth] = useState(0);
119
+ const [detailsHeight, setDetailsHeight] = useState(0);
120
+ const [dragging, setDragging] = useState(false);
121
+
122
+ const reveal = useRevealProgress(isExpanded);
123
+ const active = useRevealProgress(dragging);
124
+
125
+ // The latest props, read by a `PanResponder` that is created once. Read
126
+ // inside its CALLBACKS and never during render, so no memoized position ever
127
+ // holds a stale copy.
128
+ const stateRef = useRef({ value, trackWidth, count: levels.length });
129
+ stateRef.current = { value, trackWidth, count: levels.length };
130
+ const onValueChangeRef = useRef(onValueChange);
131
+ onValueChangeRef.current = onValueChange;
132
+ // Where the gesture started, so a MOVE can be resolved as that point plus the
133
+ // gesture's own `dx`. `locationX` is only trustworthy on the grant.
134
+ const grantOffsetRef = useRef(0);
135
+
136
+ const commitOffset = useCallback((offsetX: number) => {
137
+ const { value: current, trackWidth: width, count } = stateRef.current;
138
+ const next = levelFromOffset(offsetX, width, count);
139
+ if (next !== current) onValueChangeRef.current(next);
140
+ }, []);
141
+
142
+ const step = useCallback((delta: number) => {
143
+ const { value: current, count } = stateRef.current;
144
+ const next = Math.max(0, Math.min(count - 1, current + delta));
145
+ if (next !== current) onValueChangeRef.current(next);
146
+ }, []);
147
+
148
+ const panResponder = useMemo(
149
+ () =>
150
+ PanResponder.create({
151
+ onStartShouldSetPanResponder: () => true,
152
+ onMoveShouldSetPanResponder: () => true,
153
+ onPanResponderGrant: (event: GestureResponderEvent) => {
154
+ setDragging(true);
155
+ grantOffsetRef.current = event.nativeEvent.locationX;
156
+ commitOffset(grantOffsetRef.current);
157
+ },
158
+ onPanResponderMove: (
159
+ _event: GestureResponderEvent,
160
+ gesture: PanResponderGestureState,
161
+ ) => {
162
+ commitOffset(grantOffsetRef.current + gesture.dx);
163
+ },
164
+ onPanResponderRelease: () => setDragging(false),
165
+ onPanResponderTerminate: () => setDragging(false),
166
+ }),
167
+ [commitOffset],
168
+ );
169
+
170
+ const onTrackLayout = useCallback((event: LayoutChangeEvent) => {
171
+ const next = event.nativeEvent.layout.width;
172
+ setTrackWidth((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
173
+ }, []);
174
+
175
+ const onDetailsLayout = useCallback((event: LayoutChangeEvent) => {
176
+ const next = event.nativeEvent.layout.height;
177
+ // Sub-pixel churn would re-render on every frame of the spring.
178
+ setDetailsHeight((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
179
+ }, []);
180
+
181
+ const onAccessibilityAction = useCallback(
182
+ (event: AccessibilityActionEvent) => {
183
+ if (event.nativeEvent.actionName === 'increment') step(1);
184
+ if (event.nativeEvent.actionName === 'decrement') step(-1);
185
+ },
186
+ [step],
187
+ );
188
+
189
+ /** A stop's centre, in pixels from the rail's left edge. */
190
+ const offsetOf = (index: number): number => {
191
+ const { percent, offset } = levelStopPosition(index, levels.length);
192
+ return (percent / 100) * trackWidth + offset;
193
+ };
194
+
195
+ const hasCaptions = minLabel !== undefined || maxLabel !== undefined;
196
+ const childTestId = (part: string): string | undefined =>
197
+ testID === undefined ? undefined : `${testID}-${part}`;
198
+
199
+ return (
200
+ <StyledView className={cx('w-full', className)} style={style} testID={testID}>
201
+ {/* The rail, and the row it sits in, collapsing to nothing when the
202
+ details take its place. */}
203
+ <AnimatedStyledView
204
+ aria-hidden={isExpanded}
205
+ pointerEvents={isExpanded ? 'none' : 'auto'}
206
+ style={{
207
+ overflow: 'hidden',
208
+ opacity: reveal.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }),
209
+ maxHeight: reveal.interpolate({
210
+ inputRange: [0, 1],
211
+ outputRange: [LEVEL_SLIDER_ROW_HEIGHT, 0],
212
+ }),
213
+ }}
214
+ testID={childTestId('slider')}>
215
+ <StyledView
216
+ style={{
217
+ height: LEVEL_SLIDER_ROW_HEIGHT,
218
+ justifyContent: 'center',
219
+ paddingHorizontal: LEVEL_ROW_INSET,
220
+ }}>
221
+ <StyledView
222
+ accessibilityRole="adjustable"
223
+ accessibilityLabel={accessibilityLabel}
224
+ accessibilityActions={ADJUST_ACTIONS}
225
+ onAccessibilityAction={onAccessibilityAction}
226
+ // The FLAT `aria-value*` props: React Native folds them back into
227
+ // `accessibilityValue`, and they are the only spelling
228
+ // react-native-web reads. `slider/Slider.tsx` carries the same note.
229
+ aria-valuemin={0}
230
+ aria-valuemax={levels.length - 1}
231
+ aria-valuenow={value}
232
+ aria-valuetext={levels[value]}
233
+ onLayout={onTrackLayout}
234
+ className={LEVEL_TRACK_CLASS}
235
+ style={{ height: LEVEL_TRACK_HEIGHT }}
236
+ testID={childTestId('track')}
237
+ {...panResponder.panHandlers}>
238
+ {trackWidth > 0 ? (
239
+ <>
240
+ <StyledView
241
+ className={LEVEL_FILL_CLASS}
242
+ style={{ top: 0, bottom: 0, width: offsetOf(value) }}
243
+ />
244
+ {levels.map((label, index) => (
245
+ <StyledView
246
+ key={label}
247
+ className={index <= value ? LEVEL_STOP_REACHED_CLASS : LEVEL_STOP_CLASS}
248
+ style={{
249
+ width: LEVEL_STOP_SIZE,
250
+ height: LEVEL_STOP_SIZE,
251
+ top: (LEVEL_TRACK_HEIGHT - LEVEL_STOP_SIZE) / 2,
252
+ left: offsetOf(index) - LEVEL_STOP_SIZE / 2,
253
+ }}
254
+ />
255
+ ))}
256
+ <StyledView
257
+ className={LEVEL_THUMB_CLASS}
258
+ style={{
259
+ width: LEVEL_THUMB_SIZE,
260
+ height: LEVEL_THUMB_SIZE,
261
+ top: (LEVEL_TRACK_HEIGHT - LEVEL_THUMB_SIZE) / 2,
262
+ left: offsetOf(value) - LEVEL_THUMB_SIZE / 2,
263
+ }}
264
+ testID={childTestId('thumb')}
265
+ />
266
+ </>
267
+ ) : null}
268
+ </StyledView>
269
+ </StyledView>
270
+ </AnimatedStyledView>
271
+
272
+ {/* The summary row, with the end captions riding over it: one is showing
273
+ whenever the other is not. */}
274
+ <StyledView>
275
+ <AnimatedStyledView
276
+ // Only a drag takes the row out of the way — there is no hover here,
277
+ // and the row must stay pressable the moment the finger lifts.
278
+ pointerEvents={dragging ? 'none' : 'auto'}
279
+ style={{
280
+ opacity: active.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }),
281
+ }}>
282
+ <MenuRowShell
283
+ role="menuitem"
284
+ expanded={isExpanded}
285
+ disabled={false}
286
+ title={detailsLabel}
287
+ trailing={
288
+ <MenuRowChevron>
289
+ <AnimatedStyledView
290
+ style={{
291
+ transform: [
292
+ {
293
+ rotate: reveal.interpolate({
294
+ inputRange: [0, 1],
295
+ outputRange: ['0deg', '90deg'],
296
+ }),
297
+ },
298
+ ],
299
+ }}>
300
+ <ChevronRightIcon
301
+ width={ROW_ICON_SIZE}
302
+ height={ROW_ICON_SIZE}
303
+ fill={theme.colors.textSecondary}
304
+ />
305
+ </AnimatedStyledView>
306
+ </MenuRowChevron>
307
+ }
308
+ onPress={() => setExpanded(!isExpanded)}
309
+ className={SUB_TRIGGER_CLASS}
310
+ testID={childTestId('summary')}
311
+ />
312
+ </AnimatedStyledView>
313
+ {hasCaptions ? (
314
+ <AnimatedStyledView
315
+ aria-hidden
316
+ pointerEvents="none"
317
+ style={{
318
+ position: 'absolute',
319
+ top: 0,
320
+ bottom: 0,
321
+ left: 0,
322
+ right: 0,
323
+ flexDirection: 'row',
324
+ alignItems: 'center',
325
+ justifyContent: 'space-between',
326
+ paddingHorizontal: LEVEL_ROW_INSET,
327
+ opacity: active,
328
+ }}
329
+ testID={childTestId('captions')}>
330
+ <StyledText className={LEVEL_CAPTION_CLASS}>{minLabel}</StyledText>
331
+ <StyledText className={LEVEL_CAPTION_CLASS}>{maxLabel}</StyledText>
332
+ </AnimatedStyledView>
333
+ ) : null}
334
+ </StyledView>
335
+
336
+ {/* The details. Measured rather than assumed: an inline sub-menu opening
337
+ inside it changes its height, and only `onLayout` sees that. */}
338
+ <AnimatedStyledView
339
+ aria-hidden={!isExpanded}
340
+ pointerEvents={isExpanded ? 'auto' : 'none'}
341
+ style={{
342
+ overflow: 'hidden',
343
+ opacity: reveal,
344
+ // Before the first measurement an OPEN region must not be clipped to
345
+ // zero — `Accordion`'s rule, for its reason: the unmeasured frame
346
+ // takes the answer its state already implies.
347
+ maxHeight:
348
+ detailsHeight === 0 && isExpanded
349
+ ? undefined
350
+ : reveal.interpolate({ inputRange: [0, 1], outputRange: [0, detailsHeight] }),
351
+ }}
352
+ testID={childTestId('details')}>
353
+ <StyledView onLayout={onDetailsLayout}>
354
+ <StyledView
355
+ className={LEVEL_SEPARATOR_CLASS}
356
+ style={{ marginHorizontal: LEVEL_ROW_INSET, marginBottom: 4 }}
357
+ />
358
+ {children}
359
+ </StyledView>
360
+ </AnimatedStyledView>
361
+ </StyledView>
362
+ );
363
+ }
364
+
365
+ LevelPicker.displayName = 'LevelPicker';