@bianic-ui/slider
Version:
Accessible slider component for React that implements <input type=range>
592 lines (512 loc) • 17.4 kB
JavaScript
"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