UNPKG

@bianic-ui/slider

Version:

Accessible slider component for React that implements <input type=range>

592 lines (512 loc) 17.4 kB
"use strict"; exports.__esModule = true; exports.useSlider = useSlider; var _hooks = require("@bianic-ui/hooks"); var _utils = require("@bianic-ui/utils"); var _react = require("react"); function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; } /** * React hook that implements an accessible range slider. * * It's an alternative to `<input type="range" />`, and returns * prop getters for the component parts * * @see Docs https://bianic-ui.com/components/slider * @see WAI-ARIA https://www.w3.org/TR/wai-aria-practices-1.1/#slider */ function useSlider(props) { var _getAriaValueText, _thumbBoxModel$border; var _props$min = props.min, min = _props$min === void 0 ? 0 : _props$min, _props$max = props.max, max = _props$max === void 0 ? 100 : _props$max, onChange = props.onChange, valueProp = props.value, defaultValue = props.defaultValue, isReversed = props.isReversed, orientation = props.orientation, idProp = props.id, isDisabled = props.isDisabled, isReadOnly = props.isReadOnly, onChangeStart = props.onChangeStart, onChangeEnd = props.onChangeEnd, _props$step = props.step, step = _props$step === void 0 ? 1 : _props$step, getAriaValueText = props.getAriaValueText, ariaValueText = props["aria-valuetext"], ariaLabel = props["aria-label"], ariaLabelledBy = props["aria-labelledby"], name = props.name, htmlProps = _objectWithoutPropertiesLoose(props, ["min", "max", "onChange", "value", "defaultValue", "isReversed", "orientation", "id", "isDisabled", "isReadOnly", "onChangeStart", "onChangeEnd", "step", "getAriaValueText", "aria-valuetext", "aria-label", "aria-labelledby", "name"]); var _useBoolean = (0, _hooks.useBoolean)(), isDragging = _useBoolean[0], setDragging = _useBoolean[1]; var _useBoolean2 = (0, _hooks.useBoolean)(), isFocused = _useBoolean2[0], setFocused = _useBoolean2[1]; var _useState = (0, _react.useState)(), eventSource = _useState[0], setEventSource = _useState[1]; var isInteractive = !(isDisabled || isReadOnly); /** * Enable the slider handle controlled and uncontrolled scenarios */ var _useControllableState = (0, _hooks.useControllableState)({ value: valueProp, defaultValue: defaultValue != null ? defaultValue : getDefaultValue(min, max), onChange: onChange, shouldUpdate: function shouldUpdate(prev, next) { return prev !== next; } }), computedValue = _useControllableState[0], setValue = _useControllableState[1]; /** * Slider uses DOM APIs to add and remove event listeners. * Noticed some issues with React's synthetic events. * * We use `ref` to save the functions used to remove * the event listeners. * * Ideally, we'll love to use pointer-events API but it's * not fully supported in all browsers. */ var cleanUpRef = (0, _react.useRef)({}); /** * Constrain the value because it can't be less than min * or greater than max */ var value = (0, _utils.clampValue)(computedValue, min, max); var prev = (0, _react.useRef)(); var reversedValue = max - value + min; var trackValue = isReversed ? reversedValue : value; var trackPercent = (0, _utils.valueToPercent)(trackValue, min, max); var isVertical = orientation === "vertical"; /** * Let's keep a reference to the slider track and thumb */ var trackRef = (0, _react.useRef)(null); var thumbRef = (0, _react.useRef)(null); var rootRef = (0, _react.useRef)(null); /** * Generate unique ids for component parts */ var _useIds = (0, _hooks.useIds)(idProp, "slider-thumb", "slider-track"), thumbId = _useIds[0], trackId = _useIds[1]; /** * Get relative value of slider from the event by tracking * how far you clicked within the track to determine the value */ var getValueFromPointer = (0, _react.useCallback)(function (event) { var _event$touches$, _event$touches; if (!trackRef.current) return; var trackRect = (0, _utils.getBox)(trackRef.current).borderBox; var _ref = (_event$touches$ = (_event$touches = event.touches) == null ? void 0 : _event$touches[0]) != null ? _event$touches$ : event, clientX = _ref.clientX, clientY = _ref.clientY; var diff = isVertical ? trackRect.bottom - clientY : clientX - trackRect.left; var length = isVertical ? trackRect.height : trackRect.width; var percent = diff / length; if (isReversed) { percent = 1 - percent; } var nextValue = (0, _utils.percentToValue)(percent, min, max); if (step) { nextValue = parseFloat((0, _utils.roundValueToStep)(nextValue, min, step)); } nextValue = (0, _utils.clampValue)(nextValue, min, max); return nextValue; }, [isVertical, isReversed, max, min, step]); var tenSteps = (max - min) / 10; var oneStep = step || (max - min) / 100; var constrain = (0, _react.useCallback)(function (value) { // bail out if slider isn't interactive if (!isInteractive) return; prev.current = value; value = parseFloat((0, _utils.roundValueToStep)(value, min, oneStep)); value = (0, _utils.clampValue)(value, min, max); setValue(value); }, [oneStep, max, min, setValue, isInteractive]); var actions = (0, _react.useMemo)(function () { return { stepUp: function stepUp(step) { if (step === void 0) { step = oneStep; } var next = isReversed ? value - step : value + step; constrain(next); }, stepDown: function stepDown(step) { if (step === void 0) { step = oneStep; } var next = isReversed ? value + step : value - step; constrain(next); }, reset: function reset() { return constrain(defaultValue || 0); }, stepTo: function stepTo(value) { return constrain(value); } }; }, [constrain, isReversed, value, oneStep, defaultValue]); /** * Keyboard interaction to ensure users can operate * the slider using only their keyboard. */ var onKeyDown = (0, _utils.createOnKeyDown)({ stopPropagation: true, onKey: function onKey() { return setEventSource("keyboard"); }, keyMap: { ArrowRight: function ArrowRight() { return actions.stepUp(); }, ArrowUp: function ArrowUp() { return actions.stepUp(); }, ArrowLeft: function ArrowLeft() { return actions.stepDown(); }, ArrowDown: function ArrowDown() { return actions.stepDown(); }, PageUp: function PageUp() { return actions.stepUp(tenSteps); }, PageDown: function PageDown() { return actions.stepDown(tenSteps); }, Home: function Home() { return constrain(min); }, End: function End() { return constrain(max); } } }); /** * ARIA (Optional): To define a human readable representation of the value, * we allow users pass aria-valuetext. */ var valueText = (_getAriaValueText = getAriaValueText == null ? void 0 : getAriaValueText(value)) != null ? _getAriaValueText : ariaValueText; /** * Measure the dimensions of the thumb so * we can center it within the track properly */ var thumbBoxModel = (0, _hooks.useDimensions)(thumbRef); var thumbRect = (_thumbBoxModel$border = thumbBoxModel == null ? void 0 : thumbBoxModel.borderBox) != null ? _thumbBoxModel$border : { width: 0, height: 0 }; /** * Compute styles for all component parts. */ var thumbStyle = _extends({ position: "absolute", userSelect: "none", touchAction: "none" }, orient({ orientation: orientation, vertical: { bottom: "calc(" + trackPercent + "% - " + thumbRect.height / 2 + "px)" }, horizontal: { left: "calc(" + trackPercent + "% - " + thumbRect.width / 2 + "px)" } })); var rootStyle = _extends({ position: "relative", touchAction: "none", WebkitTapHighlightColor: "rgba(0,0,0,0)", userSelect: "none", outline: 0 }, orient({ orientation: orientation, vertical: { paddingLeft: thumbRect.width / 2, paddingRight: thumbRect.width / 2 }, horizontal: { paddingTop: thumbRect.height / 2, paddingBottom: thumbRect.height / 2 } })); var trackStyle = _extends({ position: "absolute" }, orient({ orientation: orientation, vertical: { left: "50%", transform: "translateX(-50%)", height: "100%" }, horizontal: { top: "50%", transform: "translateY(-50%)", width: "100%" } })); var innerTrackStyle = _extends({}, trackStyle, orient({ orientation: orientation, vertical: isReversed ? { height: 100 - trackPercent + "%", top: 0 } : { height: trackPercent + "%", bottom: 0 }, horizontal: isReversed ? { width: 100 - trackPercent + "%", right: 0 } : { width: trackPercent + "%", left: 0 } })); (0, _hooks.useUpdateEffect)(function () { if (thumbRef.current) { (0, _utils.focus)(thumbRef.current); } }, [value]); (0, _hooks.useUpdateEffect)(function () { var shouldUpdate = !isDragging && eventSource !== "keyboard" && prev.current !== value; if (shouldUpdate) { onChangeEnd == null ? void 0 : onChangeEnd(value); } if (eventSource === "keyboard") { onChangeEnd == null ? void 0 : onChangeEnd(value); } }, [isDragging, onChangeEnd, value, eventSource]); var onMouseDown = (0, _hooks.useEventCallback)(function (event) { /** * Prevent update if it's right-click */ if ((0, _utils.isRightClick)(event)) return; if (!isInteractive || !rootRef.current) return; setDragging.on(); prev.current = value; onChangeStart == null ? void 0 : onChangeStart(value); var doc = (0, _utils.getOwnerDocument)(rootRef.current); var run = function run(event) { var nextValue = getValueFromPointer(event); if (nextValue != null && nextValue !== value) { setEventSource("mouse"); setValue(nextValue); } }; run(event); doc.addEventListener("mousemove", run); var clean = function clean() { doc.removeEventListener("mousemove", run); setDragging.off(); }; doc.addEventListener("mouseup", clean); cleanUpRef.current.mouseup = function () { doc.removeEventListener("mouseup", clean); }; }); var onTouchStart = (0, _hooks.useEventCallback)(function (event) { if (!isInteractive || !rootRef.current) return; // Prevent scrolling for touch events event.preventDefault(); setDragging.on(); prev.current = value; onChangeStart == null ? void 0 : onChangeStart(value); var doc = (0, _utils.getOwnerDocument)(rootRef.current); var run = function run(event) { var nextValue = getValueFromPointer(event); if (nextValue != null && nextValue !== value) { setEventSource("touch"); setValue(nextValue); } }; run(event); doc.addEventListener("touchmove", run); var clean = function clean() { doc.removeEventListener("touchmove", run); setDragging.off(); }; doc.addEventListener("touchend", clean); doc.addEventListener("touchcancel", clean); cleanUpRef.current.touchend = function () { doc.removeEventListener("touchend", clean); }; cleanUpRef.current.touchcancel = function () { doc.removeEventListener("touchcancel", clean); }; }); /** * Remove all event handlers */ var detach = function detach() { Object.values(cleanUpRef.current).forEach(function (cleanup) { cleanup == null ? void 0 : cleanup(); }); cleanUpRef.current = {}; }; /** * Ensure we clean up listeners when slider unmounts */ (0, _react.useEffect)(function () { return function () { return detach(); }; }, []); (0, _hooks.useUpdateEffect)(function () { if (!isDragging) { detach(); } }, [isDragging]); cleanUpRef.current.mousedown = (0, _hooks.useEventListener)("mousedown", onMouseDown, rootRef.current); cleanUpRef.current.touchstart = (0, _hooks.useEventListener)("touchstart", onTouchStart, rootRef.current); var getRootProps = function getRootProps(props, ref) { if (props === void 0) { props = {}; } if (ref === void 0) { ref = null; } return _extends({}, props, htmlProps, { ref: (0, _utils.mergeRefs)(ref, rootRef), tabIndex: -1, "aria-disabled": (0, _utils.ariaAttr)(isDisabled), "data-focused": (0, _utils.dataAttr)(isFocused), style: _extends({}, props.style, rootStyle) }); }; var getTrackProps = function getTrackProps(props, ref) { if (props === void 0) { props = {}; } if (ref === void 0) { ref = null; } return _extends({}, props, { ref: (0, _utils.mergeRefs)(ref, trackRef), id: trackId, "data-disabled": (0, _utils.dataAttr)(isDisabled), style: _extends({}, props.style, trackStyle) }); }; var getInnerTrackProps = function getInnerTrackProps(props, ref) { if (props === void 0) { props = {}; } if (ref === void 0) { ref = null; } return _extends({}, props, { ref: ref, style: _extends({}, props.style, innerTrackStyle) }); }; var getThumbProps = function getThumbProps(props, ref) { if (props === void 0) { props = {}; } if (ref === void 0) { ref = null; } return _extends({}, props, { ref: (0, _utils.mergeRefs)(ref, thumbRef), role: "slider", tabIndex: 0, id: thumbId, "data-active": (0, _utils.dataAttr)(isDragging), "aria-valuetext": valueText, "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": value, "aria-orientation": orientation, "aria-disabled": (0, _utils.ariaAttr)(isDisabled), "aria-readonly": (0, _utils.ariaAttr)(isReadOnly), "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : ariaLabelledBy, style: _extends({}, props.style, thumbStyle), onKeyDown: (0, _utils.callAllHandlers)(props.onKeyDown, onKeyDown), onFocus: (0, _utils.callAllHandlers)(props.onFocus, setFocused.on), onBlur: (0, _utils.callAllHandlers)(props.onBlur, setFocused.off) }); }; var getMarkerProps = function getMarkerProps(props, ref) { if (props === void 0) { props = {}; } if (ref === void 0) { ref = null; } var isInRange = !(props.value < min || props.value > max); var isHighlighted = value >= props.value; var markerPercent = (0, _utils.valueToPercent)(props.value, min, max); var markerStyle = _extends({ position: "absolute", pointerEvents: "none" }, orient({ orientation: orientation, vertical: { bottom: isReversed ? 100 - markerPercent + "%" : markerPercent + "%" }, horizontal: { left: isReversed ? 100 - markerPercent + "%" : markerPercent + "%" } })); return _extends({}, props, { ref: ref, role: "presentation", "aria-hidden": true, "data-disabled": (0, _utils.dataAttr)(isDisabled), "data-invalid": (0, _utils.dataAttr)(!isInRange), "data-highlighted": (0, _utils.dataAttr)(isHighlighted), style: _extends({}, props.style, markerStyle) }); }; var getInputProps = function getInputProps(props, ref) { if (props === void 0) { props = {}; } if (ref === void 0) { ref = null; } return _extends({}, props, { ref: ref, type: "hidden", value: value, name: name }); }; return { state: { value: value, isFocused: isFocused, isDragging: isDragging }, actions: actions, getRootProps: getRootProps, getTrackProps: getTrackProps, getInnerTrackProps: getInnerTrackProps, getThumbProps: getThumbProps, getMarkerProps: getMarkerProps, getInputProps: getInputProps }; } function orient(options) { var orientation = options.orientation, vertical = options.vertical, horizontal = options.horizontal; return orientation === "vertical" ? vertical : horizontal; } /** * The browser <input type="range" /> calculates * the default value of a slider by using mid-point * between the min and the max. * * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/range */ function getDefaultValue(min, max) { return max < min ? min : min + (max - min) / 2; } //# sourceMappingURL=use-slider.js.map