UNPKG

20.2 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 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();
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 {
218 isFocusVisibleRef,
219 onBlur: handleBlurVisible,
220 onFocus: handleFocusVisible,
221 ref: focusVisibleRef
222 } = useIsFocusVisible();
223 const [focusedThumbIndex, setFocusedThumbIndex] = React.useState(-1);
224 const sliderRef = React.useRef();
225 const handleFocusRef = useForkRef(focusVisibleRef, sliderRef);
226 const handleRef = useForkRef(ref, handleFocusRef);
227 const createHandleHiddenInputFocus = otherHandlers => event => {
228 const index = Number(event.currentTarget.getAttribute('data-index'));
229 handleFocusVisible(event);
230 if (isFocusVisibleRef.current === true) {
231 setFocusedThumbIndex(index);
232 }
233 setOpen(index);
234 otherHandlers?.onFocus?.(event);
235 };
236 const createHandleHiddenInputBlur = otherHandlers => event => {
237 handleBlurVisible(event);
238 if (isFocusVisibleRef.current === false) {
239 setFocusedThumbIndex(-1);
240 }
241 setOpen(-1);
242 otherHandlers?.onBlur?.(event);
243 };
244 const changeValue = (event, valueInput) => {
245 const index = Number(event.currentTarget.getAttribute('data-index'));
246 const value = values[index];
247 const marksIndex = marksValues.indexOf(value);
248 let newValue = valueInput;
249 if (marks && step == null) {
250 const maxMarksValue = marksValues[marksValues.length - 1];
251 if (newValue > maxMarksValue) {
252 newValue = maxMarksValue;
253 } else if (newValue < marksValues[0]) {
254 newValue = marksValues[0];
255 } else {
256 newValue = newValue < value ? marksValues[marksIndex - 1] : marksValues[marksIndex + 1];
257 }
258 }
259 newValue = clamp(newValue, min, max);
260 if (range) {
261 // Bound the new value to the thumb's neighbours.
262 if (disableSwap) {
263 newValue = clamp(newValue, values[index - 1] || -Infinity, values[index + 1] || Infinity);
264 }
265 const previousValue = newValue;
266 newValue = setValueIndex({
267 values,
268 newValue,
269 index
270 });
271 let activeIndex = index;
272
273 // Potentially swap the index if needed.
274 if (!disableSwap) {
275 activeIndex = newValue.indexOf(previousValue);
276 }
277 focusThumb({
278 sliderRef,
279 activeIndex
280 });
281 }
282 setValueState(newValue);
283 setFocusedThumbIndex(index);
284 if (handleChange && !areValuesEqual(newValue, valueDerived)) {
285 handleChange(event, newValue, index);
286 }
287 if (onChangeCommitted) {
288 onChangeCommitted(event, newValue);
289 }
290 };
291 const createHandleHiddenInputKeyDown = otherHandlers => event => {
292 // The Shift + Up/Down keyboard shortcuts for moving the slider makes sense to be supported
293 // only if the step is defined. If the step is null, this means tha the marks are used for specifying the valid values.
294 if (step !== null) {
295 const index = Number(event.currentTarget.getAttribute('data-index'));
296 const value = values[index];
297 let newValue = null;
298 if ((event.key === 'ArrowLeft' || event.key === 'ArrowDown') && event.shiftKey || event.key === 'PageDown') {
299 newValue = Math.max(value - shiftStep, min);
300 } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && event.shiftKey || event.key === 'PageUp') {
301 newValue = Math.min(value + shiftStep, max);
302 }
303 if (newValue !== null) {
304 changeValue(event, newValue);
305 event.preventDefault();
306 }
307 }
308 otherHandlers?.onKeyDown?.(event);
309 };
310 useEnhancedEffect(() => {
311 if (disabled && sliderRef.current.contains(document.activeElement)) {
312 // This is necessary because Firefox and Safari will keep focus
313 // on a disabled element:
314 // https://codesandbox.io/p/sandbox/mui-pr-22247-forked-h151h?file=/src/App.js
315 // @ts-ignore
316 document.activeElement?.blur();
317 }
318 }, [disabled]);
319 if (disabled && active !== -1) {
320 setActive(-1);
321 }
322 if (disabled && focusedThumbIndex !== -1) {
323 setFocusedThumbIndex(-1);
324 }
325 const createHandleHiddenInputChange = otherHandlers => event => {
326 otherHandlers.onChange?.(event);
327 // @ts-ignore
328 changeValue(event, event.target.valueAsNumber);
329 };
330 const previousIndex = React.useRef();
331 let axis = orientation;
332 if (isRtl && orientation === 'horizontal') {
333 axis += '-reverse';
334 }
335 const getFingerNewValue = ({
336 finger,
337 move = false
338 }) => {
339 const {
340 current: slider
341 } = sliderRef;
342 const {
343 width,
344 height,
345 bottom,
346 left
347 } = slider.getBoundingClientRect();
348 let percent;
349 if (axis.indexOf('vertical') === 0) {
350 percent = (bottom - finger.y) / height;
351 } else {
352 percent = (finger.x - left) / width;
353 }
354 if (axis.indexOf('-reverse') !== -1) {
355 percent = 1 - percent;
356 }
357 let newValue;
358 newValue = percentToValue(percent, min, max);
359 if (step) {
360 newValue = roundValueToStep(newValue, step, min);
361 } else {
362 const closestIndex = findClosest(marksValues, newValue);
363 newValue = marksValues[closestIndex];
364 }
365 newValue = clamp(newValue, min, max);
366 let activeIndex = 0;
367 if (range) {
368 if (!move) {
369 activeIndex = findClosest(values, newValue);
370 } else {
371 activeIndex = previousIndex.current;
372 }
373
374 // Bound the new value to the thumb's neighbours.
375 if (disableSwap) {
376 newValue = clamp(newValue, values[activeIndex - 1] || -Infinity, values[activeIndex + 1] || Infinity);
377 }
378 const previousValue = newValue;
379 newValue = setValueIndex({
380 values,
381 newValue,
382 index: activeIndex
383 });
384
385 // Potentially swap the index if needed.
386 if (!(disableSwap && move)) {
387 activeIndex = newValue.indexOf(previousValue);
388 previousIndex.current = activeIndex;
389 }
390 }
391 return {
392 newValue,
393 activeIndex
394 };
395 };
396 const handleTouchMove = useEventCallback(nativeEvent => {
397 const finger = trackFinger(nativeEvent, touchId);
398 if (!finger) {
399 return;
400 }
401 moveCount.current += 1;
402
403 // Cancel move in case some other element consumed a mouseup event and it was not fired.
404 // @ts-ignore buttons doesn't not exists on touch event
405 if (nativeEvent.type === 'mousemove' && nativeEvent.buttons === 0) {
406 // eslint-disable-next-line @typescript-eslint/no-use-before-define
407 handleTouchEnd(nativeEvent);
408 return;
409 }
410 const {
411 newValue,
412 activeIndex
413 } = getFingerNewValue({
414 finger,
415 move: true
416 });
417 focusThumb({
418 sliderRef,
419 activeIndex,
420 setActive
421 });
422 setValueState(newValue);
423 if (!dragging && moveCount.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) {
424 setDragging(true);
425 }
426 if (handleChange && !areValuesEqual(newValue, valueDerived)) {
427 handleChange(nativeEvent, newValue, activeIndex);
428 }
429 });
430 const handleTouchEnd = useEventCallback(nativeEvent => {
431 const finger = trackFinger(nativeEvent, touchId);
432 setDragging(false);
433 if (!finger) {
434 return;
435 }
436 const {
437 newValue
438 } = getFingerNewValue({
439 finger,
440 move: true
441 });
442 setActive(-1);
443 if (nativeEvent.type === 'touchend') {
444 setOpen(-1);
445 }
446 if (onChangeCommitted) {
447 onChangeCommitted(nativeEvent, newValue);
448 }
449 touchId.current = undefined;
450
451 // eslint-disable-next-line @typescript-eslint/no-use-before-define
452 stopListening();
453 });
454 const handleTouchStart = useEventCallback(nativeEvent => {
455 if (disabled) {
456 return;
457 }
458 // If touch-action: none; is not supported we need to prevent the scroll manually.
459 if (!doesSupportTouchActionNone()) {
460 nativeEvent.preventDefault();
461 }
462 const touch = nativeEvent.changedTouches[0];
463 if (touch != null) {
464 // A number that uniquely identifies the current finger in the touch session.
465 touchId.current = touch.identifier;
466 }
467 const finger = trackFinger(nativeEvent, touchId);
468 if (finger !== false) {
469 const {
470 newValue,
471 activeIndex
472 } = getFingerNewValue({
473 finger
474 });
475 focusThumb({
476 sliderRef,
477 activeIndex,
478 setActive
479 });
480 setValueState(newValue);
481 if (handleChange && !areValuesEqual(newValue, valueDerived)) {
482 handleChange(nativeEvent, newValue, activeIndex);
483 }
484 }
485 moveCount.current = 0;
486 const doc = ownerDocument(sliderRef.current);
487 doc.addEventListener('touchmove', handleTouchMove, {
488 passive: true
489 });
490 doc.addEventListener('touchend', handleTouchEnd, {
491 passive: true
492 });
493 });
494 const stopListening = React.useCallback(() => {
495 const doc = ownerDocument(sliderRef.current);
496 doc.removeEventListener('mousemove', handleTouchMove);
497 doc.removeEventListener('mouseup', handleTouchEnd);
498 doc.removeEventListener('touchmove', handleTouchMove);
499 doc.removeEventListener('touchend', handleTouchEnd);
500 }, [handleTouchEnd, handleTouchMove]);
501 React.useEffect(() => {
502 const {
503 current: slider
504 } = sliderRef;
505 slider.addEventListener('touchstart', handleTouchStart, {
506 passive: doesSupportTouchActionNone()
507 });
508 return () => {
509 slider.removeEventListener('touchstart', handleTouchStart);
510 stopListening();
511 };
512 }, [stopListening, handleTouchStart]);
513 React.useEffect(() => {
514 if (disabled) {
515 stopListening();
516 }
517 }, [disabled, stopListening]);
518 const createHandleMouseDown = otherHandlers => event => {
519 otherHandlers.onMouseDown?.(event);
520 if (disabled) {
521 return;
522 }
523 if (event.defaultPrevented) {
524 return;
525 }
526
527 // Only handle left clicks
528 if (event.button !== 0) {
529 return;
530 }
531
532 // Avoid text selection
533 event.preventDefault();
534 const finger = trackFinger(event, touchId);
535 if (finger !== false) {
536 const {
537 newValue,
538 activeIndex
539 } = getFingerNewValue({
540 finger
541 });
542 focusThumb({
543 sliderRef,
544 activeIndex,
545 setActive
546 });
547 setValueState(newValue);
548 if (handleChange && !areValuesEqual(newValue, valueDerived)) {
549 handleChange(event, newValue, activeIndex);
550 }
551 }
552 moveCount.current = 0;
553 const doc = ownerDocument(sliderRef.current);
554 doc.addEventListener('mousemove', handleTouchMove, {
555 passive: true
556 });
557 doc.addEventListener('mouseup', handleTouchEnd);
558 };
559 const trackOffset = valueToPercent(range ? values[0] : min, min, max);
560 const trackLeap = valueToPercent(values[values.length - 1], min, max) - trackOffset;
561 const getRootProps = (externalProps = {}) => {
562 const externalHandlers = extractEventHandlers(externalProps);
563 const ownEventHandlers = {
564 onMouseDown: createHandleMouseDown(externalHandlers || {})
565 };
566 const mergedEventHandlers = _extends({}, externalHandlers, ownEventHandlers);
567 return _extends({}, externalProps, {
568 ref: handleRef
569 }, mergedEventHandlers);
570 };
571 const createHandleMouseOver = otherHandlers => event => {
572 otherHandlers.onMouseOver?.(event);
573 const index = Number(event.currentTarget.getAttribute('data-index'));
574 setOpen(index);
575 };
576 const createHandleMouseLeave = otherHandlers => event => {
577 otherHandlers.onMouseLeave?.(event);
578 setOpen(-1);
579 };
580 const getThumbProps = (externalProps = {}) => {
581 const externalHandlers = extractEventHandlers(externalProps);
582 const ownEventHandlers = {
583 onMouseOver: createHandleMouseOver(externalHandlers || {}),
584 onMouseLeave: createHandleMouseLeave(externalHandlers || {})
585 };
586 return _extends({}, externalProps, externalHandlers, ownEventHandlers);
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 = _extends({}, externalHandlers, ownEventHandlers);
603 return _extends({
604 tabIndex,
605 'aria-labelledby': ariaLabelledby,
606 'aria-orientation': orientation,
607 'aria-valuemax': scale(max),
608 'aria-valuemin': scale(min),
609 name,
610 type: 'range',
611 min: parameters.min,
612 max: parameters.max,
613 step: parameters.step === null && parameters.marks ? 'any' : parameters.step ?? undefined,
614 disabled
615 }, externalProps, mergedEventHandlers, {
616 style: _extends({}, visuallyHidden, {
617 direction: isRtl ? 'rtl' : 'ltr',
618 // So that VoiceOver's focus indicator matches the thumb's dimensions
619 width: '100%',
620 height: '100%'
621 })
622 });
623 };
624 return {
625 active,
626 axis: axis,
627 axisProps,
628 dragging,
629 focusedThumbIndex,
630 getHiddenInputProps,
631 getRootProps,
632 getThumbProps,
633 marks: marks,
634 open,
635 range,
636 rootRef: handleRef,
637 trackLeap,
638 trackOffset,
639 values,
640 getThumbStyle
641 };
642}
\No newline at end of file