UNPKG

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