@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.
- package/docs/level-picker.mdx +105 -0
- package/docs/slider.mdx +3 -1
- package/lib/commonjs/floating/index.js.map +1 -1
- package/lib/commonjs/floating/menu-sub-flyout.js +77 -5
- package/lib/commonjs/floating/menu-sub-flyout.js.map +1 -1
- package/lib/commonjs/index.js +8 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +33 -25
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/level-picker/LevelPicker.js +349 -0
- package/lib/commonjs/level-picker/LevelPicker.js.map +1 -0
- package/lib/commonjs/level-picker/LevelPicker.web.js +346 -0
- package/lib/commonjs/level-picker/LevelPicker.web.js.map +1 -0
- package/lib/commonjs/level-picker/constants.js +137 -0
- package/lib/commonjs/level-picker/constants.js.map +1 -0
- package/lib/commonjs/level-picker/index.js +13 -0
- package/lib/commonjs/level-picker/index.js.map +1 -0
- package/lib/commonjs/level-picker/index.web.js +13 -0
- package/lib/commonjs/level-picker/index.web.js.map +1 -0
- package/lib/commonjs/level-picker/types.js +6 -0
- package/lib/commonjs/level-picker/types.js.map +1 -0
- package/lib/module/floating/index.js +5 -0
- package/lib/module/floating/index.js.map +1 -1
- package/lib/module/floating/menu-sub-flyout.js +77 -5
- package/lib/module/floating/menu-sub-flyout.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +1 -0
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/level-picker/LevelPicker.js +343 -0
- package/lib/module/level-picker/LevelPicker.js.map +1 -0
- package/lib/module/level-picker/LevelPicker.web.js +340 -0
- package/lib/module/level-picker/LevelPicker.web.js.map +1 -0
- package/lib/module/level-picker/constants.js +131 -0
- package/lib/module/level-picker/constants.js.map +1 -0
- package/lib/module/level-picker/index.js +4 -0
- package/lib/module/level-picker/index.js.map +1 -0
- package/lib/module/level-picker/index.web.js +4 -0
- package/lib/module/level-picker/index.web.js.map +1 -0
- package/lib/module/level-picker/types.js +4 -0
- package/lib/module/level-picker/types.js.map +1 -0
- package/lib/typescript/commonjs/floating/index.d.ts +5 -0
- package/lib/typescript/commonjs/floating/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/floating/menu-sub-flyout.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +2 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.web.d.ts +2 -0
- package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/level-picker/LevelPicker.d.ts +6 -0
- package/lib/typescript/commonjs/level-picker/LevelPicker.d.ts.map +1 -0
- package/lib/typescript/commonjs/level-picker/LevelPicker.web.d.ts +6 -0
- package/lib/typescript/commonjs/level-picker/LevelPicker.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/level-picker/constants.d.ts +93 -0
- package/lib/typescript/commonjs/level-picker/constants.d.ts.map +1 -0
- package/lib/typescript/commonjs/level-picker/index.d.ts +3 -0
- package/lib/typescript/commonjs/level-picker/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/level-picker/index.web.d.ts +3 -0
- package/lib/typescript/commonjs/level-picker/index.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/level-picker/types.d.ts +63 -0
- package/lib/typescript/commonjs/level-picker/types.d.ts.map +1 -0
- package/lib/typescript/module/floating/index.d.ts +5 -0
- package/lib/typescript/module/floating/index.d.ts.map +1 -1
- package/lib/typescript/module/floating/menu-sub-flyout.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +2 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/index.web.d.ts +2 -0
- package/lib/typescript/module/index.web.d.ts.map +1 -1
- package/lib/typescript/module/level-picker/LevelPicker.d.ts +6 -0
- package/lib/typescript/module/level-picker/LevelPicker.d.ts.map +1 -0
- package/lib/typescript/module/level-picker/LevelPicker.web.d.ts +6 -0
- package/lib/typescript/module/level-picker/LevelPicker.web.d.ts.map +1 -0
- package/lib/typescript/module/level-picker/constants.d.ts +93 -0
- package/lib/typescript/module/level-picker/constants.d.ts.map +1 -0
- package/lib/typescript/module/level-picker/index.d.ts +3 -0
- package/lib/typescript/module/level-picker/index.d.ts.map +1 -0
- package/lib/typescript/module/level-picker/index.web.d.ts +3 -0
- package/lib/typescript/module/level-picker/index.web.d.ts.map +1 -0
- package/lib/typescript/module/level-picker/types.d.ts +63 -0
- package/lib/typescript/module/level-picker/types.d.ts.map +1 -0
- package/package.json +20 -1
- package/src/floating/index.ts +5 -0
- package/src/floating/menu-sub-flyout.tsx +97 -6
- package/src/index.ts +2 -0
- package/src/index.web.ts +2 -0
- package/src/level-picker/LevelPicker.tsx +365 -0
- package/src/level-picker/LevelPicker.web.tsx +374 -0
- package/src/level-picker/constants.ts +131 -0
- package/src/level-picker/index.ts +2 -0
- package/src/level-picker/index.web.ts +2 -0
- package/src/level-picker/types.ts +63 -0
- package/src/__tests__/support/collision-fixture-barrel.ts +0 -20
- package/src/__tests__/support/constructed-style-sheets.ts +0 -68
- package/src/__tests__/support/press-host.ts +0 -30
- package/src/__tests__/support/rendered-style.ts +0 -99
- package/src/__tests__/support/unread-hook-fixture.ts +0 -33
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +0 -7682
- 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>(
|
|
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))
|
|
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))
|
|
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={
|
|
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';
|