@react-md/form
Version:
This package is for creating all the different form input types.
476 lines (438 loc) • 12.7 kB
text/typescript
import type {
KeyboardEventHandler,
MouseEventHandler,
FocusEventHandler,
Ref,
RefCallback,
TouchEventHandler,
} from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { applyRef, useDir, useIsomorphicLayoutEffect } from "@react-md/utils";
import { DEFAULT_SLIDER_ANIMATION_TIME } from "./constants";
import type {
DefinedSliderValueOptions,
SliderControls,
SliderDragEvent,
SliderDraggingBy,
SliderEventHandlers,
SliderPresentation,
SliderThumbIndex,
ThumbIndex,
} from "./types";
import type { CombinedSliderControls, SliderDragValues } from "./utils";
import {
getDragPercentage,
getDragValue,
isMouseEvent,
isRangeSlider,
isTouchEvent,
} from "./utils";
/**
* @internal
* @remarks \@since 2.5.0
*/
export type SliderControlsOptions = CombinedSliderControls &
SliderPresentation &
SliderEventHandlers &
DefinedSliderValueOptions & {
ref?: Ref<HTMLSpanElement | null>;
thumb1Ref?: Ref<HTMLSpanElement | null>;
thumb2Ref?: Ref<HTMLSpanElement | null>;
animationDuration?: number;
};
const VALID_KEYS = [
"ArrowDown",
"ArrowUp",
"ArrowLeft",
"ArrowRight",
"Home",
"End",
"PageUp",
"PageDown",
];
/**
* @internal
* @remarks \@since 2.5.0
*/
export interface SliderAndRangeSliderControls {
thumb1Ref: RefCallback<HTMLSpanElement | null>;
thumb1Value: number;
thumb1Percentage: string;
thumb2Ref: RefCallback<HTMLSpanElement | null>;
thumb2Value?: number;
thumb2Percentage?: string;
dragging: boolean;
draggingIndex: SliderThumbIndex;
ref: RefCallback<HTMLSpanElement | null>;
onBlur: FocusEventHandler<HTMLSpanElement>;
onKeyDown: KeyboardEventHandler<HTMLSpanElement>;
onMouseDown: MouseEventHandler<HTMLSpanElement>;
onTouchStart: TouchEventHandler<HTMLSpanElement>;
}
/**
* This hook provides all the logic for updating the slider's when the user
* interacts with the slider.
*
* @internal
* @remarks \@since 2.5.0
*/
export function useSliderControls({
ref,
thumb1Ref: propThumb1Ref,
thumb2Ref: propThumb2Ref,
min,
max,
step,
disabled = false,
vertical = false,
onBlur,
onKeyDown,
onMouseDown,
onTouchStart,
animationDuration = DEFAULT_SLIDER_ANIMATION_TIME,
...controls
}: SliderControlsOptions): SliderAndRangeSliderControls {
const trackRef = useRef<HTMLSpanElement | null>(null);
const thumb1Ref = useRef<HTMLSpanElement | null>(null);
const thumb2Ref = useRef<HTMLSpanElement | null>(null);
const [dragging, setDragging] = useState(false);
const [dragValue, setDragValue] = useState<number>(min);
const [draggingBy, setDraggingBy] = useState<SliderDraggingBy>(null);
const [draggingIndex, setDraggingIndex] = useState<SliderThumbIndex>(null);
const controlsRef = useRef(controls);
useIsomorphicLayoutEffect(() => {
controlsRef.current = controls;
});
const { dir } = useDir();
const isRtl = dir === "rtl";
let thumb1Value: number;
let thumb1Percentage: string;
let thumb2Value: number | undefined;
let thumb2Percentage: string | undefined;
if (isRangeSlider(controls)) {
[thumb1Value, thumb2Value] = controls.value;
({ thumb1Percentage, thumb2Percentage } = getDragPercentage({
min,
max,
thumb1Value,
thumb2Value,
dragging,
dragValue,
draggingIndex,
}));
} else {
thumb1Value = controls.value;
({ thumb1Percentage } = getDragPercentage({
min,
max,
thumb1Value,
dragging,
dragValue,
draggingIndex,
}));
}
/**
* The main handler for updating the value of the slider. To help keep the
* drag experience smooth, some values are stored in refs to prevent the
* `useEffect` from being run during renders which adds and removes the move
* event handlers
*/
const drag = useCallback(
(event: SliderDragEvent) => {
const track = trackRef.current;
const slider1 = thumb1Ref.current;
const slider2 = thumb2Ref.current;
const { altKey, ctrlKey, metaKey, shiftKey } = event;
if (
altKey ||
ctrlKey ||
metaKey ||
shiftKey ||
disabled ||
!track ||
!slider1 ||
(isMouseEvent(event) && event.button !== 0) ||
(isTouchEvent(event) && event.changedTouches.length !== 1) ||
(!isMouseEvent(event) && !isTouchEvent(event))
) {
return;
}
// prevent text from being highlighted on desktop or the page from
// scrolling on mobile while dragging
if (!isTouchEvent(event) || event.type === "touchmove") {
event.preventDefault();
}
event.stopPropagation();
// get the current mouse/touch position to help determine hwo far the
// slider is being dragged
let clientX: number;
let clientY: number;
if (isMouseEvent(event)) {
({ clientX, clientY } = event);
} else {
const touch = event.changedTouches[0];
({ clientX, clientY } = touch);
}
let index: ThumbIndex = 0;
let slider: HTMLSpanElement = slider1;
if (slider2) {
// if we aren't dragging yet, try to find the slider closest to the
// mouse/touch position and use that one
if (draggingIndex === null) {
const x1 = slider1.getBoundingClientRect().x;
const x2 = slider2.getBoundingClientRect().x;
const y1 = slider1.getBoundingClientRect().y;
const y2 = slider2.getBoundingClientRect().y;
if (vertical) {
index = Math.abs(clientY - y1) < Math.abs(clientY - y2) ? 0 : 1;
} else {
index = Math.abs(clientX - x1) < Math.abs(clientX - x2) ? 0 : 1;
}
} else {
index = draggingIndex;
}
slider = index === 0 ? slider1 : slider2;
}
// if we aren't dragging yet, want to focus the slider element to make it
// easier to switch between mouse dragging and keyboard "dragging"
if (draggingIndex !== index) {
slider.focus();
setDraggingIndex(index);
}
setDraggingBy(isMouseEvent(event) ? "mouse" : "touch");
const { left, top, height, width } = track.getBoundingClientRect();
const options: SliderDragValues = {
min,
max,
step,
vertical,
clientX,
clientY,
left,
top,
height,
width,
isRtl,
minValue: min,
maxValue: max,
};
const controls = controlsRef.current;
if (isRangeSlider(controls)) {
const [thumb1Value, thumb2Value] = controls.value;
const { value, current } = getDragValue({
...options,
minValue: index === 0 ? min : thumb1Value + step,
maxValue: index === 1 ? max : thumb2Value - step,
});
setDragValue(current);
controls.setValue(
index === 0 ? [value, thumb2Value] : [thumb1Value, value]
);
} else {
const { value, current } = getDragValue(options);
setDragValue(current);
controls.setValue(value);
}
},
[disabled, isRtl, draggingIndex, max, min, step, vertical]
);
const stop = useCallback(() => {
controlsRef.current.persist();
setDragging(false);
setDraggingIndex(null);
setDraggingBy(null);
}, []);
useEffect(() => {
if (draggingBy === null) {
return;
}
if (draggingBy === "mouse") {
window.addEventListener("mousemove", drag);
window.addEventListener("mouseup", stop);
} else {
window.addEventListener("touchmove", drag, { passive: false });
window.addEventListener("touchend", stop);
}
return () => {
if (draggingBy === "mouse") {
window.removeEventListener("mousemove", drag);
window.removeEventListener("mouseup", stop);
} else {
window.removeEventListener("touchmove", drag);
window.removeEventListener("touchend", stop);
}
};
}, [draggingBy, drag, stop]);
useEffect(() => {
if (draggingIndex === null && draggingBy === null) {
return;
}
// I don't know how to reach this flow.. so maybe can be removed?
/* istanbul ignore if */
if (draggingIndex === null) {
setDragging(false);
return;
}
const timeout = window.setTimeout(() => {
setDragging(true);
}, animationDuration);
return () => {
window.clearTimeout(timeout);
};
}, [draggingIndex, draggingBy, animationDuration]);
const handleBlur = useCallback<FocusEventHandler<HTMLSpanElement>>(
(event) => {
if (onBlur) {
onBlur(event);
}
controlsRef.current.persist();
},
[onBlur]
);
/**
* Note: this should be attached to the `SliderTrack` component.
*/
const handleMouseDown = useCallback<MouseEventHandler<HTMLSpanElement>>(
(event) => {
if (onMouseDown) {
onMouseDown(event);
}
// only call drag again when the dragging by isn't null since it can cause
// the "drag" events to be re-started if the mouse appears over the slider
// thumb again
if (draggingBy === null) {
drag(event);
}
},
[drag, draggingBy, onMouseDown]
);
/**
* Note: this should be attached to the `SliderTrack` component.
*/
const handleTouchStart = useCallback<TouchEventHandler<HTMLSpanElement>>(
(event) => {
if (onTouchStart) {
onTouchStart(event);
}
// only call drag again when the dragging by isn't null since it can cause
// the "drag" events to be re-started if the user's finger appears over
// the slider thumb again
if (draggingBy === null) {
drag(event);
}
},
[drag, draggingBy, onTouchStart]
);
/**
* Note: this should be attached to each `SliderThumb` component.
*/
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLSpanElement>>(
(event) => {
if (onKeyDown) {
onKeyDown(event);
}
const { key, altKey, ctrlKey, metaKey, shiftKey } = event;
if (
altKey ||
ctrlKey ||
metaKey ||
shiftKey ||
disabled ||
!VALID_KEYS.includes(key)
) {
return;
}
let controls: Omit<SliderControls, "setValue" | "value" | "persist">;
if (isRangeSlider(controlsRef.current)) {
const {
increment,
incrementJump,
decrement,
decrementJump,
minimum,
maximum,
} = controlsRef.current;
const index = event.currentTarget === thumb2Ref.current ? 1 : 0;
controls = {
increment: increment.bind(null, index),
incrementJump: incrementJump.bind(null, index),
decrement: decrement.bind(null, index),
decrementJump: decrementJump.bind(null, index),
minimum: minimum.bind(null, index),
maximum: maximum.bind(null, index),
};
} else {
controls = controlsRef.current;
}
const {
increment,
incrementJump,
decrement,
decrementJump,
minimum,
maximum,
} = controls;
event.preventDefault();
event.stopPropagation();
switch (key) {
case "ArrowUp":
case "ArrowRight":
increment();
break;
case "ArrowDown":
case "ArrowLeft":
decrement();
break;
case "Home":
minimum();
break;
case "End":
maximum();
break;
case "PageUp":
incrementJump();
break;
case "PageDown":
decrementJump();
break;
}
},
[onKeyDown, disabled]
);
const trackRefHandler = useCallback(
(instance: HTMLSpanElement | null) => {
applyRef(instance, ref);
trackRef.current = instance;
},
[ref]
);
const thumb1RefHandler = useCallback(
(instance: HTMLSpanElement | null) => {
applyRef(instance, propThumb1Ref);
thumb1Ref.current = instance;
},
[propThumb1Ref]
);
const thumb2RefHandler = useCallback(
(instance: HTMLSpanElement | null) => {
applyRef(instance, propThumb2Ref);
thumb2Ref.current = instance;
},
[propThumb2Ref]
);
return {
thumb1Ref: thumb1RefHandler,
thumb1Value,
thumb1Percentage,
thumb2Ref: thumb2RefHandler,
thumb2Value,
thumb2Percentage,
dragging,
draggingIndex,
ref: trackRefHandler,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
onMouseDown: handleMouseDown,
onTouchStart: handleTouchStart,
};
}