UNPKG

@reusable-ui/range

Version:

A UI for the user defines a numeric value in the specified range.

590 lines (589 loc) 27.3 kB
// 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 };