@octanejs/radix 0.1.2

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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +45 -0
  4. package/src/AccessibleIcon.ts +26 -0
  5. package/src/Accordion.ts +282 -0
  6. package/src/AlertDialog.ts +110 -0
  7. package/src/Arrow.ts +21 -0
  8. package/src/AspectRatio.ts +34 -0
  9. package/src/Avatar.ts +152 -0
  10. package/src/Checkbox.ts +325 -0
  11. package/src/Collapsible.ts +170 -0
  12. package/src/ContextMenu.ts +303 -0
  13. package/src/Dialog.ts +308 -0
  14. package/src/DismissableLayer.ts +413 -0
  15. package/src/DropdownMenu.ts +275 -0
  16. package/src/FocusScope.ts +266 -0
  17. package/src/Form.ts +621 -0
  18. package/src/HoverCard.ts +318 -0
  19. package/src/Label.ts +25 -0
  20. package/src/Menu.ts +1057 -0
  21. package/src/Menubar.ts +572 -0
  22. package/src/NavigationMenu.ts +1236 -0
  23. package/src/OneTimePasswordField.ts +977 -0
  24. package/src/PasswordToggleField.ts +495 -0
  25. package/src/Popover.ts +354 -0
  26. package/src/Popper.ts +401 -0
  27. package/src/Portal.ts +19 -0
  28. package/src/Presence.ts +225 -0
  29. package/src/Primitive.ts +53 -0
  30. package/src/Progress.ts +92 -0
  31. package/src/Radio.ts +262 -0
  32. package/src/RadioGroup.ts +212 -0
  33. package/src/RovingFocusGroup.ts +259 -0
  34. package/src/ScrollArea.ts +966 -0
  35. package/src/Select.ts +1901 -0
  36. package/src/Separator.ts +36 -0
  37. package/src/Slider.ts +745 -0
  38. package/src/Slot.ts +105 -0
  39. package/src/Switch.ts +278 -0
  40. package/src/Tabs.ts +177 -0
  41. package/src/Toast.ts +923 -0
  42. package/src/Toggle.ts +31 -0
  43. package/src/ToggleGroup.ts +157 -0
  44. package/src/Toolbar.ts +130 -0
  45. package/src/Tooltip.ts +669 -0
  46. package/src/VisuallyHidden.ts +29 -0
  47. package/src/collection.ts +97 -0
  48. package/src/compose-event-handlers.ts +15 -0
  49. package/src/compose-refs.ts +53 -0
  50. package/src/context.ts +109 -0
  51. package/src/direction.ts +19 -0
  52. package/src/focus-guards.ts +56 -0
  53. package/src/index.ts +54 -0
  54. package/src/internal.ts +42 -0
  55. package/src/scroll-lock.ts +57 -0
  56. package/src/use-callback-ref.ts +28 -0
  57. package/src/use-effect-event.ts +36 -0
  58. package/src/use-is-hydrated.ts +23 -0
  59. package/src/use-previous.ts +28 -0
  60. package/src/use-size.ts +57 -0
  61. package/src/useControllableState.ts +78 -0
  62. package/src/useId.ts +15 -0
package/src/Slider.ts ADDED
@@ -0,0 +1,745 @@
1
+ // Ported from @radix-ui/react-slider (source:
2
+ // .radix-primitives/packages/react/slider/src/slider.tsx). A multi-thumb slider:
3
+ // Root owns the sorted values array (controllable) + keyboard/pointer update logic;
4
+ // Horizontal/Vertical orientation layers translate pointer positions and step keys;
5
+ // Impl owns pointer capture + slide events; Track/Range/Thumb render the parts (thumb
6
+ // positions are percentage-based with an in-bounds offset). Each Thumb renders a
7
+ // hidden native input "bubble input" inside forms (value synced via the native
8
+ // `value` setter + a dispatched bubbling `input` event — octane's native `<form
9
+ // onInput>` observes it directly, so no extra adaptation is needed here).
10
+ import { createElement, useEffect, useMemo, useRef, useState } from 'octane';
11
+
12
+ import { createCollection } from './collection';
13
+ import { composeEventHandlers } from './compose-event-handlers';
14
+ import { useComposedRefs } from './compose-refs';
15
+ import { createContextScope } from './context';
16
+ import { useDirection } from './direction';
17
+ import { S, subSlot } from './internal';
18
+ import { Primitive } from './Primitive';
19
+ import { usePrevious } from './use-previous';
20
+ import { useSize } from './use-size';
21
+ import { useControllableState } from './useControllableState';
22
+
23
+ type Direction = 'ltr' | 'rtl';
24
+
25
+ const PAGE_KEYS = ['PageUp', 'PageDown'];
26
+ const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
27
+
28
+ type SlideDirection = 'from-left' | 'from-right' | 'from-bottom' | 'from-top';
29
+ const BACK_KEYS: Record<SlideDirection, string[]> = {
30
+ 'from-left': ['Home', 'PageDown', 'ArrowDown', 'ArrowLeft'],
31
+ 'from-right': ['Home', 'PageDown', 'ArrowDown', 'ArrowRight'],
32
+ 'from-bottom': ['Home', 'PageDown', 'ArrowDown', 'ArrowLeft'],
33
+ 'from-top': ['Home', 'PageDown', 'ArrowUp', 'ArrowLeft'],
34
+ };
35
+
36
+ const SLIDER_NAME = 'Slider';
37
+
38
+ const [Collection, useCollection, createCollectionScope] = createCollection(SLIDER_NAME);
39
+
40
+ const [createSliderContext, createSliderScope] = createContextScope(SLIDER_NAME, [
41
+ createCollectionScope,
42
+ ]);
43
+ export { createSliderScope };
44
+
45
+ interface SliderContextValue {
46
+ name: string | undefined;
47
+ disabled: boolean | undefined;
48
+ min: number;
49
+ max: number;
50
+ values: number[];
51
+ valueIndexToChangeRef: { current: number };
52
+ thumbs: Set<HTMLElement>;
53
+ orientation: 'horizontal' | 'vertical' | undefined;
54
+ form: string | undefined;
55
+ }
56
+
57
+ const [SliderProvider, useSliderContext] = createSliderContext<SliderContextValue>(SLIDER_NAME);
58
+
59
+ export function Root(props: any): any {
60
+ const slot = S('Slider.Root');
61
+ const {
62
+ __scopeSlider,
63
+ name,
64
+ min = 0,
65
+ max = 100,
66
+ step = 1,
67
+ orientation = 'horizontal',
68
+ disabled = false,
69
+ minStepsBetweenThumbs = 0,
70
+ defaultValue = [min],
71
+ value,
72
+ onValueChange = () => {},
73
+ onValueCommit = () => {},
74
+ inverted = false,
75
+ form,
76
+ ref: forwardedRef,
77
+ ...sliderProps
78
+ } = props ?? {};
79
+ const thumbRefs = useRef<Set<HTMLElement>>(new Set(), subSlot(slot, 'thumbs'));
80
+ const valueIndexToChangeRef = useRef<number>(0, subSlot(slot, 'index'));
81
+ const isKeyboardInteractionRef = useRef(false, subSlot(slot, 'keyboard'));
82
+ const isHorizontal = orientation === 'horizontal';
83
+ const SliderOrientation = isHorizontal ? SliderHorizontal : SliderVertical;
84
+
85
+ const [valuesState, setValues] = useControllableState<number[]>(
86
+ {
87
+ prop: value,
88
+ defaultProp: defaultValue,
89
+ onChange: (value: number[]) => {
90
+ const thumbs = [...thumbRefs.current];
91
+ thumbs[valueIndexToChangeRef.current]?.focus({
92
+ preventScroll: true,
93
+ focusVisible: isKeyboardInteractionRef.current,
94
+ } as FocusOptions);
95
+ isKeyboardInteractionRef.current = false;
96
+ onValueChange(value);
97
+ },
98
+ },
99
+ subSlot(slot, 'values'),
100
+ );
101
+ const values = valuesState ?? [];
102
+ const valuesBeforeSlideStartRef = useRef(values, subSlot(slot, 'before'));
103
+
104
+ function handleSlideStart(value: number): void {
105
+ const closestIndex = getClosestValueIndex(values, value);
106
+ updateValues(value, closestIndex);
107
+ }
108
+
109
+ function handleSlideMove(value: number): void {
110
+ updateValues(value, valueIndexToChangeRef.current);
111
+ }
112
+
113
+ function handleSlideEnd(): void {
114
+ const prevValue = valuesBeforeSlideStartRef.current[valueIndexToChangeRef.current];
115
+ const nextValue = values[valueIndexToChangeRef.current];
116
+ const hasChanged = nextValue !== prevValue;
117
+ if (hasChanged) onValueCommit(values);
118
+ }
119
+
120
+ function updateValues(value: number, atIndex: number, { commit } = { commit: false }): void {
121
+ const decimalCount = getDecimalCount(step);
122
+ const snapToStep = roundValue(Math.round((value - min) / step) * step + min, decimalCount);
123
+ const nextValue = clamp(snapToStep, [min, max]);
124
+
125
+ setValues((prevValues = []) => {
126
+ const nextValues = getNextSortedValues(prevValues, nextValue, atIndex);
127
+ if (hasMinStepsBetweenValues(nextValues, minStepsBetweenThumbs * step)) {
128
+ valueIndexToChangeRef.current = nextValues.indexOf(nextValue);
129
+ const hasChanged = String(nextValues) !== String(prevValues);
130
+ if (hasChanged && commit) onValueCommit(nextValues);
131
+ return hasChanged ? nextValues : prevValues;
132
+ } else {
133
+ return prevValues;
134
+ }
135
+ });
136
+ }
137
+
138
+ return createElement(SliderProvider, {
139
+ scope: __scopeSlider,
140
+ name,
141
+ disabled,
142
+ min,
143
+ max,
144
+ valueIndexToChangeRef,
145
+ thumbs: thumbRefs.current,
146
+ values,
147
+ orientation,
148
+ form,
149
+ children: createElement(Collection.Provider, {
150
+ scope: __scopeSlider,
151
+ children: createElement(Collection.Slot, {
152
+ scope: __scopeSlider,
153
+ children: createElement(SliderOrientation, {
154
+ 'aria-disabled': disabled,
155
+ 'data-disabled': disabled ? '' : undefined,
156
+ ...sliderProps,
157
+ __scopeSlider,
158
+ ref: forwardedRef,
159
+ onPointerDown: composeEventHandlers(sliderProps.onPointerDown, () => {
160
+ if (!disabled) {
161
+ valuesBeforeSlideStartRef.current = values;
162
+ isKeyboardInteractionRef.current = false;
163
+ }
164
+ }),
165
+ min,
166
+ max,
167
+ inverted,
168
+ onSlideStart: disabled ? undefined : handleSlideStart,
169
+ onSlideMove: disabled ? undefined : handleSlideMove,
170
+ onSlideEnd: disabled ? undefined : handleSlideEnd,
171
+ onHomeKeyDown: () => {
172
+ if (!disabled) {
173
+ isKeyboardInteractionRef.current = true;
174
+ updateValues(min, 0, { commit: true });
175
+ }
176
+ },
177
+ onEndKeyDown: () => {
178
+ if (!disabled) {
179
+ isKeyboardInteractionRef.current = true;
180
+ updateValues(max, values.length - 1, { commit: true });
181
+ }
182
+ },
183
+ onStepKeyDown: ({
184
+ event,
185
+ direction: stepDirection,
186
+ }: {
187
+ event: KeyboardEvent;
188
+ direction: number;
189
+ }) => {
190
+ if (!disabled) {
191
+ isKeyboardInteractionRef.current = true;
192
+ const isPageKey = PAGE_KEYS.includes(event.key);
193
+ const isSkipKey = isPageKey || (event.shiftKey && ARROW_KEYS.includes(event.key));
194
+ const multiplier = isSkipKey ? 10 : 1;
195
+ const atIndex = valueIndexToChangeRef.current;
196
+ const value = values[atIndex]!;
197
+ const stepInDirection = step * multiplier * stepDirection;
198
+ updateValues(value + stepInDirection, atIndex, { commit: true });
199
+ }
200
+ },
201
+ }),
202
+ }),
203
+ }),
204
+ });
205
+ }
206
+
207
+ const [SliderOrientationProvider, useSliderOrientationContext] = createSliderContext<{
208
+ startEdge: 'top' | 'right' | 'bottom' | 'left';
209
+ endEdge: 'top' | 'right' | 'bottom' | 'left';
210
+ size: 'width' | 'height';
211
+ direction: number;
212
+ }>(SLIDER_NAME, {
213
+ startEdge: 'left',
214
+ endEdge: 'right',
215
+ size: 'width',
216
+ direction: 1,
217
+ });
218
+
219
+ function SliderHorizontal(props: any): any {
220
+ const slot = S('Slider.Horizontal');
221
+ const {
222
+ min,
223
+ max,
224
+ dir,
225
+ inverted,
226
+ onSlideStart,
227
+ onSlideMove,
228
+ onSlideEnd,
229
+ onStepKeyDown,
230
+ ref: forwardedRef,
231
+ ...sliderProps
232
+ } = props;
233
+ const [slider, setSlider] = useState<HTMLElement | null>(null, subSlot(slot, 'slider'));
234
+ const composedRefs = useComposedRefs(forwardedRef, setSlider, subSlot(slot, 'refs'));
235
+ const rectRef = useRef<DOMRect | undefined>(undefined, subSlot(slot, 'rect'));
236
+ const direction = useDirection(dir);
237
+ const isDirectionLTR = direction === 'ltr';
238
+ const isSlidingFromLeft = (isDirectionLTR && !inverted) || (!isDirectionLTR && inverted);
239
+
240
+ function getValueFromPointer(pointerPosition: number): number {
241
+ const rect = rectRef.current || slider!.getBoundingClientRect();
242
+ const input: [number, number] = [0, rect.width];
243
+ const output: [number, number] = isSlidingFromLeft ? [min, max] : [max, min];
244
+ const value = linearScale(input, output);
245
+
246
+ rectRef.current = rect;
247
+ return value(pointerPosition - rect.left);
248
+ }
249
+
250
+ return createElement(SliderOrientationProvider, {
251
+ scope: props.__scopeSlider,
252
+ startEdge: isSlidingFromLeft ? 'left' : 'right',
253
+ endEdge: isSlidingFromLeft ? 'right' : 'left',
254
+ direction: isSlidingFromLeft ? 1 : -1,
255
+ size: 'width',
256
+ children: createElement(SliderImpl, {
257
+ dir: direction,
258
+ 'data-orientation': 'horizontal',
259
+ ...sliderProps,
260
+ ref: composedRefs,
261
+ style: {
262
+ ...sliderProps.style,
263
+ '--radix-slider-thumb-transform': 'translateX(-50%)',
264
+ },
265
+ onSlideStart: (event: PointerEvent) => {
266
+ const value = getValueFromPointer(event.clientX);
267
+ onSlideStart?.(value);
268
+ },
269
+ onSlideMove: (event: PointerEvent) => {
270
+ const value = getValueFromPointer(event.clientX);
271
+ onSlideMove?.(value);
272
+ },
273
+ onSlideEnd: () => {
274
+ rectRef.current = undefined;
275
+ onSlideEnd?.();
276
+ },
277
+ onStepKeyDown: (event: KeyboardEvent) => {
278
+ const slideDirection = isSlidingFromLeft ? 'from-left' : 'from-right';
279
+ const isBackKey = BACK_KEYS[slideDirection].includes(event.key);
280
+ onStepKeyDown?.({ event, direction: isBackKey ? -1 : 1 });
281
+ },
282
+ }),
283
+ });
284
+ }
285
+
286
+ function SliderVertical(props: any): any {
287
+ const slot = S('Slider.Vertical');
288
+ const {
289
+ min,
290
+ max,
291
+ inverted,
292
+ onSlideStart,
293
+ onSlideMove,
294
+ onSlideEnd,
295
+ onStepKeyDown,
296
+ ref: forwardedRef,
297
+ ...sliderProps
298
+ } = props;
299
+ const sliderRef = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
300
+ const ref = useComposedRefs(forwardedRef, sliderRef, subSlot(slot, 'refs'));
301
+ const rectRef = useRef<DOMRect | undefined>(undefined, subSlot(slot, 'rect'));
302
+ const isSlidingFromBottom = !inverted;
303
+
304
+ function getValueFromPointer(pointerPosition: number): number {
305
+ const rect = rectRef.current || (sliderRef.current as HTMLElement).getBoundingClientRect();
306
+ const input: [number, number] = [0, rect.height];
307
+ const output: [number, number] = isSlidingFromBottom ? [max, min] : [min, max];
308
+ const value = linearScale(input, output);
309
+
310
+ rectRef.current = rect;
311
+ return value(pointerPosition - rect.top);
312
+ }
313
+
314
+ return createElement(SliderOrientationProvider, {
315
+ scope: props.__scopeSlider,
316
+ startEdge: isSlidingFromBottom ? 'bottom' : 'top',
317
+ endEdge: isSlidingFromBottom ? 'top' : 'bottom',
318
+ size: 'height',
319
+ direction: isSlidingFromBottom ? 1 : -1,
320
+ children: createElement(SliderImpl, {
321
+ 'data-orientation': 'vertical',
322
+ ...sliderProps,
323
+ ref,
324
+ style: {
325
+ ...sliderProps.style,
326
+ '--radix-slider-thumb-transform': 'translateY(50%)',
327
+ },
328
+ onSlideStart: (event: PointerEvent) => {
329
+ const value = getValueFromPointer(event.clientY);
330
+ onSlideStart?.(value);
331
+ },
332
+ onSlideMove: (event: PointerEvent) => {
333
+ const value = getValueFromPointer(event.clientY);
334
+ onSlideMove?.(value);
335
+ },
336
+ onSlideEnd: () => {
337
+ rectRef.current = undefined;
338
+ onSlideEnd?.();
339
+ },
340
+ onStepKeyDown: (event: KeyboardEvent) => {
341
+ const slideDirection = isSlidingFromBottom ? 'from-bottom' : 'from-top';
342
+ const isBackKey = BACK_KEYS[slideDirection].includes(event.key);
343
+ onStepKeyDown?.({ event, direction: isBackKey ? -1 : 1 });
344
+ },
345
+ }),
346
+ });
347
+ }
348
+
349
+ function SliderImpl(props: any): any {
350
+ const {
351
+ __scopeSlider,
352
+ onSlideStart,
353
+ onSlideMove,
354
+ onSlideEnd,
355
+ onHomeKeyDown,
356
+ onEndKeyDown,
357
+ onStepKeyDown,
358
+ ref: forwardedRef,
359
+ ...sliderProps
360
+ } = props;
361
+ const context = useSliderContext(SLIDER_NAME, __scopeSlider);
362
+
363
+ return createElement(Primitive.span, {
364
+ ...sliderProps,
365
+ ref: forwardedRef,
366
+ onKeyDown: composeEventHandlers(props.onKeyDown, (event: KeyboardEvent) => {
367
+ if (event.key === 'Home') {
368
+ onHomeKeyDown(event);
369
+ // Prevent scrolling to page start
370
+ event.preventDefault();
371
+ } else if (event.key === 'End') {
372
+ onEndKeyDown(event);
373
+ // Prevent scrolling to page end
374
+ event.preventDefault();
375
+ } else if (PAGE_KEYS.concat(ARROW_KEYS).includes(event.key)) {
376
+ onStepKeyDown(event);
377
+ // Prevent scrolling for directional key presses
378
+ event.preventDefault();
379
+ }
380
+ }),
381
+ onPointerDown: composeEventHandlers(props.onPointerDown, (event: PointerEvent) => {
382
+ const target = event.target as HTMLElement;
383
+ target.setPointerCapture(event.pointerId);
384
+ // Prevent browser focus behaviour because we focus a thumb manually when values change.
385
+ event.preventDefault();
386
+ // Touch devices have a delay before focusing so won't focus if touch immediately moves
387
+ // away from target (sliding). We want thumb to focus regardless.
388
+ if (context.thumbs.has(target)) {
389
+ // Pointer interaction, so avoid showing the focus ring (`:focus-visible`).
390
+ target.focus({ preventScroll: true, focusVisible: false } as FocusOptions);
391
+ } else {
392
+ onSlideStart(event);
393
+ }
394
+ }),
395
+ onPointerMove: composeEventHandlers(props.onPointerMove, (event: PointerEvent) => {
396
+ const target = event.target as HTMLElement;
397
+ if (target.hasPointerCapture(event.pointerId)) onSlideMove(event);
398
+ }),
399
+ onPointerUp: composeEventHandlers(props.onPointerUp, (event: PointerEvent) => {
400
+ const target = event.target as HTMLElement;
401
+ if (target.hasPointerCapture(event.pointerId)) {
402
+ target.releasePointerCapture(event.pointerId);
403
+ onSlideEnd(event);
404
+ }
405
+ }),
406
+ });
407
+ }
408
+
409
+ export function Track(props: any): any {
410
+ const { __scopeSlider, ...trackProps } = props ?? {};
411
+ const context = useSliderContext('SliderTrack', __scopeSlider);
412
+ return createElement(Primitive.span, {
413
+ 'data-disabled': context.disabled ? '' : undefined,
414
+ 'data-orientation': context.orientation,
415
+ ...trackProps,
416
+ });
417
+ }
418
+
419
+ export function Range(props: any): any {
420
+ const slot = S('Slider.Range');
421
+ const { __scopeSlider, ref: forwardedRef, ...rangeProps } = props ?? {};
422
+ const context = useSliderContext('SliderRange', __scopeSlider);
423
+ const orientation = useSliderOrientationContext('SliderRange', __scopeSlider);
424
+ const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
425
+ const composedRefs = useComposedRefs(forwardedRef, ref, subSlot(slot, 'refs'));
426
+ const valuesCount = context.values.length;
427
+ const percentages = context.values.map((value: number) =>
428
+ convertValueToPercentage(value, context.min, context.max),
429
+ );
430
+ const offsetStart = valuesCount > 1 ? Math.min(...percentages) : 0;
431
+ const offsetEnd = 100 - Math.max(...percentages);
432
+
433
+ return createElement(Primitive.span, {
434
+ 'data-orientation': context.orientation,
435
+ 'data-disabled': context.disabled ? '' : undefined,
436
+ ...rangeProps,
437
+ ref: composedRefs,
438
+ style: {
439
+ ...props?.style,
440
+ [orientation.startEdge]: offsetStart + '%',
441
+ [orientation.endEdge]: offsetEnd + '%',
442
+ },
443
+ });
444
+ }
445
+
446
+ const THUMB_NAME = 'SliderThumb';
447
+
448
+ interface SliderThumbContextValue {
449
+ value: number | undefined;
450
+ name: string | undefined;
451
+ form: string | undefined;
452
+ isFormControl: boolean;
453
+ index: number;
454
+ thumb: HTMLElement | null;
455
+ onThumbChange(thumb: HTMLElement | null): void;
456
+ percent: number;
457
+ size: { width: number; height: number } | undefined;
458
+ }
459
+
460
+ const [SliderThumbContextProvider, useSliderThumbContext] =
461
+ createSliderContext<SliderThumbContextValue>(THUMB_NAME);
462
+
463
+ function ThumbProvider(props: any): any {
464
+ const slot = S('Slider.ThumbProvider');
465
+ const { __scopeSlider, name, children, internal_do_not_use_render } = props;
466
+ const context = useSliderContext('SliderThumbProvider', __scopeSlider);
467
+ const getItems = useCollection(__scopeSlider, subSlot(slot, 'items'));
468
+ const [thumb, setThumb] = useState<HTMLElement | null>(null, subSlot(slot, 'thumb'));
469
+ const index = useMemo(
470
+ () => (thumb ? getItems().findIndex((item: any) => item.ref.current === thumb) : -1),
471
+ [getItems, thumb],
472
+ subSlot(slot, 'index'),
473
+ );
474
+ const size = useSize(thumb, subSlot(slot, 'size'));
475
+ // We set this to true by default so that events bubble to forms without JS (SSR)
476
+ const isFormControl = thumb ? !!context.form || !!thumb.closest('form') : true;
477
+ // We cast because index could be `-1` which would return undefined
478
+ const value = context.values[index] as number | undefined;
479
+ const resolvedName =
480
+ name ?? (context.name ? context.name + (context.values.length > 1 ? '[]' : '') : undefined);
481
+ const percent =
482
+ value === undefined ? 0 : convertValueToPercentage(value, context.min, context.max);
483
+
484
+ useEffect(
485
+ () => {
486
+ if (thumb) {
487
+ context.thumbs.add(thumb);
488
+ return () => {
489
+ context.thumbs.delete(thumb);
490
+ };
491
+ }
492
+ },
493
+ [thumb, context.thumbs],
494
+ subSlot(slot, 'e:thumb'),
495
+ );
496
+
497
+ const thumbContext: SliderThumbContextValue = {
498
+ value,
499
+ name: resolvedName,
500
+ form: context.form,
501
+ isFormControl,
502
+ index,
503
+ thumb,
504
+ onThumbChange: setThumb,
505
+ percent,
506
+ size,
507
+ };
508
+
509
+ return createElement(SliderThumbContextProvider, {
510
+ scope: __scopeSlider,
511
+ ...thumbContext,
512
+ children: isFunction(internal_do_not_use_render)
513
+ ? internal_do_not_use_render(thumbContext)
514
+ : children,
515
+ });
516
+ }
517
+
518
+ function ThumbTrigger(props: any): any {
519
+ const slot = S('Slider.ThumbTrigger');
520
+ const { __scopeSlider, ref: forwardedRef, ...thumbProps } = props;
521
+ const context = useSliderContext('SliderThumbTrigger', __scopeSlider);
522
+ const orientation = useSliderOrientationContext('SliderThumbTrigger', __scopeSlider);
523
+ const { index, value, percent, size, onThumbChange } = useSliderThumbContext(
524
+ 'SliderThumbTrigger',
525
+ __scopeSlider,
526
+ );
527
+ const composedRefs = useComposedRefs(forwardedRef, onThumbChange, subSlot(slot, 'refs'));
528
+ const label = getLabel(index, context.values.length);
529
+ const orientationSize = size?.[orientation.size];
530
+ const thumbInBoundsOffset = orientationSize
531
+ ? getThumbInBoundsOffset(orientationSize, percent, orientation.direction)
532
+ : 0;
533
+
534
+ return createElement('span', {
535
+ style: {
536
+ transform: 'var(--radix-slider-thumb-transform)',
537
+ position: 'absolute',
538
+ [orientation.startEdge]: `calc(${percent}% + ${thumbInBoundsOffset}px)`,
539
+ },
540
+ children: createElement(Collection.ItemSlot, {
541
+ scope: __scopeSlider,
542
+ children: createElement(Primitive.span, {
543
+ role: 'slider',
544
+ 'aria-label': props['aria-label'] || label,
545
+ 'aria-valuemin': context.min,
546
+ 'aria-valuenow': value,
547
+ 'aria-valuemax': context.max,
548
+ 'aria-orientation': context.orientation,
549
+ 'data-orientation': context.orientation,
550
+ 'data-disabled': context.disabled ? '' : undefined,
551
+ tabIndex: context.disabled ? undefined : 0,
552
+ ...thumbProps,
553
+ ref: composedRefs,
554
+ // There will be no value on initial render while we work out the index so
555
+ // we hide thumbs without a value; otherwise SSR would render them in the
556
+ // wrong position before they snap into place during hydration.
557
+ style: value === undefined ? { display: 'none' } : props.style,
558
+ onFocus: composeEventHandlers(props.onFocus, () => {
559
+ context.valueIndexToChangeRef.current = index;
560
+ }),
561
+ }),
562
+ }),
563
+ });
564
+ }
565
+
566
+ export function Thumb(props: any): any {
567
+ const { __scopeSlider, name, ref: forwardedRef, ...thumbProps } = props ?? {};
568
+ return createElement(ThumbProvider, {
569
+ __scopeSlider,
570
+ name,
571
+ internal_do_not_use_render: ({ index, isFormControl }: SliderThumbContextValue) => [
572
+ createElement(ThumbTrigger, {
573
+ key: 'trigger',
574
+ ...thumbProps,
575
+ ref: forwardedRef,
576
+ __scopeSlider,
577
+ }),
578
+ isFormControl ? createElement(BubbleInput, { key: 'bubble-' + index, __scopeSlider }) : null,
579
+ ],
580
+ });
581
+ }
582
+
583
+ export function BubbleInput(props: any): any {
584
+ const slot = S('Slider.BubbleInput');
585
+ const { __scopeSlider, ref: forwardedRef, ...inputProps } = props ?? {};
586
+ const { value, name, form } = useSliderThumbContext('SliderBubbleInput', __scopeSlider);
587
+ const ref = useRef<HTMLInputElement | null>(null, subSlot(slot, 'ref'));
588
+ const composedRefs = useComposedRefs(ref, forwardedRef, subSlot(slot, 'refs'));
589
+ const prevValue = usePrevious(value, subSlot(slot, 'prev'));
590
+
591
+ // Bubble value change to parents (e.g form change event)
592
+ useEffect(
593
+ () => {
594
+ const input = ref.current;
595
+ if (!input) return;
596
+
597
+ const inputProto = window.HTMLInputElement.prototype;
598
+ const descriptor = Object.getOwnPropertyDescriptor(inputProto, 'value') as PropertyDescriptor;
599
+ const setValue = descriptor.set;
600
+ if (prevValue !== value && setValue) {
601
+ const event = new Event('input', { bubbles: true });
602
+ setValue.call(input, value);
603
+ input.dispatchEvent(event);
604
+ }
605
+ },
606
+ [prevValue, value],
607
+ subSlot(slot, 'e:bubble'),
608
+ );
609
+
610
+ // We purposefully do not use `type="hidden"` here otherwise forms that wrap it
611
+ // will not be able to access its value via the FormData API.
612
+ //
613
+ // octane: the source omits React's `value` prop (its controlled model would
614
+ // swallow the programmatic dispatch) and uses `defaultValue`; the octane
615
+ // equivalent of default-value semantics is the native `value` ATTRIBUTE, which
616
+ // the property setter above never touches.
617
+ return createElement(Primitive.input, {
618
+ style: { display: 'none' },
619
+ name,
620
+ form,
621
+ ...inputProps,
622
+ ref: composedRefs,
623
+ value,
624
+ });
625
+ }
626
+
627
+ function getNextSortedValues(prevValues: number[] = [], nextValue: number, atIndex: number) {
628
+ const nextValues = [...prevValues];
629
+ nextValues[atIndex] = nextValue;
630
+ return nextValues.sort((a, b) => a - b);
631
+ }
632
+
633
+ function convertValueToPercentage(value: number, min: number, max: number): number {
634
+ const maxSteps = max - min;
635
+ const percentPerStep = 100 / maxSteps;
636
+ const percentage = percentPerStep * (value - min);
637
+ return clamp(percentage, [0, 100]);
638
+ }
639
+
640
+ /**
641
+ * Returns a label for each thumb when there are two or more thumbs
642
+ */
643
+ function getLabel(index: number, totalValues: number): string | undefined {
644
+ if (totalValues > 2) {
645
+ return `Value ${index + 1} of ${totalValues}`;
646
+ } else if (totalValues === 2) {
647
+ return ['Minimum', 'Maximum'][index];
648
+ } else {
649
+ return undefined;
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Given a `values` array and a `nextValue`, determine which value in
655
+ * the array is closest to `nextValue` and return its index.
656
+ */
657
+ function getClosestValueIndex(values: number[], nextValue: number): number {
658
+ if (values.length === 1) return 0;
659
+ const distances = values.map((value) => Math.abs(value - nextValue));
660
+ const closestDistance = Math.min(...distances);
661
+ return distances.indexOf(closestDistance);
662
+ }
663
+
664
+ /**
665
+ * Offsets the thumb centre point while sliding to ensure it remains
666
+ * within the bounds of the slider when reaching the edges
667
+ */
668
+ function getThumbInBoundsOffset(width: number, left: number, direction: number): number {
669
+ const halfWidth = width / 2;
670
+ const halfPercent = 50;
671
+ const offset = linearScale([0, halfPercent], [0, halfWidth]);
672
+ return (halfWidth - offset(left) * direction) * direction;
673
+ }
674
+
675
+ /**
676
+ * Gets an array of steps between each value.
677
+ */
678
+ function getStepsBetweenValues(values: number[]): number[] {
679
+ return values.slice(0, -1).map((value, index) => values[index + 1]! - value);
680
+ }
681
+
682
+ /**
683
+ * Verifies the minimum steps between all values is greater than or equal
684
+ * to the expected minimum steps.
685
+ */
686
+ function hasMinStepsBetweenValues(values: number[], minStepsBetweenValues: number): boolean {
687
+ if (minStepsBetweenValues > 0) {
688
+ const stepsBetweenValues = getStepsBetweenValues(values);
689
+ const actualMinStepsBetweenValues = Math.min(...stepsBetweenValues);
690
+ return actualMinStepsBetweenValues >= minStepsBetweenValues;
691
+ }
692
+ return true;
693
+ }
694
+
695
+ // https://github.com/tmcw-up-for-adoption/simple-linear-scale/blob/master/index.js
696
+ function linearScale(input: readonly [number, number], output: readonly [number, number]) {
697
+ return (value: number) => {
698
+ if (input[0] === input[1] || output[0] === output[1]) return output[0];
699
+ const ratio = (output[1] - output[0]) / (input[1] - input[0]);
700
+ return output[0] + ratio * (value - input[0]);
701
+ };
702
+ }
703
+
704
+ function getDecimalCount(value: number): number {
705
+ if (!Number.isFinite(value)) return 0;
706
+
707
+ const str = value.toString();
708
+
709
+ // Numbers with a magnitude below 1e-6 (or very large numbers) are serialized
710
+ // in scientific notation (e.g. `1e-7`), so we can't just count the digits
711
+ // after the decimal point (radix#3852).
712
+ if (str.includes('e')) {
713
+ const [coefficient, exponent] = str.split('e');
714
+ const decimalPart = coefficient!.split('.')[1] || '';
715
+ const exponentNum = Number(exponent);
716
+ return Math.max(0, decimalPart.length - exponentNum);
717
+ }
718
+
719
+ const decimalPart = str.split('.')[1];
720
+ return decimalPart ? decimalPart.length : 0;
721
+ }
722
+
723
+ function roundValue(value: number, decimalCount: number): number {
724
+ const rounder = Math.pow(10, decimalCount);
725
+ return Math.round(value * rounder) / rounder;
726
+ }
727
+
728
+ // @radix-ui/number's clamp, inlined (its only export).
729
+ function clamp(value: number, [min, max]: [number, number]): number {
730
+ return Math.min(max, Math.max(min, value));
731
+ }
732
+
733
+ function isFunction(value: unknown): value is (...args: any[]) => any {
734
+ return typeof value === 'function';
735
+ }
736
+
737
+ export {
738
+ Root as Slider,
739
+ Track as SliderTrack,
740
+ Range as SliderRange,
741
+ Thumb as SliderThumb,
742
+ ThumbProvider as SliderThumbProvider,
743
+ ThumbTrigger as SliderThumbTrigger,
744
+ BubbleInput as SliderBubbleInput,
745
+ };