@octanejs/base-ui 0.1.1

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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -0
  3. package/package.json +47 -0
  4. package/src/avatar.ts +308 -0
  5. package/src/checkbox-group.ts +183 -0
  6. package/src/checkbox.ts +578 -0
  7. package/src/field.ts +676 -0
  8. package/src/fieldset.ts +150 -0
  9. package/src/form.ts +183 -0
  10. package/src/index.ts +23 -0
  11. package/src/input.ts +9 -0
  12. package/src/internal.ts +42 -0
  13. package/src/merge-props.ts +2 -0
  14. package/src/meter.ts +204 -0
  15. package/src/number-field.ts +978 -0
  16. package/src/progress.ts +260 -0
  17. package/src/radio-group.ts +285 -0
  18. package/src/radio.ts +401 -0
  19. package/src/separator.ts +43 -0
  20. package/src/slider.ts +1617 -0
  21. package/src/switch.ts +374 -0
  22. package/src/toggle-group.ts +136 -0
  23. package/src/toggle.ts +145 -0
  24. package/src/use-render.ts +40 -0
  25. package/src/utils/CheckboxGroupContext.ts +30 -0
  26. package/src/utils/CompositeRootContext.ts +27 -0
  27. package/src/utils/DirectionContext.ts +15 -0
  28. package/src/utils/RadioGroupContext.ts +27 -0
  29. package/src/utils/ToggleGroupContext.ts +33 -0
  30. package/src/utils/addEventListener.ts +13 -0
  31. package/src/utils/areArraysEqual.ts +11 -0
  32. package/src/utils/clamp.ts +8 -0
  33. package/src/utils/composeRefs.ts +53 -0
  34. package/src/utils/composite/CompositeItem.ts +56 -0
  35. package/src/utils/composite/CompositeList.ts +190 -0
  36. package/src/utils/composite/CompositeListContext.ts +23 -0
  37. package/src/utils/composite/CompositeRoot.ts +132 -0
  38. package/src/utils/composite/keys.ts +179 -0
  39. package/src/utils/composite/list-utils.ts +78 -0
  40. package/src/utils/composite/useCompositeItem.ts +58 -0
  41. package/src/utils/composite/useCompositeListItem.ts +108 -0
  42. package/src/utils/composite/useCompositeRoot.ts +346 -0
  43. package/src/utils/contains.ts +22 -0
  44. package/src/utils/createChangeEventDetails.ts +81 -0
  45. package/src/utils/field/FieldItemContext.ts +13 -0
  46. package/src/utils/field/FieldRootContext.ts +98 -0
  47. package/src/utils/field/FormContext.ts +37 -0
  48. package/src/utils/field/LabelableContext.ts +32 -0
  49. package/src/utils/field/LabelableProvider.ts +108 -0
  50. package/src/utils/field/constants.ts +80 -0
  51. package/src/utils/field/getCombinedFieldValidityData.ts +16 -0
  52. package/src/utils/field/useAriaLabelledBy.ts +86 -0
  53. package/src/utils/field/useFieldControlRegistration.ts +179 -0
  54. package/src/utils/field/useFieldValidation.ts +311 -0
  55. package/src/utils/field/useLabel.ts +95 -0
  56. package/src/utils/field/useLabelableId.ts +99 -0
  57. package/src/utils/field/useRegisterFieldControl.ts +41 -0
  58. package/src/utils/formatNumber.ts +44 -0
  59. package/src/utils/getDefaultFormSubmitter.ts +19 -0
  60. package/src/utils/getElementRef.ts +13 -0
  61. package/src/utils/getStateAttributesProps.ts +34 -0
  62. package/src/utils/matchesFocusVisible.ts +16 -0
  63. package/src/utils/mergeObjects.ts +18 -0
  64. package/src/utils/mergeProps.ts +206 -0
  65. package/src/utils/noop.ts +4 -0
  66. package/src/utils/number/constants.ts +4 -0
  67. package/src/utils/number/parse.ts +227 -0
  68. package/src/utils/number/types.ts +25 -0
  69. package/src/utils/number/validate.ts +129 -0
  70. package/src/utils/owner.ts +9 -0
  71. package/src/utils/platform.ts +15 -0
  72. package/src/utils/resolveAriaLabelledBy.ts +11 -0
  73. package/src/utils/resolveClassName.ts +8 -0
  74. package/src/utils/resolveRef.ts +10 -0
  75. package/src/utils/resolveStyle.ts +10 -0
  76. package/src/utils/serializeValue.ts +15 -0
  77. package/src/utils/slider/asc.ts +4 -0
  78. package/src/utils/slider/getMidpoint.ts +14 -0
  79. package/src/utils/slider/getPushedThumbValues.ts +73 -0
  80. package/src/utils/slider/getSliderValue.ts +27 -0
  81. package/src/utils/slider/replaceArrayItemAtIndex.ts +8 -0
  82. package/src/utils/slider/resolveThumbCollision.ts +178 -0
  83. package/src/utils/slider/roundValueToStep.ts +22 -0
  84. package/src/utils/slider/validateMinimumDistance.ts +22 -0
  85. package/src/utils/slider/valueArrayToPercentages.ts +11 -0
  86. package/src/utils/stringifyLocale.ts +10 -0
  87. package/src/utils/useAnimationFrame.ts +59 -0
  88. package/src/utils/useAnimationsFinished.ts +100 -0
  89. package/src/utils/useBaseUiId.ts +19 -0
  90. package/src/utils/useButton.ts +217 -0
  91. package/src/utils/useCheckboxGroupParent.ts +113 -0
  92. package/src/utils/useControlled.ts +42 -0
  93. package/src/utils/useFocusableWhenDisabled.ts +84 -0
  94. package/src/utils/useForcedRerendering.ts +11 -0
  95. package/src/utils/useIsHydrating.ts +9 -0
  96. package/src/utils/useOpenChangeComplete.ts +46 -0
  97. package/src/utils/usePressAndHold.ts +35 -0
  98. package/src/utils/useRefWithInit.ts +23 -0
  99. package/src/utils/useRegisteredLabelId.ts +32 -0
  100. package/src/utils/useRenderElement.ts +165 -0
  101. package/src/utils/useStableCallback.ts +26 -0
  102. package/src/utils/useTimeout.ts +51 -0
  103. package/src/utils/useTransitionStatus.ts +115 -0
  104. package/src/utils/useValueAsRef.ts +25 -0
  105. package/src/utils/useValueChanged.ts +37 -0
  106. package/src/utils/valueToPercent.ts +4 -0
  107. package/src/utils/visuallyHidden.ts +24 -0
package/src/slider.ts ADDED
@@ -0,0 +1,1617 @@
1
+ // Ported from .base-ui/packages/react/src/slider/ (v1.6.0): root/SliderRoot (+ SliderRootContext,
2
+ // stateAttributesMapping), control/SliderControl, track/SliderTrack, indicator/SliderIndicator,
3
+ // thumb/SliderThumb, value/SliderValue, label/SliderLabel — plus its `index.parts` (the `Slider`
4
+ // namespace).
5
+ //
6
+ // octane adaptations: `React.forwardRef` → ref-as-prop; native delegated events (no
7
+ // `.nativeEvent` — the handler's `event` IS the native event); `useMergedRefs` → `useComposedRefs`;
8
+ // `useIsoLayoutEffect` → `useLayoutEffect`. Unlike a controlled TEXT input, a controlled range
9
+ // input reflects its live value to the `value` ATTRIBUTE (verified vs React), so octane's native
10
+ // attribute write matches with no freeze/property adaptation. SSR-only bits are dropped: the
11
+ // `thumbAlignment: 'edge'` pre-hydration `<script>` + CSP nonce, and `suppressHydrationWarning` (a
12
+ // React hydration-warning no-op). With the default `center` alignment, thumb/indicator positions
13
+ // are pure math (`valueToPercent`), so mount and keyboard interaction render identically without
14
+ // layout (pointer drag needs real layout — inert in jsdom, so not differential-covered).
15
+ import {
16
+ createContext,
17
+ createElement,
18
+ useContext,
19
+ useEffect,
20
+ useLayoutEffect,
21
+ useMemo,
22
+ useRef,
23
+ useState,
24
+ } from 'octane';
25
+
26
+ import { S, subSlot } from './internal';
27
+ import { useRenderElement } from './utils/useRenderElement';
28
+ import { mergeProps } from './utils/mergeProps';
29
+ import type { StateAttributesMapping } from './utils/getStateAttributesProps';
30
+ import { useComposedRefs } from './utils/composeRefs';
31
+ import { useControlled } from './utils/useControlled';
32
+ import { useStableCallback } from './utils/useStableCallback';
33
+ import { useValueAsRef } from './utils/useValueAsRef';
34
+ import { useValueChanged } from './utils/useValueChanged';
35
+ import { useBaseUiId } from './utils/useBaseUiId';
36
+ import { useAnimationFrame } from './utils/useAnimationFrame';
37
+ import { clamp } from './utils/clamp';
38
+ import { areArraysEqual } from './utils/areArraysEqual';
39
+ import { contains } from './utils/contains';
40
+ import { addEventListener } from './utils/addEventListener';
41
+ import { ownerDocument, ownerWindow } from './utils/owner';
42
+ import { visuallyHidden } from './utils/visuallyHidden';
43
+ import { valueToPercent } from './utils/valueToPercent';
44
+ import { formatNumber } from './utils/formatNumber';
45
+ import { matchesFocusVisible } from './utils/matchesFocusVisible';
46
+ import { useIsHydrating } from './utils/useIsHydrating';
47
+ import { getTarget } from './utils/composite/list-utils';
48
+ import { CompositeList } from './utils/composite/CompositeList';
49
+ import { useCompositeListItem } from './utils/composite/useCompositeListItem';
50
+ import {
51
+ ARROW_UP,
52
+ ARROW_DOWN,
53
+ ARROW_LEFT,
54
+ ARROW_RIGHT,
55
+ HOME,
56
+ END,
57
+ PAGE_UP,
58
+ PAGE_DOWN,
59
+ COMPOSITE_KEYS,
60
+ } from './utils/composite/keys';
61
+ import { useDirection } from './utils/DirectionContext';
62
+ import { fieldValidityMapping, type FieldRootState } from './utils/field/constants';
63
+ import { useFieldRootContext } from './utils/field/FieldRootContext';
64
+ import { useFormContext } from './utils/field/FormContext';
65
+ import { useLabelableContext } from './utils/field/LabelableContext';
66
+ import { useLabelableId } from './utils/field/useLabelableId';
67
+ import { useLabel, focusElementWithVisible } from './utils/field/useLabel';
68
+ import { useRegisterFieldControl } from './utils/field/useRegisterFieldControl';
69
+ import {
70
+ createChangeEventDetails,
71
+ createGenericEventDetails,
72
+ REASONS,
73
+ } from './utils/createChangeEventDetails';
74
+ import { asc } from './utils/slider/asc';
75
+ import { getSliderValue } from './utils/slider/getSliderValue';
76
+ import { getMidpoint } from './utils/slider/getMidpoint';
77
+ import { getDecimalPrecision, roundValueToStep } from './utils/slider/roundValueToStep';
78
+ import { validateMinimumDistance } from './utils/slider/validateMinimumDistance';
79
+ import { resolveThumbCollision } from './utils/slider/resolveThumbCollision';
80
+ import { resolveAriaLabelledBy, getDefaultLabelId } from './utils/resolveAriaLabelledBy';
81
+
82
+ type Orientation = 'horizontal' | 'vertical';
83
+
84
+ const ALL_KEYS = new Set([...COMPOSITE_KEYS, PAGE_UP, PAGE_DOWN]);
85
+ const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2;
86
+
87
+ function isElementNode(target: unknown): target is Element {
88
+ return target != null && target instanceof Element;
89
+ }
90
+
91
+ function isHTMLElementNode(target: unknown): target is HTMLElement {
92
+ return target != null && target instanceof HTMLElement;
93
+ }
94
+
95
+ // --- state → data-* mapping --------------------------------------------------
96
+
97
+ const sliderStateAttributesMapping: StateAttributesMapping<any> = {
98
+ activeThumbIndex: () => null,
99
+ max: () => null,
100
+ min: () => null,
101
+ minStepsBetweenValues: () => null,
102
+ step: () => null,
103
+ values: () => null,
104
+ ...(fieldValidityMapping as StateAttributesMapping<any>),
105
+ };
106
+
107
+ export interface SliderRootState extends FieldRootState {
108
+ activeThumbIndex: number;
109
+ disabled: boolean;
110
+ dragging: boolean;
111
+ max: number;
112
+ min: number;
113
+ minStepsBetweenValues: number;
114
+ orientation: Orientation;
115
+ step: number;
116
+ values: readonly number[];
117
+ }
118
+
119
+ // --- Context -----------------------------------------------------------------
120
+
121
+ const SliderRootContext = createContext<any>(undefined);
122
+
123
+ function useSliderRootContext(): any {
124
+ const context = useContext(SliderRootContext);
125
+ if (context === undefined) {
126
+ throw new Error(
127
+ 'Base UI: SliderRootContext is missing. Slider parts must be placed within <Slider.Root>.',
128
+ );
129
+ }
130
+ return context;
131
+ }
132
+
133
+ function getSliderChangeEventReason(event: any): string {
134
+ return 'key' in event ? REASONS.keyboard : REASONS.inputChange;
135
+ }
136
+
137
+ function areValuesEqual(
138
+ newValue: number | readonly number[],
139
+ oldValue: number | readonly number[],
140
+ ) {
141
+ if (typeof newValue === 'number' && typeof oldValue === 'number') {
142
+ return newValue === oldValue;
143
+ }
144
+ if (Array.isArray(newValue) && Array.isArray(oldValue)) {
145
+ return areArraysEqual(newValue, oldValue);
146
+ }
147
+ return false;
148
+ }
149
+
150
+ // --- Root --------------------------------------------------------------------
151
+
152
+ function SliderRoot(componentProps: any): any {
153
+ const slot = S('SliderRoot');
154
+ const {
155
+ 'aria-labelledby': ariaLabelledByProp,
156
+ className,
157
+ defaultValue,
158
+ disabled: disabledProp = false,
159
+ id: idProp,
160
+ format,
161
+ largeStep = 10,
162
+ locale,
163
+ render,
164
+ max = 100,
165
+ min = 0,
166
+ minStepsBetweenValues = 0,
167
+ form,
168
+ name: nameProp,
169
+ onValueChange: onValueChangeProp,
170
+ onValueCommitted: onValueCommittedProp,
171
+ orientation = 'horizontal',
172
+ step = 1,
173
+ thumbCollisionBehavior = 'push',
174
+ thumbAlignment = 'center',
175
+ value: valueProp,
176
+ style,
177
+ ref,
178
+ ...elementProps
179
+ } = componentProps;
180
+
181
+ const id = useBaseUiId(idProp, subSlot(slot, 'id'));
182
+ const defaultLabelId = getDefaultLabelId(id);
183
+ const onValueChange = useStableCallback(onValueChangeProp, subSlot(slot, 'onChange'));
184
+ const onValueCommitted = useStableCallback(onValueCommittedProp, subSlot(slot, 'onCommit'));
185
+
186
+ const { clearErrors } = useFormContext();
187
+ const {
188
+ state: fieldState,
189
+ disabled: fieldDisabled,
190
+ name: fieldName,
191
+ setTouched,
192
+ setDirty,
193
+ validityData,
194
+ validation,
195
+ } = useFieldRootContext();
196
+ const { labelId: fieldLabelId } = useLabelableContext();
197
+ const [labelId, setLabelId] = useState<string | undefined>(undefined, subSlot(slot, 'labelId'));
198
+
199
+ const ariaLabelledby = ariaLabelledByProp ?? resolveAriaLabelledBy(fieldLabelId, labelId);
200
+ const disabled = fieldDisabled || disabledProp;
201
+ const name = fieldName ?? nameProp;
202
+
203
+ const [valueUnwrapped, setValueUnwrapped] = useControlled<number | readonly number[]>(
204
+ { controlled: valueProp, default: defaultValue ?? min, name: 'Slider' },
205
+ subSlot(slot, 'value'),
206
+ );
207
+
208
+ const sliderRef = useRef<HTMLElement | null>(null, subSlot(slot, 'sliderRef'));
209
+ const controlRef = useRef<HTMLElement | null>(null, subSlot(slot, 'controlRef'));
210
+ const thumbRefs = useRef<(HTMLElement | null)[]>([], subSlot(slot, 'thumbRefs'));
211
+ const pressedInputRef = useRef<HTMLInputElement | null>(null, subSlot(slot, 'pressedInput'));
212
+ const pressedThumbCenterOffsetRef = useRef<number | null>(null, subSlot(slot, 'pressedOffset'));
213
+ const pressedThumbIndexRef = useRef(-1, subSlot(slot, 'pressedIndex'));
214
+ const pressedValuesRef = useRef<readonly number[] | null>(null, subSlot(slot, 'pressedValues'));
215
+ const lastChangeReasonRef = useRef<string>('none', subSlot(slot, 'lastReason'));
216
+
217
+ const formatOptionsRef = useValueAsRef<Intl.NumberFormatOptions | undefined>(
218
+ format,
219
+ subSlot(slot, 'fmtRef'),
220
+ );
221
+
222
+ const [active, setActiveState] = useState(-1, subSlot(slot, 'active'));
223
+ const [lastUsedThumbIndex, setLastUsedThumbIndex] = useState(-1, subSlot(slot, 'lastUsed'));
224
+ const [dragging, setDragging] = useState(false, subSlot(slot, 'dragging'));
225
+ const [thumbMap, setThumbMap] = useState(() => new Map<Node, any>(), subSlot(slot, 'thumbMap'));
226
+ const [indicatorPosition, setIndicatorPosition] = useState<(number | undefined)[]>(
227
+ [undefined, undefined],
228
+ subSlot(slot, 'indicatorPos'),
229
+ );
230
+
231
+ const setActive = useStableCallback(
232
+ (value: number) => {
233
+ setActiveState(value);
234
+ if (value !== -1) {
235
+ setLastUsedThumbIndex(value);
236
+ }
237
+ },
238
+ subSlot(slot, 'setActive'),
239
+ );
240
+
241
+ useRegisterFieldControl(
242
+ validation.inputRef,
243
+ id,
244
+ valueUnwrapped,
245
+ undefined,
246
+ !disabled,
247
+ nameProp,
248
+ subSlot(slot, 'register'),
249
+ );
250
+
251
+ useValueChanged(
252
+ valueUnwrapped,
253
+ () => {
254
+ clearErrors(name);
255
+ validation.change(valueUnwrapped);
256
+ const initialValue = validityData.initialValue;
257
+ let isDirty: boolean;
258
+ if (Array.isArray(valueUnwrapped) && Array.isArray(initialValue)) {
259
+ isDirty = !areArraysEqual(valueUnwrapped, initialValue);
260
+ } else {
261
+ isDirty = valueUnwrapped !== initialValue;
262
+ }
263
+ setDirty(isDirty);
264
+ },
265
+ subSlot(slot, 'valueChanged'),
266
+ );
267
+
268
+ const registerFieldControlRef = useStableCallback(
269
+ (element: HTMLElement | null) => {
270
+ if (element) {
271
+ controlRef.current = element;
272
+ }
273
+ },
274
+ subSlot(slot, 'regRef'),
275
+ );
276
+
277
+ const range = Array.isArray(valueUnwrapped);
278
+
279
+ const values = useMemo(
280
+ () => {
281
+ if (!range) {
282
+ return [clamp(valueUnwrapped as number, min, max)];
283
+ }
284
+ return (valueUnwrapped as number[]).slice().sort(asc);
285
+ },
286
+ [max, min, range, valueUnwrapped],
287
+ subSlot(slot, 'values'),
288
+ );
289
+
290
+ const setValue = useStableCallback(
291
+ (newValue: number | number[], details?: any): boolean => {
292
+ if (Number.isNaN(newValue) || areValuesEqual(newValue, valueUnwrapped)) {
293
+ return false;
294
+ }
295
+
296
+ const changeDetails =
297
+ details ??
298
+ createChangeEventDetails(REASONS.none, undefined, undefined, { activeThumbIndex: -1 });
299
+
300
+ // Redefine `target` so form libraries can read `event.target.value`.
301
+ const nativeEvent = changeDetails.event;
302
+ const EventConstructor = (nativeEvent.constructor as typeof Event | undefined) ?? Event;
303
+ let clonedEvent: Event;
304
+ try {
305
+ clonedEvent = new EventConstructor(nativeEvent.type, nativeEvent as any);
306
+ } catch {
307
+ clonedEvent = new Event(nativeEvent.type);
308
+ }
309
+ Object.defineProperty(clonedEvent, 'target', {
310
+ writable: true,
311
+ value: { value: newValue, name },
312
+ });
313
+ changeDetails.event = clonedEvent;
314
+
315
+ onValueChange(newValue, changeDetails);
316
+
317
+ if (changeDetails.isCanceled) {
318
+ return false;
319
+ }
320
+
321
+ lastChangeReasonRef.current = changeDetails.reason;
322
+ setValueUnwrapped(newValue);
323
+ return true;
324
+ },
325
+ subSlot(slot, 'setValue'),
326
+ );
327
+
328
+ const handleInputChange = useStableCallback(
329
+ (valueInput: number, index: number, event: any) => {
330
+ const newValue = getSliderValue(valueInput, index, min, max, range, values);
331
+ if (validateMinimumDistance(newValue, step, minStepsBetweenValues)) {
332
+ const reason = getSliderChangeEventReason(event);
333
+ const applied = setValue(
334
+ newValue,
335
+ createChangeEventDetails(reason, event, undefined, { activeThumbIndex: index }),
336
+ );
337
+ setTouched(true);
338
+ if (applied) {
339
+ onValueCommitted(newValue, createGenericEventDetails(reason, event));
340
+ }
341
+ }
342
+ },
343
+ subSlot(slot, 'handleInput'),
344
+ );
345
+
346
+ useLayoutEffect(
347
+ () => {
348
+ const activeEl = ownerDocument(sliderRef.current).activeElement;
349
+ if (disabled && contains(sliderRef.current, activeEl)) {
350
+ (activeEl as HTMLElement).blur();
351
+ }
352
+ },
353
+ [disabled],
354
+ subSlot(slot, 'e:disabledBlur'),
355
+ );
356
+
357
+ if (disabled && active !== -1) {
358
+ setActive(-1);
359
+ }
360
+
361
+ const state: SliderRootState = useMemo(
362
+ () => ({
363
+ ...fieldState,
364
+ activeThumbIndex: active,
365
+ disabled,
366
+ dragging,
367
+ orientation,
368
+ max,
369
+ min,
370
+ minStepsBetweenValues,
371
+ step,
372
+ values,
373
+ }),
374
+ [
375
+ fieldState,
376
+ active,
377
+ disabled,
378
+ dragging,
379
+ max,
380
+ min,
381
+ minStepsBetweenValues,
382
+ orientation,
383
+ step,
384
+ values,
385
+ ],
386
+ subSlot(slot, 'state'),
387
+ );
388
+
389
+ const contextValue = useMemo(
390
+ () => ({
391
+ active,
392
+ controlRef,
393
+ disabled,
394
+ dragging,
395
+ validation,
396
+ formatOptionsRef,
397
+ handleInputChange,
398
+ indicatorPosition,
399
+ inset: thumbAlignment !== 'center',
400
+ labelId: ariaLabelledby,
401
+ rootLabelId: defaultLabelId,
402
+ largeStep,
403
+ lastUsedThumbIndex,
404
+ lastChangeReasonRef,
405
+ form,
406
+ locale,
407
+ max,
408
+ min,
409
+ minStepsBetweenValues,
410
+ name,
411
+ onValueCommitted,
412
+ orientation,
413
+ pressedInputRef,
414
+ pressedThumbCenterOffsetRef,
415
+ pressedThumbIndexRef,
416
+ pressedValuesRef,
417
+ registerFieldControlRef,
418
+ renderBeforeHydration: thumbAlignment === 'edge',
419
+ setActive,
420
+ setDragging,
421
+ setIndicatorPosition,
422
+ setLabelId,
423
+ setValue,
424
+ state,
425
+ step,
426
+ thumbCollisionBehavior,
427
+ thumbMap,
428
+ thumbRefs,
429
+ values,
430
+ }),
431
+ [
432
+ active,
433
+ ariaLabelledby,
434
+ defaultLabelId,
435
+ disabled,
436
+ dragging,
437
+ validation,
438
+ formatOptionsRef,
439
+ handleInputChange,
440
+ indicatorPosition,
441
+ largeStep,
442
+ lastUsedThumbIndex,
443
+ form,
444
+ locale,
445
+ max,
446
+ min,
447
+ minStepsBetweenValues,
448
+ name,
449
+ onValueCommitted,
450
+ orientation,
451
+ registerFieldControlRef,
452
+ setActive,
453
+ setValue,
454
+ state,
455
+ step,
456
+ thumbCollisionBehavior,
457
+ thumbAlignment,
458
+ thumbMap,
459
+ values,
460
+ ],
461
+ subSlot(slot, 'ctx'),
462
+ );
463
+
464
+ const element = useRenderElement(
465
+ 'div',
466
+ { render, className, style },
467
+ {
468
+ state,
469
+ ref: [ref, sliderRef],
470
+ props: [
471
+ { 'aria-labelledby': ariaLabelledby, id, role: 'group' },
472
+ elementProps,
473
+ (props: any) => validation.getValidationProps(disabled, props),
474
+ ],
475
+ stateAttributesMapping: sliderStateAttributesMapping,
476
+ },
477
+ subSlot(slot, 're'),
478
+ );
479
+
480
+ return createElement(SliderRootContext.Provider, {
481
+ value: contextValue,
482
+ children: createElement(CompositeList, {
483
+ elementsRef: thumbRefs,
484
+ onMapChange: setThumbMap,
485
+ children: element,
486
+ }),
487
+ });
488
+ }
489
+
490
+ // --- Control -----------------------------------------------------------------
491
+
492
+ interface Coords {
493
+ x: number;
494
+ y: number;
495
+ }
496
+
497
+ interface FingerState {
498
+ value: number | number[];
499
+ thumbIndex: number;
500
+ didSwap: boolean;
501
+ }
502
+
503
+ function getControlOffset(styles: CSSStyleDeclaration | null, vertical: boolean) {
504
+ if (!styles) {
505
+ return { start: 0, end: 0 };
506
+ }
507
+ function parseSize(value: string | null | undefined) {
508
+ const parsed = value != null ? parseFloat(value) : 0;
509
+ return Number.isNaN(parsed) ? 0 : parsed;
510
+ }
511
+ const start = !vertical ? 'InlineStart' : 'Top';
512
+ const end = !vertical ? 'InlineEnd' : 'Bottom';
513
+ return {
514
+ start:
515
+ parseSize((styles as any)[`border${start}Width`]) +
516
+ parseSize((styles as any)[`padding${start}`]),
517
+ end:
518
+ parseSize((styles as any)[`border${end}Width`]) + parseSize((styles as any)[`padding${end}`]),
519
+ };
520
+ }
521
+
522
+ function getFingerCoords(event: any, touchIdRef: { current: number | null }): Coords | null {
523
+ if (touchIdRef.current != null && event.changedTouches) {
524
+ for (let i = 0; i < event.changedTouches.length; i += 1) {
525
+ const touch = event.changedTouches[i];
526
+ if (touch.identifier === touchIdRef.current) {
527
+ return { x: touch.clientX, y: touch.clientY };
528
+ }
529
+ }
530
+ return null;
531
+ }
532
+ return { x: event.clientX, y: event.clientY };
533
+ }
534
+
535
+ function SliderControl(componentProps: any): any {
536
+ const slot = S('SliderControl');
537
+ const { render, className, style, ref, ...elementProps } = componentProps;
538
+
539
+ const {
540
+ disabled,
541
+ dragging,
542
+ inset,
543
+ lastChangeReasonRef,
544
+ max,
545
+ min,
546
+ minStepsBetweenValues,
547
+ onValueCommitted,
548
+ orientation,
549
+ pressedInputRef,
550
+ pressedThumbCenterOffsetRef,
551
+ pressedThumbIndexRef,
552
+ pressedValuesRef,
553
+ registerFieldControlRef,
554
+ renderBeforeHydration,
555
+ setActive,
556
+ setDragging,
557
+ setValue,
558
+ state,
559
+ step,
560
+ thumbCollisionBehavior,
561
+ thumbRefs,
562
+ values,
563
+ } = useSliderRootContext();
564
+
565
+ const direction = useDirection();
566
+ const range = values.length > 1;
567
+ const vertical = orientation === 'vertical';
568
+
569
+ const controlRef = useRef<HTMLElement | null>(null, subSlot(slot, 'controlRef'));
570
+ const stylesRef = useRef<CSSStyleDeclaration | null>(null, subSlot(slot, 'stylesRef'));
571
+ const setStylesRef = useStableCallback(
572
+ (element: HTMLElement | null) => {
573
+ if (element && stylesRef.current == null) {
574
+ stylesRef.current = ownerWindow(element).getComputedStyle(element);
575
+ }
576
+ },
577
+ subSlot(slot, 'setStyles'),
578
+ );
579
+
580
+ const touchIdRef = useRef<number | null>(null, subSlot(slot, 'touchId'));
581
+ const moveCountRef = useRef(0, subSlot(slot, 'moveCount'));
582
+ const insetThumbOffsetRef = useRef(0, subSlot(slot, 'insetOffset'));
583
+ const currentInteractionValueRef = useRef<number | number[] | null>(
584
+ null,
585
+ subSlot(slot, 'currentVal'),
586
+ );
587
+ const latestValuesRef = useValueAsRef<readonly number[]>(values, subSlot(slot, 'latestValues'));
588
+
589
+ function updatePressedThumb(nextIndex: number) {
590
+ if (pressedThumbIndexRef.current !== nextIndex) {
591
+ pressedThumbIndexRef.current = nextIndex;
592
+ }
593
+ const thumbElement = thumbRefs.current[nextIndex];
594
+ if (!thumbElement) {
595
+ pressedThumbCenterOffsetRef.current = null;
596
+ pressedInputRef.current = null;
597
+ return;
598
+ }
599
+ pressedInputRef.current = thumbElement.querySelector('input[type="range"]');
600
+ }
601
+
602
+ function resetPressedThumb() {
603
+ pressedThumbIndexRef.current = -1;
604
+ pressedThumbCenterOffsetRef.current = null;
605
+ pressedInputRef.current = null;
606
+ }
607
+
608
+ function isTargetDisabledThumb(target: EventTarget | null) {
609
+ if (!isElementNode(target)) {
610
+ return false;
611
+ }
612
+ return thumbRefs.current.some((thumbEl: HTMLElement | null) => {
613
+ if (!isElementNode(thumbEl) || !contains(thumbEl, target)) {
614
+ return false;
615
+ }
616
+ return thumbEl.querySelector<HTMLInputElement>('input[type="range"]')?.disabled === true;
617
+ });
618
+ }
619
+
620
+ function getFingerState(fingerCoords: Coords): FingerState | null {
621
+ const control = controlRef.current;
622
+ const thumbIndex = pressedThumbIndexRef.current;
623
+ if (!control || (!range && (thumbIndex < 0 || thumbIndex >= values.length))) {
624
+ return null;
625
+ }
626
+ const { width, height, bottom, left, right } = control.getBoundingClientRect();
627
+ const controlOffset = getControlOffset(stylesRef.current, vertical);
628
+ const insetThumbOffset = insetThumbOffsetRef.current;
629
+ const controlSize =
630
+ (vertical ? height : width) - controlOffset.start - controlOffset.end - insetThumbOffset * 2;
631
+ const thumbCenterOffset = pressedThumbCenterOffsetRef.current ?? 0;
632
+ const fingerX = fingerCoords.x - thumbCenterOffset;
633
+ const fingerY = fingerCoords.y - thumbCenterOffset;
634
+ const valueSize = vertical
635
+ ? bottom - fingerY - controlOffset.end
636
+ : (direction === 'rtl' ? right - fingerX : fingerX - left) - controlOffset.start;
637
+ const valueRescaled = clamp((valueSize - insetThumbOffset) / controlSize, 0, 1);
638
+ let newValue = (max - min) * valueRescaled + min;
639
+ newValue = roundValueToStep(newValue, step, min);
640
+ newValue = clamp(newValue, min, max);
641
+
642
+ if (!range) {
643
+ return { value: newValue, thumbIndex, didSwap: false };
644
+ }
645
+ if (thumbIndex < 0) {
646
+ return null;
647
+ }
648
+ return resolveThumbCollision({
649
+ behavior: thumbCollisionBehavior,
650
+ values,
651
+ currentValues: latestValuesRef.current ?? values,
652
+ initialValues: pressedValuesRef.current,
653
+ pressedIndex: thumbIndex,
654
+ nextValue: newValue,
655
+ min,
656
+ max,
657
+ step,
658
+ minStepsBetweenValues,
659
+ });
660
+ }
661
+
662
+ function startPressing(fingerCoords: Coords) {
663
+ pressedValuesRef.current = range ? values.slice() : null;
664
+ currentInteractionValueRef.current = null;
665
+ latestValuesRef.current = values;
666
+
667
+ const pressedThumbIndex = pressedThumbIndexRef.current;
668
+ let closestThumbIndex = pressedThumbIndex;
669
+
670
+ if (pressedThumbIndex > -1 && pressedThumbIndex < values.length) {
671
+ if (values[pressedThumbIndex] === max) {
672
+ let candidateIndex = pressedThumbIndex;
673
+ while (candidateIndex > 0 && values[candidateIndex - 1] === max) {
674
+ candidateIndex -= 1;
675
+ }
676
+ closestThumbIndex = candidateIndex;
677
+ }
678
+ } else {
679
+ const axis = !vertical ? 'x' : 'y';
680
+ let minDistance: number | undefined;
681
+ closestThumbIndex = -1;
682
+ for (let i = 0; i < thumbRefs.current.length; i += 1) {
683
+ const thumbEl = thumbRefs.current[i];
684
+ if (
685
+ isElementNode(thumbEl) &&
686
+ !thumbEl.querySelector<HTMLInputElement>('input[type="range"]')?.disabled
687
+ ) {
688
+ const midpoint = getMidpoint(thumbEl);
689
+ const distance = Math.abs(fingerCoords[axis] - midpoint[axis]);
690
+ if (minDistance === undefined || distance <= minDistance) {
691
+ closestThumbIndex = i;
692
+ minDistance = distance;
693
+ }
694
+ }
695
+ }
696
+ }
697
+
698
+ if (closestThumbIndex > -1 && closestThumbIndex !== pressedThumbIndex) {
699
+ updatePressedThumb(closestThumbIndex);
700
+ }
701
+
702
+ if (inset) {
703
+ const thumbEl = thumbRefs.current[closestThumbIndex];
704
+ if (isElementNode(thumbEl)) {
705
+ const thumbRect = thumbEl.getBoundingClientRect();
706
+ const side = !vertical ? 'width' : 'height';
707
+ insetThumbOffsetRef.current = thumbRect[side] / 2;
708
+ }
709
+ }
710
+ }
711
+
712
+ function focusThumb(thumbIndex: number) {
713
+ const input = thumbRefs.current?.[thumbIndex]?.querySelector('input[type="range"]');
714
+ if (!input) {
715
+ return;
716
+ }
717
+ (input as any).focus({ preventScroll: true, focusVisible: false });
718
+ }
719
+
720
+ function setValueFromPointer(finger: FingerState, reason: string, nativeEvent: any) {
721
+ const applied = setValue(
722
+ finger.value,
723
+ createChangeEventDetails(reason, nativeEvent, undefined, {
724
+ activeThumbIndex: finger.thumbIndex,
725
+ }),
726
+ );
727
+ if (applied) {
728
+ currentInteractionValueRef.current = finger.value;
729
+ latestValuesRef.current = Array.isArray(finger.value) ? finger.value : [finger.value];
730
+ if (finger.didSwap) {
731
+ updatePressedThumb(finger.thumbIndex);
732
+ }
733
+ }
734
+ return applied;
735
+ }
736
+
737
+ const handleTouchMove = useStableCallback(
738
+ (nativeEvent: any) => {
739
+ const fingerCoords = getFingerCoords(nativeEvent, touchIdRef);
740
+ if (fingerCoords == null) {
741
+ return;
742
+ }
743
+ moveCountRef.current += 1;
744
+ if (nativeEvent.type === 'pointermove' && nativeEvent.buttons === 0) {
745
+ handleTouchEnd(nativeEvent);
746
+ return;
747
+ }
748
+ const finger = getFingerState(fingerCoords);
749
+ if (finger == null) {
750
+ return;
751
+ }
752
+ if (validateMinimumDistance(finger.value, step, minStepsBetweenValues)) {
753
+ if (!dragging && moveCountRef.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) {
754
+ setDragging(true);
755
+ }
756
+ const applied = setValueFromPointer(finger, REASONS.drag, nativeEvent);
757
+ if (applied && finger.didSwap) {
758
+ focusThumb(finger.thumbIndex);
759
+ }
760
+ }
761
+ },
762
+ subSlot(slot, 'touchMove'),
763
+ );
764
+
765
+ const handleTouchEnd = useStableCallback(
766
+ (nativeEvent: any) => {
767
+ setActive(-1);
768
+ setDragging(false);
769
+ pressedInputRef.current = null;
770
+ pressedThumbCenterOffsetRef.current = null;
771
+ if (currentInteractionValueRef.current != null) {
772
+ const commitReason = lastChangeReasonRef.current;
773
+ onValueCommitted(
774
+ currentInteractionValueRef.current,
775
+ createGenericEventDetails(commitReason, nativeEvent),
776
+ );
777
+ }
778
+ if (
779
+ 'pointerType' in nativeEvent &&
780
+ controlRef.current?.hasPointerCapture(nativeEvent.pointerId)
781
+ ) {
782
+ controlRef.current?.releasePointerCapture(nativeEvent.pointerId);
783
+ }
784
+ pressedThumbIndexRef.current = -1;
785
+ touchIdRef.current = null;
786
+ pressedValuesRef.current = null;
787
+ currentInteractionValueRef.current = null;
788
+ stopListening();
789
+ },
790
+ subSlot(slot, 'touchEnd'),
791
+ );
792
+
793
+ const handleTouchStart = useStableCallback(
794
+ (nativeEvent: any) => {
795
+ if (disabled) {
796
+ return;
797
+ }
798
+ if (isTargetDisabledThumb(getTarget(nativeEvent))) {
799
+ resetPressedThumb();
800
+ return;
801
+ }
802
+ const touch = nativeEvent.changedTouches[0];
803
+ if (touch != null) {
804
+ touchIdRef.current = touch.identifier;
805
+ }
806
+ const fingerCoords = getFingerCoords(nativeEvent, touchIdRef);
807
+ if (fingerCoords != null) {
808
+ startPressing(fingerCoords);
809
+ const finger = getFingerState(fingerCoords);
810
+ if (finger == null) {
811
+ return;
812
+ }
813
+ focusThumb(finger.thumbIndex);
814
+ const applied = setValueFromPointer(finger, REASONS.trackPress, nativeEvent);
815
+ if (applied && finger.didSwap) {
816
+ focusThumb(finger.thumbIndex);
817
+ }
818
+ }
819
+ moveCountRef.current = 0;
820
+ const doc = ownerDocument(controlRef.current);
821
+ doc.addEventListener('touchmove', handleTouchMove, { passive: true });
822
+ doc.addEventListener('touchend', handleTouchEnd, { passive: true });
823
+ },
824
+ subSlot(slot, 'touchStart'),
825
+ );
826
+
827
+ const stopListening = useStableCallback(
828
+ () => {
829
+ const doc = ownerDocument(controlRef.current);
830
+ doc.removeEventListener('pointermove', handleTouchMove);
831
+ doc.removeEventListener('pointerup', handleTouchEnd);
832
+ doc.removeEventListener('touchmove', handleTouchMove);
833
+ doc.removeEventListener('touchend', handleTouchEnd);
834
+ pressedValuesRef.current = null;
835
+ currentInteractionValueRef.current = null;
836
+ },
837
+ subSlot(slot, 'stop'),
838
+ );
839
+
840
+ const focusFrame = useAnimationFrame(subSlot(slot, 'focusFrame'));
841
+
842
+ useEffect(
843
+ () => {
844
+ const control = controlRef.current;
845
+ if (!control) {
846
+ return () => stopListening();
847
+ }
848
+ const unsubscribeTouchStart = addEventListener(control, 'touchstart', handleTouchStart, {
849
+ passive: true,
850
+ });
851
+ return () => {
852
+ unsubscribeTouchStart();
853
+ focusFrame.cancel();
854
+ stopListening();
855
+ };
856
+ },
857
+ [stopListening, handleTouchStart, focusFrame],
858
+ subSlot(slot, 'e:touchstart'),
859
+ );
860
+
861
+ useEffect(
862
+ () => {
863
+ if (disabled) {
864
+ stopListening();
865
+ }
866
+ },
867
+ [disabled, stopListening],
868
+ subSlot(slot, 'e:disabled'),
869
+ );
870
+
871
+ return useRenderElement(
872
+ 'div',
873
+ { render, className, style },
874
+ {
875
+ state,
876
+ ref: [ref, registerFieldControlRef, controlRef, setStylesRef],
877
+ props: [
878
+ {
879
+ ['data-base-ui-slider-control']: renderBeforeHydration ? '' : undefined,
880
+ onPointerDown(event: any) {
881
+ const control = controlRef.current;
882
+ const target = getTarget(event);
883
+ if (
884
+ !control ||
885
+ disabled ||
886
+ event.defaultPrevented ||
887
+ !isElementNode(target) ||
888
+ event.button !== 0
889
+ ) {
890
+ return;
891
+ }
892
+ if (isTargetDisabledThumb(target)) {
893
+ resetPressedThumb();
894
+ return;
895
+ }
896
+ const fingerCoords = getFingerCoords(event, touchIdRef);
897
+ if (fingerCoords != null) {
898
+ startPressing(fingerCoords);
899
+ const finger = getFingerState(fingerCoords);
900
+ if (finger == null) {
901
+ return;
902
+ }
903
+ const pressedOnFocusedThumb = contains(
904
+ thumbRefs.current[finger.thumbIndex],
905
+ ownerDocument(control).activeElement,
906
+ );
907
+ if (pressedOnFocusedThumb) {
908
+ event.preventDefault();
909
+ } else {
910
+ focusFrame.request(() => {
911
+ focusThumb(finger.thumbIndex);
912
+ });
913
+ }
914
+ setDragging(true);
915
+ const pressedOnAnyThumb = pressedThumbCenterOffsetRef.current != null;
916
+ if (!pressedOnAnyThumb) {
917
+ const applied = setValueFromPointer(finger, REASONS.trackPress, event);
918
+ if (applied && finger.didSwap) {
919
+ focusThumb(finger.thumbIndex);
920
+ }
921
+ }
922
+ }
923
+ if (event.pointerId) {
924
+ control.setPointerCapture(event.pointerId);
925
+ }
926
+ moveCountRef.current = 0;
927
+ const doc = ownerDocument(controlRef.current);
928
+ doc.addEventListener('pointermove', handleTouchMove, { passive: true });
929
+ doc.addEventListener('pointerup', handleTouchEnd, { once: true });
930
+ },
931
+ },
932
+ elementProps,
933
+ ],
934
+ stateAttributesMapping: sliderStateAttributesMapping,
935
+ },
936
+ subSlot(slot, 're'),
937
+ );
938
+ }
939
+
940
+ // --- Track -------------------------------------------------------------------
941
+
942
+ function SliderTrack(componentProps: any): any {
943
+ const slot = S('SliderTrack');
944
+ const { render, className, style, ref, ...elementProps } = componentProps;
945
+ const { state } = useSliderRootContext();
946
+ return useRenderElement(
947
+ 'div',
948
+ { render, className, style },
949
+ {
950
+ state,
951
+ ref,
952
+ props: [{ style: { position: 'relative' } }, elementProps],
953
+ stateAttributesMapping: sliderStateAttributesMapping,
954
+ },
955
+ subSlot(slot, 're'),
956
+ );
957
+ }
958
+
959
+ // --- Indicator ---------------------------------------------------------------
960
+
961
+ function getInsetStyles(
962
+ vertical: boolean,
963
+ range: boolean,
964
+ start: number | undefined,
965
+ end: number | undefined,
966
+ renderBeforeHydration: boolean,
967
+ hydrating: boolean,
968
+ ): Record<string, any> {
969
+ const visibility = start === undefined || (range && end === undefined) ? 'hidden' : undefined;
970
+ const startEdge = vertical ? 'bottom' : 'insetInlineStart';
971
+ const mainSide = vertical ? 'height' : 'width';
972
+ const crossSide = vertical ? 'width' : 'height';
973
+ const styles: Record<string, any> = {
974
+ visibility: renderBeforeHydration && hydrating ? 'hidden' : visibility,
975
+ position: vertical ? 'absolute' : 'relative',
976
+ [crossSide]: 'inherit',
977
+ };
978
+ styles['--start-position'] = `${start ?? 0}%`;
979
+ if (!range) {
980
+ styles[startEdge] = 0;
981
+ styles[mainSide] = 'var(--start-position)';
982
+ return styles;
983
+ }
984
+ styles['--relative-size'] = `${(end ?? 0) - (start ?? 0)}%`;
985
+ styles[startEdge] = 'var(--start-position)';
986
+ styles[mainSide] = 'var(--relative-size)';
987
+ return styles;
988
+ }
989
+
990
+ function getCenteredStyles(
991
+ vertical: boolean,
992
+ range: boolean,
993
+ start: number,
994
+ end: number,
995
+ ): Record<string, any> {
996
+ const startEdge = vertical ? 'bottom' : 'insetInlineStart';
997
+ const mainSide = vertical ? 'height' : 'width';
998
+ const crossSide = vertical ? 'width' : 'height';
999
+ const styles: Record<string, any> = {
1000
+ position: vertical ? 'absolute' : 'relative',
1001
+ [crossSide]: 'inherit',
1002
+ };
1003
+ if (!range) {
1004
+ styles[startEdge] = 0;
1005
+ styles[mainSide] = `${start}%`;
1006
+ return styles;
1007
+ }
1008
+ const size = end - start;
1009
+ styles[startEdge] = `${start}%`;
1010
+ styles[mainSide] = `${size}%`;
1011
+ return styles;
1012
+ }
1013
+
1014
+ function SliderIndicator(componentProps: any): any {
1015
+ const slot = S('SliderIndicator');
1016
+ const { render, className, style: styleProp, ref, ...elementProps } = componentProps;
1017
+ const { indicatorPosition, inset, max, min, orientation, renderBeforeHydration, state, values } =
1018
+ useSliderRootContext();
1019
+ const isHydrating = useIsHydrating();
1020
+ const vertical = orientation === 'vertical';
1021
+ const range = values.length > 1;
1022
+
1023
+ const style = inset
1024
+ ? getInsetStyles(
1025
+ vertical,
1026
+ range,
1027
+ indicatorPosition[0],
1028
+ indicatorPosition[1],
1029
+ renderBeforeHydration,
1030
+ isHydrating,
1031
+ )
1032
+ : getCenteredStyles(
1033
+ vertical,
1034
+ range,
1035
+ valueToPercent(values[0], min, max),
1036
+ valueToPercent(values[values.length - 1], min, max),
1037
+ );
1038
+
1039
+ return useRenderElement(
1040
+ 'div',
1041
+ { render, className, style: styleProp },
1042
+ {
1043
+ state,
1044
+ ref,
1045
+ props: [
1046
+ { ['data-base-ui-slider-indicator']: renderBeforeHydration ? '' : undefined, style },
1047
+ elementProps,
1048
+ ],
1049
+ stateAttributesMapping: sliderStateAttributesMapping,
1050
+ },
1051
+ subSlot(slot, 're'),
1052
+ );
1053
+ }
1054
+
1055
+ // --- Thumb -------------------------------------------------------------------
1056
+
1057
+ function getDefaultAriaValueText(
1058
+ values: readonly number[],
1059
+ index: number,
1060
+ format: Intl.NumberFormatOptions | undefined,
1061
+ locale: Intl.LocalesArgument | undefined,
1062
+ ): string | undefined {
1063
+ if (index < 0) {
1064
+ return undefined;
1065
+ }
1066
+ if (values.length === 2) {
1067
+ if (index === 0) {
1068
+ return `${formatNumber(values[index], locale, format)} start range`;
1069
+ }
1070
+ return `${formatNumber(values[index], locale, format)} end range`;
1071
+ }
1072
+ return format ? formatNumber(values[index], locale, format) : undefined;
1073
+ }
1074
+
1075
+ function getNewValue(
1076
+ thumbValue: number,
1077
+ increment: number,
1078
+ direction: 1 | -1,
1079
+ min: number,
1080
+ max: number,
1081
+ ): number {
1082
+ const value = direction === 1 ? thumbValue + increment : thumbValue - increment;
1083
+ const roundedValue = Number(
1084
+ value.toFixed(
1085
+ Math.max(
1086
+ getDecimalPrecision(thumbValue),
1087
+ getDecimalPrecision(increment),
1088
+ getDecimalPrecision(min),
1089
+ ),
1090
+ ),
1091
+ );
1092
+ return clamp(roundedValue, min, max);
1093
+ }
1094
+
1095
+ function SliderThumb(componentProps: any): any {
1096
+ const slot = S('SliderThumb');
1097
+ const {
1098
+ render,
1099
+ children: childrenProp,
1100
+ className,
1101
+ 'aria-describedby': ariaDescribedByProp,
1102
+ 'aria-label': ariaLabelProp,
1103
+ 'aria-labelledby': ariaLabelledByProp,
1104
+ 'aria-valuetext': ariaValueTextProp,
1105
+ disabled: disabledProp = false,
1106
+ getAriaLabel: getAriaLabelProp,
1107
+ getAriaValueText: getAriaValueTextProp,
1108
+ id: idProp,
1109
+ index: indexProp,
1110
+ inputRef: inputRefProp,
1111
+ onBlur: onBlurProp,
1112
+ onFocus: onFocusProp,
1113
+ onKeyDown: onKeyDownProp,
1114
+ tabIndex: tabIndexProp,
1115
+ style,
1116
+ ref,
1117
+ ...elementProps
1118
+ } = componentProps;
1119
+
1120
+ const id = useBaseUiId(idProp, subSlot(slot, 'id'));
1121
+
1122
+ const {
1123
+ active: activeIndex,
1124
+ lastUsedThumbIndex,
1125
+ controlRef,
1126
+ disabled: contextDisabled,
1127
+ validation,
1128
+ formatOptionsRef,
1129
+ handleInputChange,
1130
+ inset,
1131
+ labelId,
1132
+ largeStep,
1133
+ locale,
1134
+ max,
1135
+ min,
1136
+ minStepsBetweenValues,
1137
+ form,
1138
+ name,
1139
+ orientation,
1140
+ pressedInputRef,
1141
+ pressedThumbCenterOffsetRef,
1142
+ pressedThumbIndexRef,
1143
+ renderBeforeHydration,
1144
+ setActive,
1145
+ setIndicatorPosition,
1146
+ state,
1147
+ step,
1148
+ values: sliderValues,
1149
+ } = useSliderRootContext();
1150
+
1151
+ const direction = useDirection();
1152
+ const disabled = disabledProp || contextDisabled;
1153
+ const range = sliderValues.length > 1;
1154
+ const vertical = orientation === 'vertical';
1155
+ const rtl = direction === 'rtl';
1156
+
1157
+ const { setTouched, setFocused, validationMode } = useFieldRootContext();
1158
+
1159
+ const thumbRef = useRef<HTMLElement | null>(null, subSlot(slot, 'thumbRef'));
1160
+ const inputRef = useRef<HTMLInputElement | null>(null, subSlot(slot, 'inputRef'));
1161
+ const restoringFocusVisibleRef = useRef(false, subSlot(slot, 'restoring'));
1162
+
1163
+ const defaultInputId = useBaseUiId(undefined, subSlot(slot, 'defInputId'));
1164
+ const labelableId = useLabelableId(undefined, subSlot(slot, 'labelableId'));
1165
+ const inputId = range ? defaultInputId : labelableId;
1166
+
1167
+ const thumbMetadata = useMemo(() => ({ inputId }), [inputId], subSlot(slot, 'meta'));
1168
+
1169
+ const { ref: listItemRef, index: compositeIndex } = useCompositeListItem(
1170
+ { metadata: thumbMetadata },
1171
+ subSlot(slot, 'listItem'),
1172
+ );
1173
+
1174
+ const index = !range ? 0 : (indexProp ?? compositeIndex);
1175
+ const last = index === sliderValues.length - 1;
1176
+ const thumbValue = sliderValues[index];
1177
+ const thumbValuePercent = valueToPercent(thumbValue, min, max);
1178
+
1179
+ const [positionPercent, setPositionPercent] = useState<number | undefined>(
1180
+ undefined,
1181
+ subSlot(slot, 'posPct'),
1182
+ );
1183
+ const isHydrating = useIsHydrating();
1184
+
1185
+ const safeLastUsedThumbIndex =
1186
+ lastUsedThumbIndex >= 0 && lastUsedThumbIndex < sliderValues.length ? lastUsedThumbIndex : -1;
1187
+
1188
+ const getInsetPosition = useStableCallback(
1189
+ () => {
1190
+ const control = controlRef.current;
1191
+ const thumb = thumbRef.current;
1192
+ if (!control || !thumb) {
1193
+ return;
1194
+ }
1195
+ const thumbRect = thumb.getBoundingClientRect();
1196
+ const controlRect = control.getBoundingClientRect();
1197
+ const side = vertical ? 'height' : 'width';
1198
+ const controlSize = controlRect[side] - thumbRect[side];
1199
+ const thumbOffsetFromControlEdge =
1200
+ thumbRect[side] / 2 + (controlSize * thumbValuePercent) / 100;
1201
+ const nextPositionPercent = (thumbOffsetFromControlEdge / controlRect[side]) * 100;
1202
+ const nextInsetPosition = Number.isFinite(nextPositionPercent)
1203
+ ? nextPositionPercent
1204
+ : undefined;
1205
+ setPositionPercent(nextInsetPosition);
1206
+ if (index === 0) {
1207
+ setIndicatorPosition((prevPosition: any[]) => [nextInsetPosition, prevPosition[1]]);
1208
+ } else if (last) {
1209
+ setIndicatorPosition((prevPosition: any[]) => [prevPosition[0], nextInsetPosition]);
1210
+ }
1211
+ },
1212
+ subSlot(slot, 'insetPos'),
1213
+ );
1214
+
1215
+ useLayoutEffect(
1216
+ () => {
1217
+ if (inset) {
1218
+ queueMicrotask(getInsetPosition);
1219
+ }
1220
+ },
1221
+ [getInsetPosition, inset],
1222
+ subSlot(slot, 'e:inset1'),
1223
+ );
1224
+
1225
+ useLayoutEffect(
1226
+ () => {
1227
+ if (inset) {
1228
+ getInsetPosition();
1229
+ }
1230
+ },
1231
+ [getInsetPosition, inset, thumbValuePercent],
1232
+ subSlot(slot, 'e:inset2'),
1233
+ );
1234
+
1235
+ useLayoutEffect(
1236
+ () => {
1237
+ if (!inset) {
1238
+ return undefined;
1239
+ }
1240
+ const control = controlRef.current;
1241
+ const thumb = thumbRef.current;
1242
+ if (!control || !thumb) {
1243
+ return undefined;
1244
+ }
1245
+ const ResizeObserverCtor = ownerWindow(control).ResizeObserver;
1246
+ if (typeof ResizeObserverCtor !== 'function') {
1247
+ return undefined;
1248
+ }
1249
+ const resizeObserver = new ResizeObserverCtor(getInsetPosition);
1250
+ resizeObserver.observe(control);
1251
+ resizeObserver.observe(thumb);
1252
+ return () => {
1253
+ resizeObserver.disconnect();
1254
+ };
1255
+ },
1256
+ [getInsetPosition, inset],
1257
+ subSlot(slot, 'e:inset3'),
1258
+ );
1259
+
1260
+ const startEdge = vertical ? 'bottom' : 'insetInlineStart';
1261
+ const crossOffsetProperty = vertical ? 'left' : 'top';
1262
+
1263
+ let zIndex: number | undefined;
1264
+ if (range) {
1265
+ if (activeIndex === index) {
1266
+ zIndex = 2;
1267
+ } else if (safeLastUsedThumbIndex === index) {
1268
+ zIndex = 1;
1269
+ }
1270
+ } else if (activeIndex === index) {
1271
+ zIndex = 1;
1272
+ }
1273
+
1274
+ let thumbStyle: Record<string, any>;
1275
+ if (inset) {
1276
+ thumbStyle = {
1277
+ ['--position']: `${positionPercent ?? 0}%`,
1278
+ visibility:
1279
+ (renderBeforeHydration && isHydrating) || positionPercent === undefined
1280
+ ? 'hidden'
1281
+ : undefined,
1282
+ position: 'absolute',
1283
+ [startEdge]: 'var(--position)',
1284
+ [crossOffsetProperty]: '50%',
1285
+ translate: `${(vertical || !rtl ? -1 : 1) * 50}% ${(vertical ? 1 : -1) * 50}%`,
1286
+ zIndex,
1287
+ };
1288
+ } else {
1289
+ thumbStyle = !Number.isFinite(thumbValuePercent)
1290
+ ? visuallyHidden
1291
+ : {
1292
+ position: 'absolute',
1293
+ [startEdge]: `${thumbValuePercent}%`,
1294
+ [crossOffsetProperty]: '50%',
1295
+ translate: `${(vertical || !rtl ? -1 : 1) * 50}% ${(vertical ? 1 : -1) * 50}%`,
1296
+ zIndex,
1297
+ };
1298
+ }
1299
+
1300
+ let cssWritingMode: string | undefined;
1301
+ if (orientation === 'vertical') {
1302
+ cssWritingMode = rtl ? 'vertical-rl' : 'vertical-lr';
1303
+ }
1304
+
1305
+ const ariaLabel =
1306
+ typeof getAriaLabelProp === 'function' ? getAriaLabelProp(index) : ariaLabelProp;
1307
+
1308
+ const inputProps = mergeProps(
1309
+ {
1310
+ 'aria-label': ariaLabel,
1311
+ 'aria-labelledby': ariaLabelledByProp ?? (ariaLabel == null ? labelId : undefined),
1312
+ 'aria-describedby': ariaDescribedByProp,
1313
+ 'aria-orientation': orientation,
1314
+ 'aria-valuenow': thumbValue,
1315
+ 'aria-valuetext':
1316
+ typeof getAriaValueTextProp === 'function'
1317
+ ? getAriaValueTextProp(
1318
+ formatNumber(thumbValue, locale, formatOptionsRef.current ?? undefined),
1319
+ thumbValue,
1320
+ index,
1321
+ )
1322
+ : (ariaValueTextProp ??
1323
+ getDefaultAriaValueText(
1324
+ sliderValues,
1325
+ index,
1326
+ formatOptionsRef.current ?? undefined,
1327
+ locale,
1328
+ )),
1329
+ disabled,
1330
+ form,
1331
+ id: inputId,
1332
+ max,
1333
+ min,
1334
+ name,
1335
+ onChange(event: any) {
1336
+ handleInputChange(event.currentTarget.valueAsNumber, index, event);
1337
+ },
1338
+ onFocus(event: any) {
1339
+ const isRestoringFocusVisible = restoringFocusVisibleRef.current;
1340
+ restoringFocusVisibleRef.current = false;
1341
+ setActive(index);
1342
+ setFocused(true);
1343
+ if (isRestoringFocusVisible) {
1344
+ event.stopPropagation();
1345
+ }
1346
+ },
1347
+ onBlur(event: any) {
1348
+ if (restoringFocusVisibleRef.current) {
1349
+ event.stopPropagation();
1350
+ return;
1351
+ }
1352
+ if (!thumbRef.current) {
1353
+ return;
1354
+ }
1355
+ setActive(-1);
1356
+ setTouched(true);
1357
+ setFocused(false);
1358
+ if (validationMode === 'onBlur') {
1359
+ validation.commit(getSliderValue(thumbValue, index, min, max, range, sliderValues));
1360
+ }
1361
+ },
1362
+ onKeyDown(event: any) {
1363
+ if (event.defaultPrevented) {
1364
+ return;
1365
+ }
1366
+ if (!ALL_KEYS.has(event.key)) {
1367
+ return;
1368
+ }
1369
+ if (COMPOSITE_KEYS.has(event.key)) {
1370
+ event.stopPropagation();
1371
+ }
1372
+ let newValue = null;
1373
+ const roundedValue = roundValueToStep(thumbValue, step, min);
1374
+ switch (event.key) {
1375
+ case ARROW_UP:
1376
+ newValue = getNewValue(roundedValue, event.shiftKey ? largeStep : step, 1, min, max);
1377
+ break;
1378
+ case ARROW_RIGHT:
1379
+ newValue = getNewValue(
1380
+ roundedValue,
1381
+ event.shiftKey ? largeStep : step,
1382
+ rtl ? -1 : 1,
1383
+ min,
1384
+ max,
1385
+ );
1386
+ break;
1387
+ case ARROW_DOWN:
1388
+ newValue = getNewValue(roundedValue, event.shiftKey ? largeStep : step, -1, min, max);
1389
+ break;
1390
+ case ARROW_LEFT:
1391
+ newValue = getNewValue(
1392
+ roundedValue,
1393
+ event.shiftKey ? largeStep : step,
1394
+ rtl ? 1 : -1,
1395
+ min,
1396
+ max,
1397
+ );
1398
+ break;
1399
+ case PAGE_UP:
1400
+ newValue = getNewValue(roundedValue, largeStep, 1, min, max);
1401
+ break;
1402
+ case PAGE_DOWN:
1403
+ newValue = getNewValue(roundedValue, largeStep, -1, min, max);
1404
+ break;
1405
+ case END:
1406
+ newValue = max;
1407
+ if (range) {
1408
+ newValue = Number.isFinite(sliderValues[index + 1])
1409
+ ? sliderValues[index + 1] - step * minStepsBetweenValues
1410
+ : max;
1411
+ }
1412
+ break;
1413
+ case HOME:
1414
+ newValue = min;
1415
+ if (range) {
1416
+ newValue = Number.isFinite(sliderValues[index - 1])
1417
+ ? sliderValues[index - 1] + step * minStepsBetweenValues
1418
+ : min;
1419
+ }
1420
+ break;
1421
+ default:
1422
+ break;
1423
+ }
1424
+ if (newValue !== null) {
1425
+ const input = event.currentTarget as HTMLInputElement;
1426
+ if (!matchesFocusVisible(input)) {
1427
+ restoringFocusVisibleRef.current = true;
1428
+ input.blur();
1429
+ (input as any).focus({ preventScroll: true, focusVisible: true });
1430
+ }
1431
+ handleInputChange(newValue, index, event);
1432
+ event.preventDefault();
1433
+ }
1434
+ },
1435
+ step,
1436
+ style: {
1437
+ ...visuallyHidden,
1438
+ width: '100%',
1439
+ height: '100%',
1440
+ writingMode: cssWritingMode,
1441
+ },
1442
+ tabIndex: tabIndexProp ?? undefined,
1443
+ type: 'range',
1444
+ // octane: a controlled range input reflects its live value to the `value` ATTRIBUTE
1445
+ // (verified vs React) — octane's native attribute write matches, so no freeze/property
1446
+ // adaptation is needed here (unlike a controlled TEXT input). Base UI prevents native
1447
+ // range interaction (keydown → preventDefault), so the input stays pristine and the
1448
+ // attribute drives the property/position.
1449
+ value: thumbValue ?? '',
1450
+ },
1451
+ (props: any) => validation.getValidationProps(disabled, props),
1452
+ { onKeyDown: onKeyDownProp },
1453
+ );
1454
+
1455
+ const mergedInputRef = useComposedRefs(
1456
+ inputRef,
1457
+ validation.inputRef,
1458
+ inputRefProp,
1459
+ subSlot(slot, 'inputRefs'),
1460
+ );
1461
+
1462
+ const inputElement = createElement('input', { ...inputProps, ref: mergedInputRef });
1463
+
1464
+ return useRenderElement(
1465
+ 'div',
1466
+ { render, className, style },
1467
+ {
1468
+ state,
1469
+ ref: [ref, listItemRef, thumbRef],
1470
+ props: [
1471
+ {
1472
+ ['data-index']: index,
1473
+ children: [childrenProp, inputElement],
1474
+ id,
1475
+ onBlur: onBlurProp,
1476
+ onFocus: onFocusProp,
1477
+ onPointerDown(event: any) {
1478
+ if (disabled) {
1479
+ return;
1480
+ }
1481
+ pressedThumbIndexRef.current = index;
1482
+ if (thumbRef.current != null) {
1483
+ const axis = orientation === 'horizontal' ? 'x' : 'y';
1484
+ const midpoint = getMidpoint(thumbRef.current);
1485
+ const offset =
1486
+ (orientation === 'horizontal' ? event.clientX : event.clientY) - midpoint[axis];
1487
+ pressedThumbCenterOffsetRef.current = offset;
1488
+ }
1489
+ if (inputRef.current != null && pressedInputRef.current !== inputRef.current) {
1490
+ pressedInputRef.current = inputRef.current;
1491
+ }
1492
+ },
1493
+ style: thumbStyle,
1494
+ },
1495
+ elementProps,
1496
+ ],
1497
+ stateAttributesMapping: sliderStateAttributesMapping,
1498
+ },
1499
+ subSlot(slot, 're'),
1500
+ );
1501
+ }
1502
+
1503
+ // --- Value -------------------------------------------------------------------
1504
+
1505
+ function SliderValue(componentProps: any): any {
1506
+ const slot = S('SliderValue');
1507
+ const {
1508
+ 'aria-live': ariaLive = 'off',
1509
+ render,
1510
+ className,
1511
+ children,
1512
+ style,
1513
+ ref,
1514
+ ...elementProps
1515
+ } = componentProps;
1516
+ const { thumbMap, state, values, formatOptionsRef, locale } = useSliderRootContext();
1517
+
1518
+ let htmlFor = '';
1519
+ for (const thumbMetadata of thumbMap.values()) {
1520
+ if (thumbMetadata?.inputId) {
1521
+ htmlFor += `${thumbMetadata.inputId} `;
1522
+ }
1523
+ }
1524
+ const outputFor = htmlFor.trim() === '' ? undefined : htmlFor.trim();
1525
+
1526
+ const formattedValues = useMemo(
1527
+ () => {
1528
+ const arr = [];
1529
+ for (let i = 0; i < values.length; i += 1) {
1530
+ arr.push(formatNumber(values[i], locale, formatOptionsRef.current ?? undefined));
1531
+ }
1532
+ return arr;
1533
+ },
1534
+ [formatOptionsRef, locale, values],
1535
+ subSlot(slot, 'fmt'),
1536
+ );
1537
+
1538
+ const defaultDisplayValue = values
1539
+ .map((v: number, i: number) => formattedValues[i] || v)
1540
+ .join(' – ');
1541
+
1542
+ return useRenderElement(
1543
+ 'output',
1544
+ { render, className, style },
1545
+ {
1546
+ state,
1547
+ ref,
1548
+ props: [
1549
+ {
1550
+ 'aria-live': ariaLive,
1551
+ children:
1552
+ typeof children === 'function'
1553
+ ? children(formattedValues, values)
1554
+ : defaultDisplayValue,
1555
+ htmlFor: outputFor,
1556
+ },
1557
+ elementProps,
1558
+ ],
1559
+ stateAttributesMapping: sliderStateAttributesMapping,
1560
+ },
1561
+ subSlot(slot, 're'),
1562
+ );
1563
+ }
1564
+
1565
+ // --- Label -------------------------------------------------------------------
1566
+
1567
+ function SliderLabel(componentProps: any): any {
1568
+ const slot = S('SliderLabel');
1569
+ const { render, className, style, ref, ...elementProps } = componentProps;
1570
+ delete (elementProps as any).id;
1571
+
1572
+ const { state, setLabelId, controlRef, rootLabelId } = useSliderRootContext();
1573
+
1574
+ function focusControl(event: any, controlId: string | null | undefined) {
1575
+ if (controlId) {
1576
+ const controlElement = ownerDocument(event.currentTarget).getElementById(controlId);
1577
+ if (isHTMLElementNode(controlElement)) {
1578
+ focusElementWithVisible(controlElement);
1579
+ return;
1580
+ }
1581
+ }
1582
+ const fallbackInputs = controlRef.current?.querySelectorAll('input[type="range"]');
1583
+ const fallbackInput = fallbackInputs?.length === 1 ? fallbackInputs[0] : null;
1584
+ if (isHTMLElementNode(fallbackInput)) {
1585
+ focusElementWithVisible(fallbackInput);
1586
+ }
1587
+ }
1588
+
1589
+ const labelProps = useLabel(
1590
+ { id: rootLabelId, setLabelId, focusControl },
1591
+ subSlot(slot, 'label'),
1592
+ );
1593
+
1594
+ return useRenderElement(
1595
+ 'div',
1596
+ { render, className, style },
1597
+ {
1598
+ state,
1599
+ ref,
1600
+ props: [labelProps, elementProps],
1601
+ stateAttributesMapping: sliderStateAttributesMapping,
1602
+ },
1603
+ subSlot(slot, 're'),
1604
+ );
1605
+ }
1606
+
1607
+ // --- Namespace (mirrors `export * as Slider`) --------------------------------
1608
+
1609
+ export const Slider = {
1610
+ Root: SliderRoot,
1611
+ Value: SliderValue,
1612
+ Control: SliderControl,
1613
+ Track: SliderTrack,
1614
+ Indicator: SliderIndicator,
1615
+ Thumb: SliderThumb,
1616
+ Label: SliderLabel,
1617
+ };