panelui-native 0.40.0 → 0.43.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 (92) hide show
  1. package/README.md +4 -2
  2. package/lib/module/components/card/index.js +172 -3
  3. package/lib/module/components/card/index.js.map +1 -1
  4. package/lib/module/components/color-picker/index.js +1395 -0
  5. package/lib/module/components/color-picker/index.js.map +1 -0
  6. package/lib/module/components/combobox/index.js +87 -19
  7. package/lib/module/components/combobox/index.js.map +1 -1
  8. package/lib/module/components/drawer/index.js +13 -4
  9. package/lib/module/components/drawer/index.js.map +1 -1
  10. package/lib/module/components/kpi-chart/index.js +648 -0
  11. package/lib/module/components/kpi-chart/index.js.map +1 -0
  12. package/lib/module/components/map/index.js +72 -12
  13. package/lib/module/components/map/index.js.map +1 -1
  14. package/lib/module/components/menu/index.js +40 -10
  15. package/lib/module/components/menu/index.js.map +1 -1
  16. package/lib/module/components/pagination/index.js +19 -9
  17. package/lib/module/components/pagination/index.js.map +1 -1
  18. package/lib/module/components/panelside/index.js +68 -18
  19. package/lib/module/components/panelside/index.js.map +1 -1
  20. package/lib/module/components/radar-chart/index.js +704 -0
  21. package/lib/module/components/radar-chart/index.js.map +1 -0
  22. package/lib/module/components/section-rail/index.js +32 -7
  23. package/lib/module/components/section-rail/index.js.map +1 -1
  24. package/lib/module/components/slider/index.js +240 -39
  25. package/lib/module/components/slider/index.js.map +1 -1
  26. package/lib/module/components/tabs/index.js +194 -10
  27. package/lib/module/components/tabs/index.js.map +1 -1
  28. package/lib/module/components/text-animation/index.js +857 -0
  29. package/lib/module/components/text-animation/index.js.map +1 -0
  30. package/lib/module/icons/index.js +25 -7
  31. package/lib/module/icons/index.js.map +1 -1
  32. package/lib/module/index.js +5 -0
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/theme/use-theme.js +1 -1
  35. package/lib/module/utils/chart.js +44 -0
  36. package/lib/module/utils/chart.js.map +1 -1
  37. package/lib/module/utils/color.js +268 -0
  38. package/lib/module/utils/color.js.map +1 -0
  39. package/lib/typescript/src/components/card/index.d.ts +20 -0
  40. package/lib/typescript/src/components/card/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/color-picker/index.d.ts +258 -0
  42. package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -0
  43. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  44. package/lib/typescript/src/components/drawer/index.d.ts +13 -1
  45. package/lib/typescript/src/components/drawer/index.d.ts.map +1 -1
  46. package/lib/typescript/src/components/kpi-chart/index.d.ts +490 -0
  47. package/lib/typescript/src/components/kpi-chart/index.d.ts.map +1 -0
  48. package/lib/typescript/src/components/map/index.d.ts +37 -2
  49. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  51. package/lib/typescript/src/components/pagination/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/panelside/index.d.ts +50 -4
  53. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/radar-chart/index.d.ts +214 -0
  55. package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -0
  56. package/lib/typescript/src/components/section-rail/index.d.ts +8 -1
  57. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/slider/index.d.ts +19 -0
  59. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  60. package/lib/typescript/src/components/tabs/index.d.ts +11 -1
  61. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  62. package/lib/typescript/src/components/text-animation/index.d.ts +268 -0
  63. package/lib/typescript/src/components/text-animation/index.d.ts.map +1 -0
  64. package/lib/typescript/src/icons/index.d.ts +6 -0
  65. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  66. package/lib/typescript/src/index.d.ts +8 -3
  67. package/lib/typescript/src/index.d.ts.map +1 -1
  68. package/lib/typescript/src/utils/chart.d.ts +21 -0
  69. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  70. package/lib/typescript/src/utils/color.d.ts +74 -0
  71. package/lib/typescript/src/utils/color.d.ts.map +1 -0
  72. package/package.json +2 -1
  73. package/src/components/card/index.tsx +227 -2
  74. package/src/components/color-picker/index.tsx +1618 -0
  75. package/src/components/combobox/index.tsx +89 -19
  76. package/src/components/drawer/index.tsx +31 -9
  77. package/src/components/kpi-chart/index.tsx +719 -0
  78. package/src/components/map/index.tsx +158 -14
  79. package/src/components/menu/index.tsx +51 -6
  80. package/src/components/pagination/index.tsx +18 -6
  81. package/src/components/panelside/index.tsx +134 -19
  82. package/src/components/radar-chart/index.tsx +910 -0
  83. package/src/components/section-rail/index.tsx +42 -8
  84. package/src/components/slider/index.tsx +283 -41
  85. package/src/components/tabs/index.tsx +263 -8
  86. package/src/components/text-animation/index.tsx +1093 -0
  87. package/src/icons/index.tsx +23 -4
  88. package/src/index.ts +73 -1
  89. package/src/theme/use-theme.ts +1 -1
  90. package/src/utils/chart.ts +52 -0
  91. package/src/utils/color.ts +246 -0
  92. 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({ className, children, ...props }: SectionRailContentProps) {
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({ className, children, ...props }: SectionRailContentProps) {
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={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
- 'max-w-[60%] gap-0.5 rounded-2xl border border-border bg-popover p-2 shadow-lg',
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
- style={{ paddingLeft: 12 + level * ITEM_INDENT }}
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-3 active:bg-accent',
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
- numberOfLines={1}
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 single thumb.
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
- * Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).
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
- const nativeUI = native ? getNativeUI() : null;
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
- // Fraction 0..1 that drives the fill and the thumb, animated on the UI thread.
202
- const progress = useSharedValue(max > min ? (value - min) / (max - min) : 0);
203
- const startProgress = useSharedValue(0);
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
- const next = max > min ? (value - min) / (max - min) : 0;
230
- progress.value = withSpring(next, SPRING);
231
- }, [value, min, max, progress]);
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 * (max - min);
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
- startProgress.value = progress.value;
256
- pressed.value = withSpring(1, KNOB_SPRING);
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
- const next = Math.min(Math.max(startProgress.value + delta, 0), 1);
267
- progress.value = next;
268
- runOnJS(commitFromProgress)(next, false);
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
- runOnJS(commitFromProgress)(progress.value, true);
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
- progress.value = withSpring(next, SPRING);
286
- runOnJS(commitFromProgress)(next, true);
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 the thumb rather than up to it, so the two never show
292
- // a seam between them as the thumb moves.
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 { width: progress.value * travel + thumbWidth };
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: progress.value * travel * sign }] };
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
- const next = snap(value + dir * (step || 1), min, max, step);
312
- if (next === value) return;
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
- progress.value = withSpring(max > min ? (next - min) / (max - min) : 0, SPRING);
318
- emitChange(next);
319
- emitCommit(next);
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 onAccessibilityAction = (event: AccessibilityActionEvent) => {
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 shownValue = formatValue ? formatValue(value) : String(Math.round(value));
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={label}
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={onAccessibilityAction}
630
+ onAccessibilityAction={accessibilityAction('high')}
389
631
  >
390
632
  <Animated.View
391
633
  style={knobStyle}