UNPKG

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