@reusable-ui/range
Version:
A UI for the user defines a numeric value in the specified range.
590 lines (589 loc) • 27.3 kB
JavaScript
// react:
import {
// react:
default as React,
// hooks:
useRef, useMemo, } from 'react';
import {
// checks if a certain css feature is supported by the running browser:
supportsHasPseudoClass, } from '@cssfn/core'; // writes css in javascript
import {
// style sheets:
dynamicStyleSheet, } from '@cssfn/cssfn-react'; // writes css in react hook
// reusable-ui core:
import {
// a set of numeric utility functions:
clamp,
// react helper hooks:
useTriggerRender, useEvent, useMergeEvents, useMergeRefs, useMergeClasses, useMergeStyles, useScheduleTriggerEvent,
// an accessibility management system:
usePropEnabled, usePropReadOnly,
// a capability of UI to capture the mouse/touch event inside & outside the UI itself:
usePointerCapturable, useOrientationable,
// a capability of UI to be focused:
useFocusable,
// adds an interactive feel to a UI:
useInteractable,
// a capability of UI to be clicked:
useClickable, } from '@reusable-ui/core'; // a set of reusable-ui packages which are responsible for building any component
// reusable-ui components:
import { Generic, } from '@reusable-ui/generic'; // a complement component
import { EditableControl, } from '@reusable-ui/editable-control'; // a base component
import { EditableActionControl, } from '@reusable-ui/editable-action-control'; // a base component
// internals:
import {
// defaults:
defaultOrientationableOptions, } from './defaults.js';
import {
// features:
usesRange, } from './features/range.js';
// styles:
export const useRangeStyleSheet = dynamicStyleSheet(() => import(/* webpackPrefetch: true */ './styles/styles.js'), {
id: 'jue5zxlqsc', // a unique salt for SSR support, ensures the server-side & client-side have the same generated class names
lazyCsr: supportsHasPseudoClass(), // dealing with browsers that don't support the :has() selector
});
// handlers:
const handleChangeDummy = (_event) => {
/* nothing to do */
};
const Range = (props) => {
// props:
const {
// refs:
outerRef, elmRef,
// variants:
orientation: _orientation, // remove
// classes:
variantClasses, stateClasses,
// styles:
style,
// accessibilities:
pressed: _pressed, // remove
// still on <EditableControl> element
// autoFocus,
// tabIndex,
// enterKeyHint,
// behaviors:
actionMouses: _actionMouses, // remove
actionTouches: _actionTouches, // remove
actionKeys: _actionKeys, // remove
releaseDelay: _releaseDelay, // remove
// forms:
name, form,
// values:
defaultValue: defaultUncontrollableValueRaw, value: controllableValueRaw, onChange, // forwards to `input[type]`
// validations:
enableValidation, isValid, inheritValidation, validationDeps: validationDepsOverwrite, validDelay, invalidDelay, noValidationDelay, min = 0, max = 100, step: stepRaw = 1,
// components:
trackComponent = React.createElement(EditableControl, null), trackLowerComponent = React.createElement(Generic, null), trackUpperComponent = React.createElement(Generic, null), thumbComponent = React.createElement(EditableActionControl, null), trackRef, trackLowerRef, trackUpperRef, thumbRef, trackClasses, trackLowerClasses, trackUpperClasses, thumbClasses, trackStyle, trackLowerStyle, trackUpperStyle, thumbStyle,
// handlers:
onFocus, onBlur, onMouseEnter, onMouseLeave, onAnimationStart, onAnimationEnd, onMouseDown, onTouchStart, onKeyDown,
// other props:
...restRangeProps } = props;
const step = (stepRaw === 'any') ? 0 : Math.abs(stepRaw);
const isReversedRange = (max < min);
const appendValidationDeps = useEvent((bases) => [
...bases,
// validations:
/*
Since we use <EditableControl> as a wrapper,
and we don't pass the `required` prop to the <EditableControl>,
and the <Range> doesn't support the `required` prop,
we don't need to re-apply that prop here.
*/
min,
max,
step,
]);
const mergedValidationDeps = useEvent((bases) => {
const basesStage2 = appendValidationDeps(bases);
const basesStage3 = validationDepsOverwrite ? validationDepsOverwrite(basesStage2) : basesStage2;
return basesStage3;
});
// styles:
const styles = useRangeStyleSheet();
// variants:
const orientationableVariant = useOrientationable(props, defaultOrientationableOptions);
const isOrientationBlock = orientationableVariant.isOrientationBlock;
const isOrientationVertical = orientationableVariant.isOrientationVertical;
// states:
const focusableState = useFocusable(props);
const interactableState = useInteractable(props, focusableState);
const clickableState = useClickable({
enabled: props.enabled,
inheritEnabled: props.inheritEnabled,
readOnly: props.readOnly,
inheritReadOnly: props.inheritReadOnly,
pressed: props.pressed,
actionMouses: (props.actionMouses !== undefined) ? props.actionMouses : null, // handled manually
actionTouches: (props.actionTouches !== undefined) ? props.actionTouches : null, // handled manually
actionKeys: (props.actionKeys !== undefined) ? props.actionKeys : null, // handled manually
});
// fn props:
const propEnabled = usePropEnabled(props);
const propReadOnly = usePropReadOnly(props);
const propEditable = propEnabled && !propReadOnly;
// capabilities:
const pointerCapturable = usePointerCapturable({
enabled: propEditable,
onPointerCaptureMove(event) {
// conditions:
const track = trackRefInternal.current;
const thumb = thumbRefInternal.current;
if (!track)
return;
if (!thumb)
return;
const style = getComputedStyle(track);
const borderStart = (Number.parseInt(isOrientationVertical ? style.borderTopWidth : style.borderLeftWidth) || 0 /* NaN => 0 */);
const paddingStart = (Number.parseInt(isOrientationVertical ? style.paddingTop : style.paddingLeft) || 0 /* NaN => 0 */);
const paddingEnd = (Number.parseInt(isOrientationVertical ? style.paddingBottom : style.paddingRight) || 0 /* NaN => 0 */);
const thumbSize = (isOrientationVertical ? thumb.offsetHeight : thumb.offsetWidth);
const trackSize = ((isOrientationVertical ? track.clientHeight : track.clientWidth) - paddingStart - paddingEnd - thumbSize);
const rect = track.getBoundingClientRect();
const cursorStart = (isOrientationVertical ? event.clientY : event.clientX) - (isOrientationVertical ? rect.top : rect.left) - borderStart - paddingStart - (thumbSize / 2);
// if ((cursorStart < 0) || (cursorStart > trackSize)) return; // setValueRatio will take care of this
let valueRatio = cursorStart / trackSize;
if (isOrientationVertical || (style.direction === 'rtl'))
valueRatio = (1 - valueRatio); // reverse the ratio from end
changeValue('setValueRatio', valueRatio);
// indicates the <Range> is currently being pressed/touched
switch (event.type) {
case 'mousedown':
clickableState.handleMouseDown({
...event,
nativeEvent: event,
});
break;
case 'touchstart':
clickableState.handleTouchStart({
...event,
nativeEvent: event,
});
break;
} // switch
},
});
// refs:
const rangeRefInternal = useRef(null);
const inputRefInternal = useRef(null);
const trackRefInternal = useRef(null);
const thumbRefInternal = useRef(null);
const mergedRangeRef = useMergeRefs(
// preserves the original `outerRef` from `props`:
outerRef, rangeRefInternal);
const mergedInputRef = useMergeRefs(
// preserves the original `elmRef` from `props`:
elmRef, inputRefInternal);
const mergedTrackRef = useMergeRefs(
// preserves the original `elmRef` from `trackComponent`:
trackComponent.props.elmRef,
// preserves the original `trackRef` from `props`:
trackRef, trackRefInternal);
const mergedTrackLowerRef = useMergeRefs(
// preserves the original `elmRef` from `trackLowerComponent`:
trackLowerComponent.props.elmRef,
// preserves the original `trackLowerRef` from `props`:
trackLowerRef);
const mergedTrackUpperRef = useMergeRefs(
// preserves the original `elmRef` from `trackUpperComponent`:
trackUpperComponent.props.elmRef,
// preserves the original `trackUpperRef` from `props`:
trackUpperRef);
const mergedThumbRef = useMergeRefs(
// preserves the original `elmRef` from `thumbComponent`:
thumbComponent.props.elmRef,
// preserves the original `thumbRef` from `props`:
thumbRef, thumbRefInternal);
// utilities:
const trimValue = useEvent((value) => {
return clamp(min, value, max, step);
});
// fn props:
const controllableValue = trimValue(controllableValueRaw);
const defaultUncontrollableValue = trimValue(defaultUncontrollableValueRaw);
// events:
const scheduleTriggerEvent = useScheduleTriggerEvent();
// Tracks the value changes for both controllable and uncontrollable value:
const valueTrackerRef = useRef(/*initialState: */ controllableValue
??
defaultUncontrollableValue
??
(min + ((max - min) * 0.5)));
// Updates the value tracker when the controllable value changed:
if (controllableValue !== undefined)
valueTrackerRef.current = controllableValue; // controllable component mode: update the source_of_truth on every_re_render -- on every [value] prop changes
// Re-render the component and re-evaluate the value tracker:
const [triggerRender] = useTriggerRender(); // uncontrollable component mode: update the source_of_truth when modified internally by internal component(s)
// Re-evaluate the value tracker (must be placed after "Updates the value tracker"):
const value = valueTrackerRef.current;
const valueRatio = (value - min) / (max - min);
const changeValue = useEvent((action, amount) => {
let newValue = valueTrackerRef.current;
switch (action) {
case 'setValue':
{
newValue = trimValue(amount);
}
break;
case 'setValueRatio':
{
let valueRatio = amount;
// make sure the valueRatio is between 0 & 1:
valueRatio = Math.min(Math.max(valueRatio, 0), 1);
newValue = trimValue(min + ((max - min) * valueRatio));
}
break;
case 'decrease':
{
newValue = trimValue(newValue - ((step || 1) * (isReversedRange ? -1 : 1) * amount));
}
break;
case 'increase':
{
newValue = trimValue(newValue + ((step || 1) * (isReversedRange ? -1 : 1) * amount));
}
break;
} // switch
// trigger `onChange` event if the newValue is different from the current value:
if (valueTrackerRef.current !== newValue) {
const oldValue = valueTrackerRef.current; // react *hack* get_prev_value *before* modifying the value
if (controllableValue === undefined) { // uncontrollable component mode: update the source_of_truth when modified internally by internal component(s)
valueTrackerRef.current = newValue; // update
triggerRender(); // re-render the component and re-evaluate the value tracker
}
// else {
// // for controllable component mode: the update of value prop and the source_of_truth are decided by <Parent> component (on every_re_render).
// }
const inputElm = inputRefInternal.current;
if (inputElm) {
// react *hack*: trigger `onChange` event:
scheduleTriggerEvent(() => {
inputElm.valueAsNumber = newValue; // react *hack* set_value *before* firing `input` event
inputElm._valueTracker?.setValue(`${oldValue}`); // react *hack* in order to React *see* the changes when `input` event fired
// fire `input` native event to trigger `onChange` synthetic event:
inputElm.dispatchEvent(new Event('input', { bubbles: true, cancelable: false, composed: true }));
});
} // if
} // if
});
// classes:
const mergedVariantClasses = useMergeClasses(
// preserves the original `variantClasses` from `props`:
variantClasses,
// variants:
orientationableVariant.class);
const mergedStateClasses = useMergeClasses(
// preserves the original `stateClasses` from `props`:
stateClasses,
// states:
focusableState.class, interactableState.class);
const mergedTrackClasses = useMergeClasses(
// preserves the original `classes` from `trackComponent`:
trackComponent.props.classes,
// preserves the original `trackClasses` from `props`:
trackClasses,
// identifiers:
'track');
const mergedTrackLowerClasses = useMergeClasses(
// preserves the original `classes` from `trackLowerComponent`:
trackLowerComponent.props.classes,
// preserves the original `trackLowerClasses` from `props`:
trackLowerClasses,
// identifiers:
'tracklower');
const mergedTrackUpperClasses = useMergeClasses(
// preserves the original `classes` from `trackUpperComponent`:
trackUpperComponent.props.classes,
// preserves the original `trackUpperClasses` from `props`:
trackUpperClasses,
// identifiers:
'trackupper');
const mergedThumbClasses = useMergeClasses(
// preserves the original `classes` from `thumbComponent`:
thumbComponent.props.classes,
// preserves the original `thumbClasses` from `props`:
thumbClasses,
// identifiers:
'thumb');
// features:
const { rangeVars } = usesRange();
// styles:
const valueRatioStyle = useMemo(() => ({
// values:
[rangeVars.valueRatio
.slice(4, -1) // fix: var(--customProp) => --customProp
]: valueRatio,
}), [rangeVars.valueRatio, valueRatio]);
const mergedStyle = useMergeStyles(
// values:
valueRatioStyle,
// preserves the original `style` (can overwrite the `valueRatioStyle`) from `props`:
style);
const mergedTrackStyle = useMergeStyles(
// preserves the original `trackStyle` from `props`:
trackStyle,
// preserves the original `style` from `trackComponent` (can overwrite the `trackStyle`):
trackComponent.props.style);
const mergedTrackLowerStyle = useMergeStyles(
// preserves the original `trackLowerStyle` from `props`:
trackLowerStyle,
// preserves the original `style` from `trackLowerComponent` (can overwrite the `trackLowerStyle`):
trackLowerComponent.props.style);
const mergedTrackUpperStyle = useMergeStyles(
// preserves the original `trackUpperStyle` from `props`:
trackUpperStyle,
// preserves the original `style` from `trackUpperComponent` (can overwrite the `trackUpperStyle`):
trackUpperComponent.props.style);
const mergedThumbStyle = useMergeStyles(
// preserves the original `thumbStyle` from `props`:
thumbStyle,
// preserves the original `style` from `thumbComponent` (can overwrite the `thumbStyle`):
thumbComponent.props.style);
// handlers:
const handleFocus = useMergeEvents(
// preserves the original `onFocus` from `props`:
onFocus,
// states:
focusableState.handleFocus);
const handleBlur = useMergeEvents(
// preserves the original `onBlur` from `props`:
onBlur,
// states:
focusableState.handleBlur);
const handleMouseEnter = useMergeEvents(
// preserves the original `onMouseEnter` from `props`:
onMouseEnter,
// states:
interactableState.handleMouseEnter);
const handleMouseLeave = useMergeEvents(
// preserves the original `onMouseLeave` from `props`:
onMouseLeave,
// states:
interactableState.handleMouseLeave);
const handleAnimationStart = useMergeEvents(
// preserves the original `onAnimationStart` from `props`:
onAnimationStart,
// states:
focusableState.handleAnimationStart, interactableState.handleAnimationStart, clickableState.handleAnimationStart);
const handleAnimationEnd = useMergeEvents(
// preserves the original `onAnimationEnd` from `props`:
onAnimationEnd,
// states:
focusableState.handleAnimationEnd, interactableState.handleAnimationEnd, clickableState.handleAnimationEnd);
const handleKeyboardSlide = useEvent((event) => {
// conditions:
if (!propEditable)
return; // control is disabled or readOnly => no response required
if (event.defaultPrevented)
return; // the event was already handled by user => nothing to do
/* note: the `code` may `undefined` on autoComplete */
const keyCode = event.code?.toLowerCase();
if (!keyCode)
return; // ignores [unidentified] key
if ((() => {
const isRtl = (getComputedStyle(event.currentTarget).direction === 'rtl');
if ((keyCode === 'pagedown'))
changeValue('decrease', 1);
else if ((keyCode === 'pageup'))
changeValue('increase', 1);
else if ((keyCode === 'home'))
changeValue('setValue', min);
else if ((keyCode === 'end'))
changeValue('setValue', max);
else if (isOrientationVertical && (keyCode === 'arrowdown'))
changeValue('decrease', 1);
else if (isOrientationVertical && (keyCode === 'arrowup'))
changeValue('increase', 1);
else if (!isOrientationVertical && !isRtl && (keyCode === 'arrowleft'))
changeValue('decrease', 1);
else if (!isOrientationVertical && !isRtl && (keyCode === 'arrowright'))
changeValue('increase', 1);
else if (!isOrientationVertical && isRtl && (keyCode === 'arrowright'))
changeValue('decrease', 1);
else if (!isOrientationVertical && isRtl && (keyCode === 'arrowleft'))
changeValue('increase', 1);
else
return false; // not handled
return true; // handled
})()) {
clickableState.handleKeyDown(event); // indicates the <Range> is currently being key pressed
event.preventDefault(); // prevents the whole page from scrolling when the user press the [up],[down],[left],[right],[pg up],[pg down],[home],[end]
} // if
});
const handleMouseDown = useMergeEvents(
// preserves the original `onMouseDown` from `props`:
onMouseDown,
// capabilities:
pointerCapturable.handleMouseDown);
const handleTouchStart = useMergeEvents(
// preserves the original `onTouchStart` from `props`:
onTouchStart,
// capabilities:
pointerCapturable.handleTouchStart);
const handleKeyDown = useMergeEvents(
// preserves the original `onKeyDown` from `props`:
onKeyDown,
// range handlers:
handleKeyboardSlide, // update the keyboard arrow keys
// states:
focusableState.handleKeyDown);
const handleChange = useMergeEvents(
// preserves the original `onChange` from `props`:
onChange,
// dummy:
handleChangeDummy);
// default props:
const {
// semantics:
tag = 'div', role = 'slider', 'aria-orientation': ariaOrientation = orientationableVariant['aria-orientation'], 'aria-valuenow': ariaValueNow = value, 'aria-valuemin': ariaValueMin = (isReversedRange ? max : min), 'aria-valuemax': ariaValueMax = (isReversedRange ? min : max),
// variants:
nude = true, theme = 'primary', mild = false,
// classes:
mainClass = styles.main,
// other props:
...restEditableControlProps } = restRangeProps;
const mildAlternate = !mild;
const {
// variants:
theme: thumbComponentTheme = theme, mild: thumbComponentMild = mildAlternate,
// accessibilities:
inheritEnabled: thumbComponentInheritEnabled = true, inheritReadOnly: thumbComponentInheritReadOnly = true, inheritActive: thumbComponentInheritActive = true, focused: thumbComponentFocused = focusableState.focused, // if the <Range> got focus => the <Thumb> has focus indicator too
tabIndex: thumbComponentTabIndex = -1, // focus on the whole <Range>, not the <Thumb>
// states:
arrived: thumbComponentArrived = interactableState.arrived, pressed: thumbComponentPressed = clickableState.pressed,
// validations:
enableValidation: thumbComponentEnableValidation = enableValidation, isValid: thumbComponentIsValid = isValid, inheritValidation: thumbComponentInheritValidation = inheritValidation, validDelay: thumbComponentValidDelay = validDelay, invalidDelay: thumbComponentInvalidDelay = invalidDelay, noValidationDelay: thumbComponentNoValidationDelay = noValidationDelay, } = thumbComponent.props;
const trackLower = React.cloneElement(trackLowerComponent,
// props:
{
// refs:
elmRef: mergedTrackLowerRef,
// classes:
classes: mergedTrackLowerClasses,
// styles:
style: mergedTrackLowerStyle,
});
const trackUpper = React.cloneElement(trackUpperComponent,
// props:
{
// refs:
elmRef: mergedTrackUpperRef,
// classes:
classes: mergedTrackUpperClasses,
// styles:
style: mergedTrackUpperStyle,
});
const thumb = React.cloneElement(thumbComponent,
// props:
{
// refs:
elmRef: mergedThumbRef,
// variants:
theme: thumbComponentTheme,
mild: thumbComponentMild,
// classes:
classes: mergedThumbClasses,
// styles:
style: mergedThumbStyle,
// accessibilities:
inheritEnabled: thumbComponentInheritEnabled,
inheritReadOnly: thumbComponentInheritReadOnly,
inheritActive: thumbComponentInheritActive,
focused: thumbComponentFocused,
tabIndex: thumbComponentTabIndex,
// states:
arrived: thumbComponentArrived,
pressed: thumbComponentPressed,
// validations:
enableValidation: thumbComponentEnableValidation,
isValid: thumbComponentIsValid,
inheritValidation: thumbComponentInheritValidation,
validDelay: thumbComponentValidDelay,
invalidDelay: thumbComponentInvalidDelay,
noValidationDelay: thumbComponentNoValidationDelay,
});
const {
// variants:
mild: trackComponentMild = mild,
// accessibilities:
inheritEnabled: trackComponentInheritEnabled = true, inheritReadOnly: trackComponentInheritReadOnly = true, inheritActive: trackComponentInheritActive = true, tabIndex: trackComponentTabIndex = -1, // focus on the whole <Range>, not the <Track>
// states:
arrived: trackComponentArrived = interactableState.arrived,
// validations:
enableValidation: trackComponentEnableValidation = enableValidation, isValid: trackComponentIsValid = isValid, inheritValidation: trackComponentInheritValidation = inheritValidation, validDelay: trackComponentValidDelay = validDelay, invalidDelay: trackComponentInvalidDelay = invalidDelay, noValidationDelay: trackComponentNoValidationDelay = noValidationDelay,
// children:
children: trackComponentChildren = React.createElement(React.Fragment, null,
isOrientationBlock ? trackUpper : trackLower,
thumb,
isOrientationBlock ? trackLower : trackUpper), } = trackComponent.props;
const track = React.cloneElement(trackComponent,
// props:
{
// refs:
elmRef: mergedTrackRef,
// variants:
mild: trackComponentMild,
// classes:
classes: mergedTrackClasses,
// styles:
style: mergedTrackStyle,
// accessibilities:
inheritEnabled: trackComponentInheritEnabled,
inheritReadOnly: trackComponentInheritReadOnly,
inheritActive: trackComponentInheritActive,
tabIndex: trackComponentTabIndex,
// states:
arrived: trackComponentArrived,
// validations:
enableValidation: trackComponentEnableValidation,
isValid: trackComponentIsValid,
inheritValidation: trackComponentInheritValidation,
validDelay: trackComponentValidDelay,
invalidDelay: trackComponentInvalidDelay,
noValidationDelay: trackComponentNoValidationDelay,
},
// children:
trackComponentChildren);
// jsx:
return (React.createElement(EditableControl, { ...restEditableControlProps,
// refs:
outerRef: mergedRangeRef,
// semantics:
tag: tag, role: role, "aria-orientation": ariaOrientation, "aria-valuenow": ariaValueNow, "aria-valuemin": ariaValueMin, "aria-valuemax": ariaValueMax,
// variants:
nude: nude, theme: theme, mild: mild,
// classes:
mainClass: mainClass, variantClasses: mergedVariantClasses, stateClasses: mergedStateClasses,
// styles:
style: mergedStyle,
// validations:
enableValidation: enableValidation, isValid: isValid, inheritValidation: inheritValidation, validationDeps: mergedValidationDeps, validDelay: validDelay, invalidDelay: invalidDelay, noValidationDelay: noValidationDelay,
// handlers:
onFocus: handleFocus, onBlur: handleBlur, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onAnimationStart: handleAnimationStart, onAnimationEnd: handleAnimationEnd, onMouseDown: handleMouseDown,
// // onMouseMove = {handleMouseMove }
onTouchStart: handleTouchStart,
// // onTouchMove = {handleTouchMove }
onKeyDown: handleKeyDown },
React.createElement("input", {
// refs:
ref: mergedInputRef,
// accessibilities:
// still on <EditableControl> element
// {...{
// autoFocus, // the input is hidden => not focusable
// tabIndex, // the input is hidden => not focusable
// enterKeyHint, // not supported
// }}
disabled: !propEnabled, readOnly: propReadOnly, name,
form,
// defaultValue : defaultUncontrollableValue, // fully controllable, no defaultValue
value: value, // fully controllable
onChange: handleChange, min: isReversedRange ? max : min,
max: isReversedRange ? min : max,
step: step, type: 'range' }),
track));
};
export { Range, // named export for readibility
Range as default, // default export to support React.lazy
};