panelui-native 0.41.0 → 0.44.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/README.md +3 -2
- package/lib/module/components/card/index.js +8 -154
- package/lib/module/components/card/index.js.map +1 -1
- package/lib/module/components/color-picker/index.js +1395 -0
- package/lib/module/components/color-picker/index.js.map +1 -0
- package/lib/module/components/combobox/index.js +87 -19
- package/lib/module/components/combobox/index.js.map +1 -1
- package/lib/module/components/kpi-chart/index.js +648 -0
- package/lib/module/components/kpi-chart/index.js.map +1 -0
- package/lib/module/components/menu/index.js +40 -10
- package/lib/module/components/menu/index.js.map +1 -1
- package/lib/module/components/radar-chart/index.js +704 -0
- package/lib/module/components/radar-chart/index.js.map +1 -0
- package/lib/module/components/section-rail/index.js +32 -7
- package/lib/module/components/section-rail/index.js.map +1 -1
- package/lib/module/components/slider/index.js +240 -39
- package/lib/module/components/slider/index.js.map +1 -1
- package/lib/module/components/tabs/index.js +194 -10
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/theme/use-theme.js +1 -1
- package/lib/module/utils/chart.js +44 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/module/utils/color.js +268 -0
- package/lib/module/utils/color.js.map +1 -0
- package/lib/typescript/src/components/card/index.d.ts +0 -20
- package/lib/typescript/src/components/card/index.d.ts.map +1 -1
- package/lib/typescript/src/components/color-picker/index.d.ts +258 -0
- package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -0
- package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/kpi-chart/index.d.ts +490 -0
- package/lib/typescript/src/components/kpi-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
- package/lib/typescript/src/components/radar-chart/index.d.ts +214 -0
- package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/section-rail/index.d.ts +8 -1
- package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
- package/lib/typescript/src/components/slider/index.d.ts +19 -0
- package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tabs/index.d.ts +11 -1
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +5 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +21 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/lib/typescript/src/utils/color.d.ts +74 -0
- package/lib/typescript/src/utils/color.d.ts.map +1 -0
- package/package.json +2 -1
- package/src/components/card/index.tsx +7 -209
- package/src/components/color-picker/index.tsx +1618 -0
- package/src/components/combobox/index.tsx +89 -19
- package/src/components/kpi-chart/index.tsx +719 -0
- package/src/components/menu/index.tsx +51 -6
- package/src/components/radar-chart/index.tsx +910 -0
- package/src/components/section-rail/index.tsx +42 -8
- package/src/components/slider/index.tsx +283 -41
- package/src/components/tabs/index.tsx +263 -8
- package/src/index.ts +61 -1
- package/src/theme/use-theme.ts +1 -1
- package/src/utils/chart.ts +52 -0
- package/src/utils/color.ts +246 -0
- package/theme.css +125 -112
|
@@ -62,6 +62,14 @@ const BAR_LEVEL_STEP = 4;
|
|
|
62
62
|
const BAR_ACTIVE_EXTRA = 8;
|
|
63
63
|
/** Indent per level in the expanded panel. */
|
|
64
64
|
const ITEM_INDENT = 12;
|
|
65
|
+
/**
|
|
66
|
+
* How wide the panel may grow by default. A row spends at least 40pt on
|
|
67
|
+
* indent, padding and the panel's own border before any text, so a tighter cap
|
|
68
|
+
* than this truncates ordinary section titles on a narrow phone.
|
|
69
|
+
*/
|
|
70
|
+
const PANEL_MAX_WIDTH = '78%' as const;
|
|
71
|
+
/** …and a floor, so a rail with one short section still opens a readable panel. */
|
|
72
|
+
const PANEL_MIN_WIDTH = 200;
|
|
65
73
|
|
|
66
74
|
export type SectionRailPlacement = 'left' | 'right';
|
|
67
75
|
export type SectionRailAlign = 'center' | 'top' | 'bottom';
|
|
@@ -327,6 +335,13 @@ function SectionRailBar({ className, value, level = 0 }: SectionRailBarProps) {
|
|
|
327
335
|
|
|
328
336
|
export interface SectionRailContentProps extends ViewProps {
|
|
329
337
|
className?: string;
|
|
338
|
+
/**
|
|
339
|
+
* How wide the panel may grow, as a fraction of the screen or a point width.
|
|
340
|
+
* The default leaves room for the rail and the edge it is anchored to; raise
|
|
341
|
+
* it for a screen whose section titles are long enough to be worth wrapping
|
|
342
|
+
* rather than truncating.
|
|
343
|
+
*/
|
|
344
|
+
maxWidth?: number | `${number}%`;
|
|
330
345
|
children: ReactNode;
|
|
331
346
|
}
|
|
332
347
|
|
|
@@ -334,7 +349,12 @@ export interface SectionRailContentProps extends ViewProps {
|
|
|
334
349
|
* The expanded panel. Mounted through a portal so it floats over everything,
|
|
335
350
|
* and unmounted after it fades out rather than left behind hidden.
|
|
336
351
|
*/
|
|
337
|
-
function SectionRailContent({
|
|
352
|
+
function SectionRailContent({
|
|
353
|
+
className,
|
|
354
|
+
maxWidth = PANEL_MAX_WIDTH,
|
|
355
|
+
children,
|
|
356
|
+
...props
|
|
357
|
+
}: SectionRailContentProps) {
|
|
338
358
|
const context = useSectionRail('SectionRail.Content');
|
|
339
359
|
const { open, close, placement, align } = context;
|
|
340
360
|
const insets = useSafeAreaInsets();
|
|
@@ -366,7 +386,7 @@ function SectionRailContent({ className, children, ...props }: SectionRailConten
|
|
|
366
386
|
}}
|
|
367
387
|
pointerEvents="box-none"
|
|
368
388
|
>
|
|
369
|
-
<SectionRailPanel className={className} {...props}>
|
|
389
|
+
<SectionRailPanel className={className} maxWidth={maxWidth} {...props}>
|
|
370
390
|
{children}
|
|
371
391
|
</SectionRailPanel>
|
|
372
392
|
</Animated.View>
|
|
@@ -380,7 +400,12 @@ function SectionRailContent({ className, children, ...props }: SectionRailConten
|
|
|
380
400
|
* view as the entering/exiting fade — Reanimated will let a layout animation
|
|
381
401
|
* overwrite an animated style that touches the same property.
|
|
382
402
|
*/
|
|
383
|
-
function SectionRailPanel({
|
|
403
|
+
function SectionRailPanel({
|
|
404
|
+
className,
|
|
405
|
+
maxWidth = PANEL_MAX_WIDTH,
|
|
406
|
+
children,
|
|
407
|
+
...props
|
|
408
|
+
}: SectionRailContentProps) {
|
|
384
409
|
const { placement } = useSectionRail('SectionRail.Content');
|
|
385
410
|
const slide = useSharedValue(0);
|
|
386
411
|
|
|
@@ -398,10 +423,14 @@ function SectionRailPanel({ className, children, ...props }: SectionRailContentP
|
|
|
398
423
|
|
|
399
424
|
return (
|
|
400
425
|
<Animated.View
|
|
401
|
-
style
|
|
426
|
+
// The cap is a style rather than a class so it can be a prop: a screen
|
|
427
|
+
// whose sections are called "Shipping and returns" needs more room than
|
|
428
|
+
// one whose sections are called "Specs", and a `className` override would
|
|
429
|
+
// be fighting the class already here rather than replacing it.
|
|
430
|
+
style={[style, { maxWidth, minWidth: PANEL_MIN_WIDTH }]}
|
|
402
431
|
accessibilityRole="menu"
|
|
403
432
|
className={cn(
|
|
404
|
-
'
|
|
433
|
+
'gap-0.5 rounded-2xl border border-border bg-popover p-2 shadow-lg',
|
|
405
434
|
className
|
|
406
435
|
)}
|
|
407
436
|
{...props}
|
|
@@ -432,9 +461,11 @@ function SectionRailItem({ className, value, level = 0, children }: SectionRailI
|
|
|
432
461
|
accessibilityRole="menuitem"
|
|
433
462
|
accessibilityState={{ selected }}
|
|
434
463
|
onPress={() => onValueChange?.(value)}
|
|
435
|
-
|
|
464
|
+
// `paddingStart`, not `paddingLeft`: the indent has to fall on the same
|
|
465
|
+
// side as the bar it belongs to, which is the trailing edge under RTL.
|
|
466
|
+
style={{ paddingStart: 12 + level * ITEM_INDENT }}
|
|
436
467
|
className={cn(
|
|
437
|
-
'rounded-lg py-2 pe-
|
|
468
|
+
'rounded-lg py-2 pe-2.5 active:bg-accent',
|
|
438
469
|
selected && 'bg-accent',
|
|
439
470
|
className
|
|
440
471
|
)}
|
|
@@ -443,7 +474,10 @@ function SectionRailItem({ className, value, level = 0, children }: SectionRailI
|
|
|
443
474
|
size="sm"
|
|
444
475
|
weight={selected ? 'medium' : 'normal'}
|
|
445
476
|
className={selected ? 'text-foreground' : 'text-muted-foreground'}
|
|
446
|
-
|
|
477
|
+
// Two lines rather than one: a long section title is worth wrapping,
|
|
478
|
+
// and an ellipsis in a navigator hides the very word that tells you
|
|
479
|
+
// which section you are about to jump to.
|
|
480
|
+
numberOfLines={2}
|
|
447
481
|
>
|
|
448
482
|
{children}
|
|
449
483
|
</Text>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Slider — a value picker driven by a
|
|
2
|
+
* Slider — a value picker driven by a thumb, or a span picked by two.
|
|
3
3
|
*
|
|
4
4
|
* The thumb is a pill exactly as tall as the track rather than a disc floating
|
|
5
5
|
* over it. That is a deliberate shape choice and it is also the robust one: a
|
|
@@ -16,9 +16,16 @@
|
|
|
16
16
|
*
|
|
17
17
|
* ```tsx
|
|
18
18
|
* <Slider label="Volume" showValue defaultValue={30} />
|
|
19
|
+
*
|
|
20
|
+
* <Slider label="Price" showValue defaultRange={[20, 80]} />
|
|
19
21
|
* ```
|
|
20
22
|
*
|
|
21
|
-
*
|
|
23
|
+
* A range is a second pair of props rather than a tuple in the first, so a
|
|
24
|
+
* one-thumb slider's `onValueChange` still hands back a plain number and
|
|
25
|
+
* nobody has to narrow a union to read it.
|
|
26
|
+
*
|
|
27
|
+
* Works controlled (`value`/`range` with their change handlers) or uncontrolled
|
|
28
|
+
* (`defaultValue`/`defaultRange`).
|
|
22
29
|
*/
|
|
23
30
|
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
|
24
31
|
import {
|
|
@@ -103,16 +110,32 @@ export interface SliderProps extends Omit<SliderVariantProps, 'disabled'> {
|
|
|
103
110
|
value?: number;
|
|
104
111
|
/** Starting value when uncontrolled. */
|
|
105
112
|
defaultValue?: number;
|
|
113
|
+
/**
|
|
114
|
+
* Controlled span, as `[low, high]`. Passing this — or `defaultRange` — gives
|
|
115
|
+
* the slider two thumbs and fills between them instead of from the start.
|
|
116
|
+
*/
|
|
117
|
+
range?: [number, number];
|
|
118
|
+
/** Starting span when uncontrolled, as `[low, high]`. */
|
|
119
|
+
defaultRange?: [number, number];
|
|
106
120
|
/** Lower bound. */
|
|
107
121
|
min?: number;
|
|
108
122
|
/** Upper bound. */
|
|
109
123
|
max?: number;
|
|
110
124
|
/** Snap granularity. The value is always a multiple of `step` from `min`. */
|
|
111
125
|
step?: number;
|
|
126
|
+
/**
|
|
127
|
+
* How many steps the two thumbs must stay apart on a range slider. `0` lets
|
|
128
|
+
* them meet; `1` keeps a step between them, so the span is never empty.
|
|
129
|
+
*/
|
|
130
|
+
minStepsBetweenThumbs?: number;
|
|
112
131
|
/** Fires on every change while dragging — cheap updates only. */
|
|
113
132
|
onValueChange?: (value: number) => void;
|
|
114
133
|
/** Fires once when the gesture ends — the place for expensive side effects. */
|
|
115
134
|
onValueCommit?: (value: number) => void;
|
|
135
|
+
/** The range equivalent of `onValueChange`. Only fires on a range slider. */
|
|
136
|
+
onRangeChange?: (range: [number, number]) => void;
|
|
137
|
+
/** The range equivalent of `onValueCommit`. Only fires on a range slider. */
|
|
138
|
+
onRangeCommit?: (range: [number, number]) => void;
|
|
116
139
|
disabled?: boolean;
|
|
117
140
|
/**
|
|
118
141
|
* Render the platform's own slider instead of this one. Requires the
|
|
@@ -121,6 +144,9 @@ export interface SliderProps extends Omit<SliderVariantProps, 'disabled'> {
|
|
|
121
144
|
* **Theme tokens do not apply** — the platform draws the control, so
|
|
122
145
|
* `color`, `size` and the slot classNames are ignored. `label` and
|
|
123
146
|
* `showValue` still render the caption row above it, since that is ours.
|
|
147
|
+
*
|
|
148
|
+
* **Ignored on a range slider.** Neither platform ships a two-thumb slider,
|
|
149
|
+
* so a range draws ours rather than quietly losing a thumb.
|
|
124
150
|
*/
|
|
125
151
|
native?: boolean;
|
|
126
152
|
/** Caption above the track. Also becomes the accessibility label. */
|
|
@@ -157,6 +183,9 @@ function snap(value: number, min: number, max: number, step: number) {
|
|
|
157
183
|
return clampJS(stepped, min, max);
|
|
158
184
|
}
|
|
159
185
|
|
|
186
|
+
/** Which thumb a drag has hold of. A one-thumb slider only ever has `high`. */
|
|
187
|
+
type Thumb = 'low' | 'high';
|
|
188
|
+
|
|
160
189
|
export const Slider = forwardRef<View, SliderProps>(
|
|
161
190
|
(
|
|
162
191
|
{
|
|
@@ -168,11 +197,16 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
168
197
|
knobClassName,
|
|
169
198
|
value: valueProp,
|
|
170
199
|
defaultValue = 0,
|
|
200
|
+
range: rangeProp,
|
|
201
|
+
defaultRange,
|
|
171
202
|
min = 0,
|
|
172
203
|
max = 100,
|
|
173
204
|
step = 1,
|
|
205
|
+
minStepsBetweenThumbs = 0,
|
|
174
206
|
onValueChange,
|
|
175
207
|
onValueCommit,
|
|
208
|
+
onRangeChange,
|
|
209
|
+
onRangeCommit,
|
|
176
210
|
disabled = false,
|
|
177
211
|
native,
|
|
178
212
|
label,
|
|
@@ -184,13 +218,35 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
184
218
|
},
|
|
185
219
|
ref
|
|
186
220
|
) => {
|
|
221
|
+
// Whether there are two thumbs is decided by which props were passed, not
|
|
222
|
+
// by a flag: a slider handed `[20, 80]` is a range and there is nothing
|
|
223
|
+
// else it could be.
|
|
224
|
+
const isRange = rangeProp !== undefined || defaultRange !== undefined;
|
|
225
|
+
|
|
187
226
|
const isControlled = valueProp !== undefined;
|
|
188
227
|
const [internal, setInternal] = useState(defaultValue);
|
|
189
228
|
const value = clampJS(isControlled ? valueProp! : internal, min, max);
|
|
190
229
|
|
|
230
|
+
const isRangeControlled = rangeProp !== undefined;
|
|
231
|
+
const [internalRange, setInternalRange] = useState<[number, number]>(
|
|
232
|
+
defaultRange ?? [min, max]
|
|
233
|
+
);
|
|
234
|
+
const rawRange = isRangeControlled ? rangeProp! : internalRange;
|
|
235
|
+
// Ordered as well as clamped, so a caller who passes `[80, 20]` gets the
|
|
236
|
+
// span they meant rather than a fill of negative width.
|
|
237
|
+
const low = clampJS(Math.min(rawRange[0], rawRange[1]), min, max);
|
|
238
|
+
const high = clampJS(Math.max(rawRange[0], rawRange[1]), min, max);
|
|
239
|
+
|
|
191
240
|
const slots = sliderVariants({ color, size, disabled });
|
|
192
241
|
const thumbWidth = THUMB_WIDTH[size ?? 'md'];
|
|
193
|
-
|
|
242
|
+
// A platform slider has one thumb, so a range never hands off to it.
|
|
243
|
+
const nativeUI = native && !isRange ? getNativeUI() : null;
|
|
244
|
+
|
|
245
|
+
const span = max > min ? max - min : 1;
|
|
246
|
+
const toFraction = useCallback(
|
|
247
|
+
(n: number) => (max > min ? (n - min) / (max - min) : 0),
|
|
248
|
+
[min, max]
|
|
249
|
+
);
|
|
194
250
|
|
|
195
251
|
// Measured on layout; the thumb travels across (trackWidth - thumbWidth) so
|
|
196
252
|
// it stays inside the track at both ends.
|
|
@@ -198,10 +254,19 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
198
254
|
// pixel offset rather than a laid-out box multiplies through it.
|
|
199
255
|
const sign = useDirectionSign();
|
|
200
256
|
const trackWidth = useSharedValue(0);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
257
|
+
/*
|
|
258
|
+
* Two fractions rather than one. `high` is the thumb every slider has, and
|
|
259
|
+
* `low` sits at 0 on a one-thumb one — so the fill below reads the same
|
|
260
|
+
* either way: from `low` to `high`, which for a single slider is the start
|
|
261
|
+
* of the track to the thumb.
|
|
262
|
+
*/
|
|
263
|
+
const lowProgress = useSharedValue(isRange ? toFraction(low) : 0);
|
|
264
|
+
const highProgress = useSharedValue(isRange ? toFraction(high) : toFraction(value));
|
|
265
|
+
const dragStartLow = useSharedValue(0);
|
|
266
|
+
const dragStartHigh = useSharedValue(0);
|
|
267
|
+
const activeThumb = useSharedValue<Thumb>('high');
|
|
204
268
|
const pressed = useSharedValue(0);
|
|
269
|
+
const pressedLow = useSharedValue(0);
|
|
205
270
|
|
|
206
271
|
// The change handler runs on JS; keep the latest props reachable from the
|
|
207
272
|
// worklet callback without re-creating the gesture on every render.
|
|
@@ -209,9 +274,21 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
209
274
|
changeRef.current = onValueChange;
|
|
210
275
|
const commitRef = useRef(onValueCommit);
|
|
211
276
|
commitRef.current = onValueCommit;
|
|
277
|
+
const rangeChangeRef = useRef(onRangeChange);
|
|
278
|
+
rangeChangeRef.current = onRangeChange;
|
|
279
|
+
const rangeCommitRef = useRef(onRangeCommit);
|
|
280
|
+
rangeCommitRef.current = onRangeCommit;
|
|
212
281
|
// The last step a drag ticked on, so haptics fire once per step, not per
|
|
213
282
|
// pixel of movement within it.
|
|
214
283
|
const lastTick = useRef(value);
|
|
284
|
+
/*
|
|
285
|
+
* True from the moment a finger lands until it lifts. A controlled parent
|
|
286
|
+
* echoes every change straight back as a new prop, and springing the thumb
|
|
287
|
+
* onto that echo fights the finger — visibly so at a coarse `step`, where
|
|
288
|
+
* each frame would pull the knob back onto the last snapped value while the
|
|
289
|
+
* finger had already moved past it.
|
|
290
|
+
*/
|
|
291
|
+
const dragging = useRef(false);
|
|
215
292
|
|
|
216
293
|
const emitChange = useCallback(
|
|
217
294
|
(next: number) => {
|
|
@@ -224,11 +301,25 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
224
301
|
commitRef.current?.(next);
|
|
225
302
|
}, []);
|
|
226
303
|
|
|
304
|
+
const emitRangeChange = useCallback(
|
|
305
|
+
(next: [number, number]) => {
|
|
306
|
+
if (!isRangeControlled) setInternalRange(next);
|
|
307
|
+
rangeChangeRef.current?.(next);
|
|
308
|
+
},
|
|
309
|
+
[isRangeControlled]
|
|
310
|
+
);
|
|
311
|
+
|
|
227
312
|
// Keep the animation in step with a controlled value that changes elsewhere.
|
|
228
313
|
useEffect(() => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}, [value,
|
|
314
|
+
if (isRange || dragging.current) return;
|
|
315
|
+
highProgress.value = withSpring(toFraction(value), SPRING);
|
|
316
|
+
}, [value, isRange, toFraction, highProgress]);
|
|
317
|
+
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
if (!isRange || dragging.current) return;
|
|
320
|
+
lowProgress.value = withSpring(toFraction(low), SPRING);
|
|
321
|
+
highProgress.value = withSpring(toFraction(high), SPRING);
|
|
322
|
+
}, [low, high, isRange, toFraction, lowProgress, highProgress]);
|
|
232
323
|
|
|
233
324
|
const onLayout = (event: LayoutChangeEvent) => {
|
|
234
325
|
trackWidth.value = event.nativeEvent.layout.width;
|
|
@@ -236,7 +327,7 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
236
327
|
|
|
237
328
|
const commitFromProgress = useCallback(
|
|
238
329
|
(p: number, commit: boolean) => {
|
|
239
|
-
const raw = min + p *
|
|
330
|
+
const raw = min + p * span;
|
|
240
331
|
const snapped = snap(raw, min, max, step);
|
|
241
332
|
if (haptics && snapped !== lastTick.current) {
|
|
242
333
|
lastTick.current = snapped;
|
|
@@ -245,15 +336,64 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
245
336
|
emitChange(snapped);
|
|
246
337
|
if (commit) emitCommit(snapped);
|
|
247
338
|
},
|
|
248
|
-
[min, max, step, haptics, emitChange, emitCommit]
|
|
339
|
+
[min, max, span, step, haptics, emitChange, emitCommit]
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const commitRangeFromProgress = useCallback(
|
|
343
|
+
(pLow: number, pHigh: number, commit: boolean) => {
|
|
344
|
+
const next: [number, number] = [
|
|
345
|
+
snap(min + pLow * span, min, max, step),
|
|
346
|
+
snap(min + pHigh * span, min, max, step),
|
|
347
|
+
];
|
|
348
|
+
// Either end crossing onto a new step is a step crossed — the tick is
|
|
349
|
+
// about the thumb under the finger, and only one of them is.
|
|
350
|
+
const moved = next[0] !== lastTick.current && next[1] !== lastTick.current;
|
|
351
|
+
if (haptics && moved) {
|
|
352
|
+
lastTick.current = next[0];
|
|
353
|
+
selectionTick();
|
|
354
|
+
}
|
|
355
|
+
emitRangeChange(next);
|
|
356
|
+
if (commit) rangeCommitRef.current?.(next);
|
|
357
|
+
},
|
|
358
|
+
[min, max, span, step, haptics, emitRangeChange]
|
|
249
359
|
);
|
|
250
360
|
|
|
361
|
+
/** The gap the two thumbs must keep, in the same 0..1 fractions as everything else. */
|
|
362
|
+
const gap = minStepsBetweenThumbs > 0 ? (minStepsBetweenThumbs * step) / span : 0;
|
|
363
|
+
|
|
364
|
+
const setDragging = useCallback((next: boolean) => {
|
|
365
|
+
dragging.current = next;
|
|
366
|
+
}, []);
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Which thumb a touch at `x` should take. A tie goes to whichever thumb the
|
|
370
|
+
* touch is *outside* of: with both stacked at one end, the nearer one may
|
|
371
|
+
* be the one with nowhere to go, and a range that cannot be dragged back
|
|
372
|
+
* off its own end is a broken control.
|
|
373
|
+
*/
|
|
374
|
+
const pickThumb = (x: number): Thumb => {
|
|
375
|
+
'worklet';
|
|
376
|
+
const w = Math.max(trackWidth.value, 1);
|
|
377
|
+
const travel = Math.max(w - thumbWidth, 1);
|
|
378
|
+
const along = sign === 1 ? x : w - x;
|
|
379
|
+
const p = (along - thumbWidth / 2) / travel;
|
|
380
|
+
const toLow = Math.abs(p - lowProgress.value);
|
|
381
|
+
const toHigh = Math.abs(p - highProgress.value);
|
|
382
|
+
if (toLow === toHigh) return p < lowProgress.value ? 'low' : 'high';
|
|
383
|
+
return toLow < toHigh ? 'low' : 'high';
|
|
384
|
+
};
|
|
385
|
+
|
|
251
386
|
const pan = Gesture.Pan()
|
|
252
387
|
.enabled(!disabled)
|
|
253
388
|
.hitSlop(HIT_SLOP)
|
|
254
|
-
.onBegin(() => {
|
|
255
|
-
|
|
256
|
-
|
|
389
|
+
.onBegin((event) => {
|
|
390
|
+
runOnJS(setDragging)(true);
|
|
391
|
+
dragStartLow.value = lowProgress.value;
|
|
392
|
+
dragStartHigh.value = highProgress.value;
|
|
393
|
+
const thumb = isRange ? pickThumb(event.x) : 'high';
|
|
394
|
+
activeThumb.value = thumb;
|
|
395
|
+
if (thumb === 'low') pressedLow.value = withSpring(1, KNOB_SPRING);
|
|
396
|
+
else pressed.value = withSpring(1, KNOB_SPRING);
|
|
257
397
|
})
|
|
258
398
|
.onUpdate((event) => {
|
|
259
399
|
const travel = Math.max(trackWidth.value - thumbWidth, 1);
|
|
@@ -263,13 +403,38 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
263
403
|
* without the sign the thumb runs away from the finger.
|
|
264
404
|
*/
|
|
265
405
|
const delta = (event.translationX * sign) / travel;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
406
|
+
|
|
407
|
+
if (!isRange) {
|
|
408
|
+
const next = Math.min(Math.max(dragStartHigh.value + delta, 0), 1);
|
|
409
|
+
highProgress.value = next;
|
|
410
|
+
runOnJS(commitFromProgress)(next, false);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Each thumb is bounded by the other rather than by the track, so they
|
|
415
|
+
// can meet but never cross — a span turned inside out would fill
|
|
416
|
+
// backwards and read as a different range entirely.
|
|
417
|
+
if (activeThumb.value === 'low') {
|
|
418
|
+
const ceiling = Math.max(highProgress.value - gap, 0);
|
|
419
|
+
const next = Math.min(Math.max(dragStartLow.value + delta, 0), ceiling);
|
|
420
|
+
lowProgress.value = next;
|
|
421
|
+
runOnJS(commitRangeFromProgress)(next, highProgress.value, false);
|
|
422
|
+
} else {
|
|
423
|
+
const floor = Math.min(lowProgress.value + gap, 1);
|
|
424
|
+
const next = Math.min(Math.max(dragStartHigh.value + delta, floor), 1);
|
|
425
|
+
highProgress.value = next;
|
|
426
|
+
runOnJS(commitRangeFromProgress)(lowProgress.value, next, false);
|
|
427
|
+
}
|
|
269
428
|
})
|
|
270
429
|
.onFinalize(() => {
|
|
271
430
|
pressed.value = withSpring(0, KNOB_SPRING);
|
|
272
|
-
|
|
431
|
+
pressedLow.value = withSpring(0, KNOB_SPRING);
|
|
432
|
+
runOnJS(setDragging)(false);
|
|
433
|
+
if (isRange) {
|
|
434
|
+
runOnJS(commitRangeFromProgress)(lowProgress.value, highProgress.value, true);
|
|
435
|
+
} else {
|
|
436
|
+
runOnJS(commitFromProgress)(highProgress.value, true);
|
|
437
|
+
}
|
|
273
438
|
});
|
|
274
439
|
|
|
275
440
|
const tap = Gesture.Tap()
|
|
@@ -279,52 +444,102 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
279
444
|
const travel = Math.max(trackWidth.value - thumbWidth, 1);
|
|
280
445
|
// `event.x` is measured from the physical left edge either way, so
|
|
281
446
|
// right-to-left counts back from the far end of the track.
|
|
282
|
-
const along =
|
|
283
|
-
sign === 1 ? event.x : trackWidth.value - event.x;
|
|
447
|
+
const along = sign === 1 ? event.x : trackWidth.value - event.x;
|
|
284
448
|
const next = Math.min(Math.max((along - thumbWidth / 2) / travel, 0), 1);
|
|
285
|
-
|
|
286
|
-
|
|
449
|
+
|
|
450
|
+
if (!isRange) {
|
|
451
|
+
highProgress.value = withSpring(next, SPRING);
|
|
452
|
+
runOnJS(commitFromProgress)(next, true);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (pickThumb(event.x) === 'low') {
|
|
457
|
+
const bounded = Math.min(next, Math.max(highProgress.value - gap, 0));
|
|
458
|
+
lowProgress.value = withSpring(bounded, SPRING);
|
|
459
|
+
runOnJS(commitRangeFromProgress)(bounded, highProgress.value, true);
|
|
460
|
+
} else {
|
|
461
|
+
const bounded = Math.max(next, Math.min(lowProgress.value + gap, 1));
|
|
462
|
+
highProgress.value = withSpring(bounded, SPRING);
|
|
463
|
+
runOnJS(commitRangeFromProgress)(lowProgress.value, bounded, true);
|
|
464
|
+
}
|
|
287
465
|
});
|
|
288
466
|
|
|
289
467
|
const gesture = Gesture.Race(pan, tap);
|
|
290
468
|
|
|
291
|
-
// The fill runs under
|
|
292
|
-
// a seam
|
|
469
|
+
// The fill runs under both thumbs rather than up to them, so neither shows
|
|
470
|
+
// a seam against it as it moves. On a one-thumb slider `low` is 0, and this
|
|
471
|
+
// is the ordinary fill from the start of the track.
|
|
293
472
|
const fillStyle = useAnimatedStyle(() => {
|
|
294
473
|
const travel = Math.max(trackWidth.value - thumbWidth, 0);
|
|
295
|
-
return {
|
|
474
|
+
return {
|
|
475
|
+
width: (highProgress.value - lowProgress.value) * travel + thumbWidth,
|
|
476
|
+
transform: [{ translateX: lowProgress.value * travel * sign }],
|
|
477
|
+
};
|
|
296
478
|
});
|
|
297
479
|
|
|
298
480
|
const thumbStyle = useAnimatedStyle(() => {
|
|
299
481
|
const travel = Math.max(trackWidth.value - thumbWidth, 0);
|
|
300
482
|
// A transform is not laid out, so Yoga does not mirror it — the thumb
|
|
301
483
|
// has to travel the other way itself.
|
|
302
|
-
return { transform: [{ translateX:
|
|
484
|
+
return { transform: [{ translateX: highProgress.value * travel * sign }] };
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const lowThumbStyle = useAnimatedStyle(() => {
|
|
488
|
+
const travel = Math.max(trackWidth.value - thumbWidth, 0);
|
|
489
|
+
return { transform: [{ translateX: lowProgress.value * travel * sign }] };
|
|
303
490
|
});
|
|
304
491
|
|
|
305
492
|
const knobStyle = useAnimatedStyle(() => ({
|
|
306
493
|
transform: [{ scale: 1 - pressed.value * (1 - KNOB_PRESSED_SCALE) }],
|
|
307
494
|
}));
|
|
308
495
|
|
|
496
|
+
const lowKnobStyle = useAnimatedStyle(() => ({
|
|
497
|
+
transform: [{ scale: 1 - pressedLow.value * (1 - KNOB_PRESSED_SCALE) }],
|
|
498
|
+
}));
|
|
499
|
+
|
|
309
500
|
// VoiceOver / TalkBack increment and decrement move by a single step.
|
|
310
|
-
const nudge = (dir: 1 | -1) => {
|
|
311
|
-
|
|
312
|
-
|
|
501
|
+
const nudge = (dir: 1 | -1, thumb: Thumb = 'high') => {
|
|
502
|
+
if (!isRange) {
|
|
503
|
+
const next = snap(value + dir * (step || 1), min, max, step);
|
|
504
|
+
if (next === value) return;
|
|
505
|
+
if (haptics) {
|
|
506
|
+
lastTick.current = next;
|
|
507
|
+
selectionTick();
|
|
508
|
+
}
|
|
509
|
+
highProgress.value = withSpring(toFraction(next), SPRING);
|
|
510
|
+
emitChange(next);
|
|
511
|
+
emitCommit(next);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const gapValue = minStepsBetweenThumbs * step;
|
|
516
|
+
const current = thumb === 'low' ? low : high;
|
|
517
|
+
const bounded =
|
|
518
|
+
thumb === 'low'
|
|
519
|
+
? clampJS(current + dir * (step || 1), min, high - gapValue)
|
|
520
|
+
: clampJS(current + dir * (step || 1), low + gapValue, max);
|
|
521
|
+
const next = snap(bounded, min, max, step);
|
|
522
|
+
if (next === current) return;
|
|
313
523
|
if (haptics) {
|
|
314
524
|
lastTick.current = next;
|
|
315
525
|
selectionTick();
|
|
316
526
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
527
|
+
const pair: [number, number] = thumb === 'low' ? [next, high] : [low, next];
|
|
528
|
+
(thumb === 'low' ? lowProgress : highProgress).value = withSpring(
|
|
529
|
+
toFraction(next),
|
|
530
|
+
SPRING
|
|
531
|
+
);
|
|
532
|
+
emitRangeChange(pair);
|
|
533
|
+
rangeCommitRef.current?.(pair);
|
|
320
534
|
};
|
|
321
535
|
|
|
322
|
-
const
|
|
323
|
-
if (event.nativeEvent.actionName === 'increment') nudge(1);
|
|
324
|
-
else if (event.nativeEvent.actionName === 'decrement') nudge(-1);
|
|
536
|
+
const accessibilityAction = (thumb: Thumb) => (event: AccessibilityActionEvent) => {
|
|
537
|
+
if (event.nativeEvent.actionName === 'increment') nudge(1, thumb);
|
|
538
|
+
else if (event.nativeEvent.actionName === 'decrement') nudge(-1, thumb);
|
|
325
539
|
};
|
|
326
540
|
|
|
327
|
-
const
|
|
541
|
+
const print = (n: number) => (formatValue ? formatValue(n) : String(Math.round(n)));
|
|
542
|
+
const shownValue = isRange ? `${print(low)} – ${print(high)}` : print(value);
|
|
328
543
|
|
|
329
544
|
const header =
|
|
330
545
|
label || showValue ? (
|
|
@@ -371,21 +586,48 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
371
586
|
style={fillStyle}
|
|
372
587
|
className={slots.fill({ className: fillClassName })}
|
|
373
588
|
/>
|
|
589
|
+
|
|
590
|
+
{isRange ? (
|
|
591
|
+
<Animated.View
|
|
592
|
+
style={[lowThumbStyle, { width: thumbWidth }]}
|
|
593
|
+
className={slots.thumb({ className: thumbClassName })}
|
|
594
|
+
accessible
|
|
595
|
+
accessibilityRole="adjustable"
|
|
596
|
+
accessibilityLabel={label ? `${label}, minimum` : 'Minimum'}
|
|
597
|
+
accessibilityState={{ disabled }}
|
|
598
|
+
accessibilityValue={{
|
|
599
|
+
min,
|
|
600
|
+
max: Math.round(high),
|
|
601
|
+
now: Math.round(low),
|
|
602
|
+
text: print(low),
|
|
603
|
+
}}
|
|
604
|
+
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
|
|
605
|
+
onAccessibilityAction={accessibilityAction('low')}
|
|
606
|
+
>
|
|
607
|
+
<Animated.View
|
|
608
|
+
style={lowKnobStyle}
|
|
609
|
+
className={slots.knob({ className: knobClassName })}
|
|
610
|
+
/>
|
|
611
|
+
</Animated.View>
|
|
612
|
+
) : null}
|
|
613
|
+
|
|
374
614
|
<Animated.View
|
|
375
615
|
style={[thumbStyle, { width: thumbWidth }]}
|
|
376
616
|
className={slots.thumb({ className: thumbClassName })}
|
|
377
617
|
accessible
|
|
378
618
|
accessibilityRole="adjustable"
|
|
379
|
-
accessibilityLabel={
|
|
619
|
+
accessibilityLabel={
|
|
620
|
+
isRange ? (label ? `${label}, maximum` : 'Maximum') : label
|
|
621
|
+
}
|
|
380
622
|
accessibilityState={{ disabled }}
|
|
381
623
|
accessibilityValue={{
|
|
382
|
-
min,
|
|
624
|
+
min: isRange ? Math.round(low) : min,
|
|
383
625
|
max,
|
|
384
|
-
now: Math.round(value),
|
|
385
|
-
text: shownValue,
|
|
626
|
+
now: Math.round(isRange ? high : value),
|
|
627
|
+
text: isRange ? print(high) : shownValue,
|
|
386
628
|
}}
|
|
387
629
|
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
|
|
388
|
-
onAccessibilityAction={
|
|
630
|
+
onAccessibilityAction={accessibilityAction('high')}
|
|
389
631
|
>
|
|
390
632
|
<Animated.View
|
|
391
633
|
style={knobStyle}
|