UNPKG

19.9 kBJavaScriptView Raw
1'use client';
2
3import * as React from 'react';
4import { unstable_ownerDocument as ownerDocument, unstable_useControlled as useControlled, unstable_useEnhancedEffect as useEnhancedEffect, unstable_useEventCallback as useEventCallback, unstable_useForkRef as useForkRef, unstable_isFocusVisible as isFocusVisible, visuallyHidden, clamp } from '@mui/utils';
5import extractEventHandlers from '@mui/utils/extractEventHandlers';
6import areArraysEqual from "../utils/areArraysEqual.js";
7const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2;
8function asc(a, b) {
9 return a - b;
10}
11function findClosest(values, currentValue) {
12 const {
13 index: closestIndex
14 } = values.reduce((acc, value, index) => {
15 const distance = Math.abs(currentValue - value);
16 if (acc === null || distance < acc.distance || distance === acc.distance) {
17 return {
18 distance,
19 index
20 };
21 }
22 return acc;
23 }, null) ?? {};
24 return closestIndex;
25}
26function trackFinger(event, touchId) {
27 // The event is TouchEvent
28 if (touchId.current !== undefined && event.changedTouches) {
29 const touchEvent = event;
30 for (let i = 0; i < touchEvent.changedTouches.length; i += 1) {
31 const touch = touchEvent.changedTouches[i];
32 if (touch.identifier === touchId.current) {
33 return {
34 x: touch.clientX,
35 y: touch.clientY
36 };
37 }
38 }
39 return false;
40 }
41
42 // The event is MouseEvent
43 return {
44 x: event.clientX,
45 y: event.clientY
46 };
47}
48export function valueToPercent(value, min, max) {
49 return (value - min) * 100 / (max - min);
50}
51function percentToValue(percent, min, max) {
52 return (max - min) * percent + min;
53}
54function getDecimalPrecision(num) {
55 // This handles the case when num is very small (0.00000001), js will turn this into 1e-8.
56 // When num is bigger than 1 or less than -1 it won't get converted to this notation so it's fine.
57 if (Math.abs(num) < 1) {
58 const parts = num.toExponential().split('e-');
59 const matissaDecimalPart = parts[0].split('.')[1];
60 return (matissaDecimalPart ? matissaDecimalPart.length : 0) + parseInt(parts[1], 10);
61 }
62 const decimalPart = num.toString().split('.')[1];
63 return decimalPart ? decimalPart.length : 0;
64}
65function roundValueToStep(value, step, min) {
66 const nearest = Math.round((value - min) / step) * step + min;
67 return Number(nearest.toFixed(getDecimalPrecision(step)));
68}
69function setValueIndex({
70 values,
71 newValue,
72 index
73}) {
74 const output = values.slice();
75 output[index] = newValue;
76 return output.sort(asc);
77}
78function focusThumb({
79 sliderRef,
80 activeIndex,
81 setActive
82}) {
83 const doc = ownerDocument(sliderRef.current);
84 if (!sliderRef.current?.contains(doc.activeElement) || Number(doc?.activeElement?.getAttribute('data-index')) !== activeIndex) {
85 sliderRef.current?.querySelector(`[type="range"][data-index="${activeIndex}"]`).focus();
86 }
87 if (setActive) {
88 setActive(activeIndex);
89 }
90}
91function areValuesEqual(newValue, oldValue) {
92 if (typeof newValue === 'number' && typeof oldValue === 'number') {
93 return newValue === oldValue;
94 }
95 if (typeof newValue === 'object' && typeof oldValue === 'object') {
96 return areArraysEqual(newValue, oldValue);
97 }
98 return false;
99}
100const axisProps = {
101 horizontal: {
102 offset: percent => ({
103 left: `${percent}%`
104 }),
105 leap: percent => ({
106 width: `${percent}%`
107 })
108 },
109 'horizontal-reverse': {
110 offset: percent => ({
111 right: `${percent}%`
112 }),
113 leap: percent => ({
114 width: `${percent}%`
115 })
116 },
117 vertical: {
118 offset: percent => ({
119 bottom: `${percent}%`
120 }),
121 leap: percent => ({
122 height: `${percent}%`
123 })
124 }
125};
126export const Identity = x => x;
127
128// TODO: remove support for Safari < 13.
129// https://caniuse.com/#search=touch-action
130//
131// Safari, on iOS, supports touch action since v13.
132// Over 80% of the iOS phones are compatible
133// in August 2020.
134// Utilizing the CSS.supports method to check if touch-action is supported.
135// Since CSS.supports is supported on all but Edge@12 and IE and touch-action
136// is supported on both Edge@12 and IE if CSS.supports is not available that means that
137// touch-action will be supported
138let cachedSupportsTouchActionNone;
139function doesSupportTouchActionNone() {
140 if (cachedSupportsTouchActionNone === undefined) {
141 if (typeof CSS !== 'undefined' && typeof CSS.supports === 'function') {
142 cachedSupportsTouchActionNone = CSS.supports('touch-action', 'none');
143 } else {
144 cachedSupportsTouchActionNone = true;
145 }
146 }
147 return cachedSupportsTouchActionNone;
148}
149/**
150 *
151 * Demos:
152 *
153 * - [Slider](https://mui.com/base-ui/react-slider/#hook)
154 *
155 * API:
156 *
157 * - [useSlider API](https://mui.com/base-ui/react-slider/hooks-api/#use-slider)
158 */
159export function useSlider(parameters) {
160 const {
161 'aria-labelledby': ariaLabelledby,
162 defaultValue,
163 disabled = false,
164 disableSwap = false,
165 isRtl = false,
166 marks: marksProp = false,
167 max = 100,
168 min = 0,
169 name,
170 onChange,
171 onChangeCommitted,
172 orientation = 'horizontal',
173 rootRef: ref,
174 scale = Identity,
175 step = 1,
176 shiftStep = 10,
177 tabIndex,
178 value: valueProp
179 } = parameters;
180 const touchId = React.useRef(undefined);
181 // We can't use the :active browser pseudo-classes.
182 // - The active state isn't triggered when clicking on the rail.
183 // - The active state isn't transferred when inversing a range slider.
184 const [active, setActive] = React.useState(-1);
185 const [open, setOpen] = React.useState(-1);
186 const [dragging, setDragging] = React.useState(false);
187 const moveCount = React.useRef(0);
188 const [valueDerived, setValueState] = useControlled({
189 controlled: valueProp,
190 default: defaultValue ?? min,
191 name: 'Slider'
192 });
193 const handleChange = onChange && ((event, value, thumbIndex) => {
194 // Redefine target to allow name and value to be read.
195 // This allows seamless integration with the most popular form libraries.
196 // https://github.com/mui/material-ui/issues/13485#issuecomment-676048492
197 // Clone the event to not override `target` of the original event.
198 const nativeEvent = event.nativeEvent || event;
199 // @ts-ignore The nativeEvent is function, not object
200 const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent);
201 Object.defineProperty(clonedEvent, 'target', {
202 writable: true,
203 value: {
204 value,
205 name
206 }
207 });
208 onChange(clonedEvent, value, thumbIndex);
209 });
210 const range = Array.isArray(valueDerived);
211 let values = range ? valueDerived.slice().sort(asc) : [valueDerived];
212 values = values.map(value => value == null ? min : clamp(value, min, max));
213 const marks = marksProp === true && step !== null ? [...Array(Math.floor((max - min) / step) + 1)].map((_, index) => ({
214 value: min + step * index
215 })) : marksProp || [];
216 const marksValues = marks.map(mark => mark.value);
217 const [focusedThumbIndex, setFocusedThumbIndex] = React.useState(-1);
218 const sliderRef = React.useRef(null);
219 const handleRef = useForkRef(ref, sliderRef);
220 const createHandleHiddenInputFocus = otherHandlers => event => {
221 const index = Number(event.currentTarget.getAttribute('data-index'));
222 if (isFocusVisible(event.target)) {
223 setFocusedThumbIndex(index);
224 }
225 setOpen(index);
226 otherHandlers?.onFocus?.(event);
227 };
228 const createHandleHiddenInputBlur = otherHandlers => event => {
229 if (!isFocusVisible(event.target)) {
230 setFocusedThumbIndex(-1);
231 }
232 setOpen(-1);
233 otherHandlers?.onBlur?.(event);
234 };
235 const changeValue = (event, valueInput) => {
236 const index = Number(event.currentTarget.getAttribute('data-index'));
237 const value = values[index];
238 const marksIndex = marksValues.indexOf(value);
239 let newValue = valueInput;
240 if (marks && step == null) {
241 const maxMarksValue = marksValues[marksValues.length - 1];
242 if (newValue > maxMarksValue) {
243 newValue = maxMarksValue;
244 } else if (newValue < marksValues[0]) {
245 newValue = marksValues[0];
246 } else {
247 newValue = newValue < value ? marksValues[marksIndex - 1] : marksValues[marksIndex + 1];
248 }
249 }
250 newValue = clamp(newValue, min, max);
251 if (range) {
252 // Bound the new value to the thumb's neighbours.
253 if (disableSwap) {
254 newValue = clamp(newValue, values[index - 1] || -Infinity, values[index + 1] || Infinity);
255 }
256 const previousValue = newValue;
257 newValue = setValueIndex({
258 values,
259 newValue,
260 index
261 });
262 let activeIndex = index;
263
264 // Potentially swap the index if needed.
265 if (!disableSwap) {
266 activeIndex = newValue.indexOf(previousValue);
267 }
268 focusThumb({
269 sliderRef,
270 activeIndex
271 });
272 }
273 setValueState(newValue);
274 setFocusedThumbIndex(index);
275 if (handleChange && !areValuesEqual(newValue, valueDerived)) {
276 handleChange(event, newValue, index);
277 }
278 if (onChangeCommitted) {
279 onChangeCommitted(event, newValue);
280 }
281 };
282 const createHandleHiddenInputKeyDown = otherHandlers => event => {
283 // The Shift + Up/Down keyboard shortcuts for moving the slider makes sense to be supported
284 // only if the step is defined. If the step is null, this means tha the marks are used for specifying the valid values.
285 if (step !== null) {
286 const index = Number(event.currentTarget.getAttribute('data-index'));
287 const value = values[index];
288 let newValue = null;
289 if ((event.key === 'ArrowLeft' || event.key === 'ArrowDown') && event.shiftKey || event.key === 'PageDown') {
290 newValue = Math.max(value - shiftStep, min);
291 } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && event.shiftKey || event.key === 'PageUp') {
292 newValue = Math.min(value + shiftStep, max);
293 }
294 if (newValue !== null) {
295 changeValue(event, newValue);
296 event.preventDefault();
297 }
298 }
299 otherHandlers?.onKeyDown?.(event);
300 };
301 useEnhancedEffect(() => {
302 if (disabled && sliderRef.current.contains(document.activeElement)) {
303 // This is necessary because Firefox and Safari will keep focus
304 // on a disabled element:
305 // https://codesandbox.io/p/sandbox/mui-pr-22247-forked-h151h?file=/src/App.js
306 // @ts-ignore
307 document.activeElement?.blur();
308 }
309 }, [disabled]);
310 if (disabled && active !== -1) {
311 setActive(-1);
312 }
313 if (disabled && focusedThumbIndex !== -1) {
314 setFocusedThumbIndex(-1);
315 }
316 const createHandleHiddenInputChange = otherHandlers => event => {
317 otherHandlers.onChange?.(event);
318 // @ts-ignore
319 changeValue(event, event.target.valueAsNumber);
320 };
321 const previousIndex = React.useRef(undefined);
322 let axis = orientation;
323 if (isRtl && orientation === 'horizontal') {
324 axis += '-reverse';
325 }
326 const getFingerNewValue = ({
327 finger,
328 move = false
329 }) => {
330 const {
331 current: slider
332 } = sliderRef;
333 const {
334 width,
335 height,
336 bottom,
337 left
338 } = slider.getBoundingClientRect();
339 let percent;
340 if (axis.startsWith('vertical')) {
341 percent = (bottom - finger.y) / height;
342 } else {
343 percent = (finger.x - left) / width;
344 }
345 if (axis.includes('-reverse')) {
346 percent = 1 - percent;
347 }
348 let newValue;
349 newValue = percentToValue(percent, min, max);
350 if (step) {
351 newValue = roundValueToStep(newValue, step, min);
352 } else {
353 const closestIndex = findClosest(marksValues, newValue);
354 newValue = marksValues[closestIndex];
355 }
356 newValue = clamp(newValue, min, max);
357 let activeIndex = 0;
358 if (range) {
359 if (!move) {
360 activeIndex = findClosest(values, newValue);
361 } else {
362 activeIndex = previousIndex.current;
363 }
364
365 // Bound the new value to the thumb's neighbours.
366 if (disableSwap) {
367 newValue = clamp(newValue, values[activeIndex - 1] || -Infinity, values[activeIndex + 1] || Infinity);
368 }
369 const previousValue = newValue;
370 newValue = setValueIndex({
371 values,
372 newValue,
373 index: activeIndex
374 });
375
376 // Potentially swap the index if needed.
377 if (!(disableSwap && move)) {
378 activeIndex = newValue.indexOf(previousValue);
379 previousIndex.current = activeIndex;
380 }
381 }
382 return {
383 newValue,
384 activeIndex
385 };
386 };
387 const handleTouchMove = useEventCallback(nativeEvent => {
388 const finger = trackFinger(nativeEvent, touchId);
389 if (!finger) {
390 return;
391 }
392 moveCount.current += 1;
393
394 // Cancel move in case some other element consumed a mouseup event and it was not fired.
395 // @ts-ignore buttons doesn't not exists on touch event
396 if (nativeEvent.type === 'mousemove' && nativeEvent.buttons === 0) {
397 // eslint-disable-next-line @typescript-eslint/no-use-before-define
398 handleTouchEnd(nativeEvent);
399 return;
400 }
401 const {
402 newValue,
403 activeIndex
404 } = getFingerNewValue({
405 finger,
406 move: true
407 });
408 focusThumb({
409 sliderRef,
410 activeIndex,
411 setActive
412 });
413 setValueState(newValue);
414 if (!dragging && moveCount.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) {
415 setDragging(true);
416 }
417 if (handleChange && !areValuesEqual(newValue, valueDerived)) {
418 handleChange(nativeEvent, newValue, activeIndex);
419 }
420 });
421 const handleTouchEnd = useEventCallback(nativeEvent => {
422 const finger = trackFinger(nativeEvent, touchId);
423 setDragging(false);
424 if (!finger) {
425 return;
426 }
427 const {
428 newValue
429 } = getFingerNewValue({
430 finger,
431 move: true
432 });
433 setActive(-1);
434 if (nativeEvent.type === 'touchend') {
435 setOpen(-1);
436 }
437 if (onChangeCommitted) {
438 onChangeCommitted(nativeEvent, newValue);
439 }
440 touchId.current = undefined;
441
442 // eslint-disable-next-line @typescript-eslint/no-use-before-define
443 stopListening();
444 });
445 const handleTouchStart = useEventCallback(nativeEvent => {
446 if (disabled) {
447 return;
448 }
449 // If touch-action: none; is not supported we need to prevent the scroll manually.
450 if (!doesSupportTouchActionNone()) {
451 nativeEvent.preventDefault();
452 }
453 const touch = nativeEvent.changedTouches[0];
454 if (touch != null) {
455 // A number that uniquely identifies the current finger in the touch session.
456 touchId.current = touch.identifier;
457 }
458 const finger = trackFinger(nativeEvent, touchId);
459 if (finger !== false) {
460 const {
461 newValue,
462 activeIndex
463 } = getFingerNewValue({
464 finger
465 });
466 focusThumb({
467 sliderRef,
468 activeIndex,
469 setActive
470 });
471 setValueState(newValue);
472 if (handleChange && !areValuesEqual(newValue, valueDerived)) {
473 handleChange(nativeEvent, newValue, activeIndex);
474 }
475 }
476 moveCount.current = 0;
477 const doc = ownerDocument(sliderRef.current);
478 doc.addEventListener('touchmove', handleTouchMove, {
479 passive: true
480 });
481 doc.addEventListener('touchend', handleTouchEnd, {
482 passive: true
483 });
484 });
485 const stopListening = React.useCallback(() => {
486 const doc = ownerDocument(sliderRef.current);
487 doc.removeEventListener('mousemove', handleTouchMove);
488 doc.removeEventListener('mouseup', handleTouchEnd);
489 doc.removeEventListener('touchmove', handleTouchMove);
490 doc.removeEventListener('touchend', handleTouchEnd);
491 }, [handleTouchEnd, handleTouchMove]);
492 React.useEffect(() => {
493 const {
494 current: slider
495 } = sliderRef;
496 slider.addEventListener('touchstart', handleTouchStart, {
497 passive: doesSupportTouchActionNone()
498 });
499 return () => {
500 slider.removeEventListener('touchstart', handleTouchStart);
501 stopListening();
502 };
503 }, [stopListening, handleTouchStart]);
504 React.useEffect(() => {
505 if (disabled) {
506 stopListening();
507 }
508 }, [disabled, stopListening]);
509 const createHandleMouseDown = otherHandlers => event => {
510 otherHandlers.onMouseDown?.(event);
511 if (disabled) {
512 return;
513 }
514 if (event.defaultPrevented) {
515 return;
516 }
517
518 // Only handle left clicks
519 if (event.button !== 0) {
520 return;
521 }
522
523 // Avoid text selection
524 event.preventDefault();
525 const finger = trackFinger(event, touchId);
526 if (finger !== false) {
527 const {
528 newValue,
529 activeIndex
530 } = getFingerNewValue({
531 finger
532 });
533 focusThumb({
534 sliderRef,
535 activeIndex,
536 setActive
537 });
538 setValueState(newValue);
539 if (handleChange && !areValuesEqual(newValue, valueDerived)) {
540 handleChange(event, newValue, activeIndex);
541 }
542 }
543 moveCount.current = 0;
544 const doc = ownerDocument(sliderRef.current);
545 doc.addEventListener('mousemove', handleTouchMove, {
546 passive: true
547 });
548 doc.addEventListener('mouseup', handleTouchEnd);
549 };
550 const trackOffset = valueToPercent(range ? values[0] : min, min, max);
551 const trackLeap = valueToPercent(values[values.length - 1], min, max) - trackOffset;
552 const getRootProps = (externalProps = {}) => {
553 const externalHandlers = extractEventHandlers(externalProps);
554 const ownEventHandlers = {
555 onMouseDown: createHandleMouseDown(externalHandlers || {})
556 };
557 const mergedEventHandlers = {
558 ...externalHandlers,
559 ...ownEventHandlers
560 };
561 return {
562 ...externalProps,
563 ref: handleRef,
564 ...mergedEventHandlers
565 };
566 };
567 const createHandleMouseOver = otherHandlers => event => {
568 otherHandlers.onMouseOver?.(event);
569 const index = Number(event.currentTarget.getAttribute('data-index'));
570 setOpen(index);
571 };
572 const createHandleMouseLeave = otherHandlers => event => {
573 otherHandlers.onMouseLeave?.(event);
574 setOpen(-1);
575 };
576 const getThumbProps = (externalProps = {}) => {
577 const externalHandlers = extractEventHandlers(externalProps);
578 const ownEventHandlers = {
579 onMouseOver: createHandleMouseOver(externalHandlers || {}),
580 onMouseLeave: createHandleMouseLeave(externalHandlers || {})
581 };
582 return {
583 ...externalProps,
584 ...externalHandlers,
585 ...ownEventHandlers
586 };
587 };
588 const getThumbStyle = index => {
589 return {
590 // So the non active thumb doesn't show its label on hover.
591 pointerEvents: active !== -1 && active !== index ? 'none' : undefined
592 };
593 };
594 const getHiddenInputProps = (externalProps = {}) => {
595 const externalHandlers = extractEventHandlers(externalProps);
596 const ownEventHandlers = {
597 onChange: createHandleHiddenInputChange(externalHandlers || {}),
598 onFocus: createHandleHiddenInputFocus(externalHandlers || {}),
599 onBlur: createHandleHiddenInputBlur(externalHandlers || {}),
600 onKeyDown: createHandleHiddenInputKeyDown(externalHandlers || {})
601 };
602 const mergedEventHandlers = {
603 ...externalHandlers,
604 ...ownEventHandlers
605 };
606 return {
607 tabIndex,
608 'aria-labelledby': ariaLabelledby,
609 'aria-orientation': orientation,
610 'aria-valuemax': scale(max),
611 'aria-valuemin': scale(min),
612 name,
613 type: 'range',
614 min: parameters.min,
615 max: parameters.max,
616 step: parameters.step === null && parameters.marks ? 'any' : parameters.step ?? undefined,
617 disabled,
618 ...externalProps,
619 ...mergedEventHandlers,
620 style: {
621 ...visuallyHidden,
622 direction: isRtl ? 'rtl' : 'ltr',
623 // So that VoiceOver's focus indicator matches the thumb's dimensions
624 width: '100%',
625 height: '100%'
626 }
627 };
628 };
629 return {
630 active,
631 axis: axis,
632 axisProps,
633 dragging,
634 focusedThumbIndex,
635 getHiddenInputProps,
636 getRootProps,
637 getThumbProps,
638 marks: marks,
639 open,
640 range,
641 rootRef: handleRef,
642 trackLeap,
643 trackOffset,
644 values,
645 getThumbStyle
646 };
647}
\No newline at end of file