UNPKG

@trail-ui/react

Version:
551 lines (500 loc) 16.4 kB
import { UseCounterProps, mergeRefs, useCallbackRef, useCounter, useEventListener, useSafeLayoutEffect, useUpdateEffect, } from '@trail-ui/hooks'; import { InputDOMAttributes, PropGetter } from '@trail-ui/react-utils'; import { callAllHandlers, dataAttr } from '@trail-ui/shared-utils'; import { useCallback, useMemo, useRef, useState } from 'react'; import { useAttributeObserver } from './use-attr-observer'; import { useSpinner } from './use-spinner'; const FLOATING_POINT_REGEX = /^[Ee0-9+\-.]$/; /** * Determine if a character is a DOM floating point character * @see https://www.w3.org/TR/2012/WD-html-markup-20120329/datatypes.html#common.data.float */ function isFloatingPointNumericCharacter(character: string) { return FLOATING_POINT_REGEX.test(character); } function isValidNumericKeyboardEvent( event: React.KeyboardEvent, isValid: (key: string) => boolean, ) { if (event.key == null) return true; const isModifierKey = event.ctrlKey || event.altKey || event.metaKey; const isSingleCharacterKey = event.key.length === 1; if (!isSingleCharacterKey || isModifierKey) return true; return isValid(event.key); } export interface UseNumberInputProps extends UseCounterProps { /** * If `true`, the input will be focused as you increment * or decrement the value with the stepper * * @default true */ focusInputOnChange?: boolean; /** * This controls the value update when you blur out of the input. * - If `true` and the value is greater than `max`, the value will be reset to `max` * - Else, the value remains the same. * * @default true */ clampValueOnBlur?: boolean; /** * This is used to format the value so that screen readers * can speak out a more human-friendly value. * * It is used to set the `aria-valuetext` property of the input */ getAriaValueText?(value: string | number): string; /** * If `true`, the input will be in readonly mode */ isReadOnly?: boolean; /** * If `true`, the input will have `aria-invalid` set to `true` */ isInvalid?: boolean; /** * Whether the input should be disabled */ isDisabled?: boolean; /** * Whether the input is required */ isRequired?: boolean; /** * The `id` to use for the number input field. */ id?: string; /** * The pattern used to check the <input> element's value against on form submission. * * @default * "[0-9]*(.[0-9]+)?" */ pattern?: React.InputHTMLAttributes<any>['pattern']; /** * Hints at the type of data that might be entered by the user. It also determines * the type of keyboard shown to the user on mobile devices * * @default * "decimal" */ inputMode?: React.InputHTMLAttributes<any>['inputMode']; /** * If `true`, the input's value will change based on mouse wheel */ allowMouseWheel?: boolean; /** * The HTML `name` attribute used for forms */ name?: string; 'aria-describedby'?: string; 'aria-label'?: string; 'aria-labelledby'?: string; onFocus?: React.FocusEventHandler<HTMLInputElement>; onBlur?: React.FocusEventHandler<HTMLInputElement>; onInvalid?: (message: ValidityState, value: string, valueAsNumber: number) => void; /** * Whether the pressed key should be allowed in the input. * The default behavior is to allow DOM floating point characters defined by /^[Ee0-9+\-.]$/ */ isValidCharacter?: (value: string) => boolean; /** * If using a custom display format, this converts the custom format to a format `parseFloat` understands. */ parse?: (value: string) => string; /** * If using a custom display format, this converts the default format to the custom format. */ format?: (value: string | number) => string | number; } type ValidityState = 'rangeUnderflow' | 'rangeOverflow'; type InputSelection = { start: number | null; end: number | null }; /** * React hook that implements the WAI-ARIA Spin Button widget * and used to create numeric input fields. * * It returns prop getters you can use to build your own * custom number inputs. * * @see WAI-ARIA https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/ * @see Docs https://www.chakra-ui.com/useNumberInput * @see WHATWG https://html.spec.whatwg.org/multipage/input.html#number-state-(type=number) */ export function useNumberInput(props: UseNumberInputProps = {}) { const { focusInputOnChange = true, clampValueOnBlur = true, keepWithinRange = true, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER, step: stepProp = 1, isReadOnly, isDisabled, isRequired, isInvalid, pattern = '[0-9]*(.[0-9]+)?', inputMode = 'decimal', allowMouseWheel, id, // onChange: _, // precision, name, 'aria-describedby': ariaDescBy, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, onFocus: onFocusProp, onBlur: onBlurProp, onInvalid: onInvalidProp, getAriaValueText: getAriaValueTextProp, isValidCharacter: isValidCharacterProp, format: formatValue, parse: parseValue, ...htmlProps } = props; const onFocus = useCallbackRef(onFocusProp); const onBlur = useCallbackRef(onBlurProp); const onInvalid = useCallbackRef(onInvalidProp); const isValidCharacter = useCallbackRef(isValidCharacterProp ?? isFloatingPointNumericCharacter); const getAriaValueText = useCallbackRef(getAriaValueTextProp); /** * Leverage the `useCounter` hook since it provides * the functionality to `increment`, `decrement` and `update` * counter values */ const counter = useCounter(props); const { update: updateFn, increment: incrementFn, decrement: decrementFn } = counter; /** * Keep track of the focused state of the input, * so user can this to change the styles of the * `spinners`, maybe :) */ const [isFocused, setFocused] = useState(false); const isInteractive = !(isReadOnly || isDisabled); const inputRef = useRef<HTMLInputElement>(null); const inputSelectionRef = useRef<InputSelection | null>(null); const incrementButtonRef = useRef<HTMLButtonElement>(null); const decrementButtonRef = useRef<HTMLButtonElement>(null); const sanitize = useCallback( (value: string) => value.split('').filter(isValidCharacter).join(''), [isValidCharacter], ); const parse = useCallback((value: string) => parseValue?.(value) ?? value, [parseValue]); const format = useCallback( (value: string | number) => (formatValue?.(value) ?? value).toString(), [formatValue], ); useUpdateEffect(() => { if (counter.valueAsNumber > max) { onInvalid?.('rangeOverflow', format(counter.value), counter.valueAsNumber); } else if (counter.valueAsNumber < min) { onInvalid?.('rangeOverflow', format(counter.value), counter.valueAsNumber); } }, [counter.valueAsNumber, counter.value, format, onInvalid]); /** * Sync state with uncontrolled form libraries like `react-hook-form`. */ useSafeLayoutEffect(() => { if (!inputRef.current) return; const notInSync = inputRef.current.value != counter.value; if (notInSync) { const parsedInput = parse(inputRef.current.value); counter.setValue(sanitize(parsedInput)); } }, [parse, sanitize]); const increment = useCallback( (step = stepProp) => { if (isInteractive) { incrementFn(step); } }, [incrementFn, isInteractive, stepProp], ); const decrement = useCallback( (step = stepProp) => { if (isInteractive) { decrementFn(step); } }, [decrementFn, isInteractive, stepProp], ); /** * Leverage the `useSpinner` hook to spin the input's value * when long press on the up and down buttons. * * This leverages `setInterval` internally */ const spinner = useSpinner(increment, decrement); useAttributeObserver(incrementButtonRef, 'disabled', spinner.stop, spinner.isSpinning); useAttributeObserver(decrementButtonRef, 'disabled', spinner.stop, spinner.isSpinning); /** * The `onChange` handler filters out any character typed * that isn't floating point compatible. */ const onChange = useCallback( (event: React.ChangeEvent<HTMLInputElement>) => { const evt = event.nativeEvent as InputEvent; if (evt.isComposing) return; const parsedInput = parse(event.currentTarget.value); updateFn(sanitize(parsedInput)); inputSelectionRef.current = { start: event.currentTarget.selectionStart, end: event.currentTarget.selectionEnd, }; }, [updateFn, sanitize, parse], ); const _onFocus = useCallback( (event: React.FocusEvent<HTMLInputElement>) => { onFocus?.(event); if (!inputSelectionRef.current) return; /** * restore selection if custom format string replacement moved it to the end */ event.target.selectionStart = inputSelectionRef.current.start ?? event.currentTarget.value?.length; event.currentTarget.selectionEnd = inputSelectionRef.current.end ?? event.currentTarget.selectionStart; }, [onFocus], ); const onKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.nativeEvent.isComposing) return; if (!isValidNumericKeyboardEvent(event, isValidCharacter)) { event.preventDefault(); } /** * Keyboard Accessibility * * We want to increase or decrease the input's value * based on if the user the arrow keys. * * @see https://www.w3.org/TR/wai-aria-practices-1.1/#keyboard-interaction-17 */ const stepFactor = getStepFactor(event) * stepProp; const eventKey = event.key; const keyMap: Record<string, React.KeyboardEventHandler> = { ArrowUp: () => increment(stepFactor), ArrowDown: () => decrement(stepFactor), Home: () => updateFn(min), End: () => updateFn(max), }; const action = keyMap[eventKey]; if (action) { event.preventDefault(); action(event); } }, [isValidCharacter, stepProp, increment, decrement, updateFn, min, max], ); const getStepFactor = <Event extends React.KeyboardEvent | React.WheelEvent | WheelEvent>( event: Event, ) => { let ratio = 1; if (event.metaKey || event.ctrlKey) { ratio = 0.1; } if (event.shiftKey) { ratio = 10; } return ratio; }; /** * If user would like to use a human-readable representation * of the value, rather than the value itself they can pass `getAriaValueText` * * @see https://www.w3.org/TR/wai-aria-practices-1.1/#wai-aria-roles-states-and-properties-18 * @see https://www.w3.org/TR/wai-aria-1.1/#aria-valuetext */ const ariaValueText = useMemo(() => { const text = getAriaValueText?.(counter.value); if (text != null) return text; const defaultText = counter.value.toString(); // empty string is an invalid ARIA attribute value return !defaultText ? undefined : defaultText; }, [counter.value, getAriaValueText]); /** * Function that clamps the input's value on blur */ const validateAndClamp = useCallback(() => { let next = counter.value as string | number; if (counter.value === '') return; const valueStartsWithE = /^[eE]/.test(counter.value.toString()); if (valueStartsWithE) { counter.setValue(''); } else { if (counter.valueAsNumber < min) { next = min; } if (counter.valueAsNumber > max) { next = max; } counter.cast(next); } }, [counter, max, min]); const onInputBlur = useCallback(() => { setFocused(false); if (clampValueOnBlur) { validateAndClamp(); } }, [clampValueOnBlur, setFocused, validateAndClamp]); const focusInput = useCallback(() => { if (focusInputOnChange) { requestAnimationFrame(() => { inputRef.current?.focus(); }); } }, [focusInputOnChange]); const spinUp = useCallback( (event: React.PointerEvent) => { event.preventDefault(); spinner.up(); focusInput(); }, [focusInput, spinner], ); const spinDown = useCallback( (event: React.PointerEvent) => { event.preventDefault(); spinner.down(); focusInput(); }, [focusInput, spinner], ); useEventListener( () => inputRef.current, 'wheel', (event: WheelEvent) => { const doc = inputRef.current?.ownerDocument ?? document; const isInputFocused = doc.activeElement === inputRef.current; if (!allowMouseWheel || !isInputFocused) return; event.preventDefault(); const stepFactor = getStepFactor(event) * stepProp; const direction = Math.sign(event.deltaY); if (direction === -1) { increment(stepFactor); } else if (direction === 1) { decrement(stepFactor); } }, { passive: false }, ); const getIncrementButtonProps: PropGetter = useCallback( (props = {}, ref = null) => { const disabled = isDisabled || (keepWithinRange && counter.isAtMax); return { ...props, ref: mergeRefs(ref, incrementButtonRef), role: 'button', tabIndex: -1, onPointerDown: callAllHandlers(props.onPointerDown, (event) => { if (event.button !== 0 || disabled) return; spinUp(event); }), onPointerLeave: callAllHandlers(props.onPointerLeave, spinner.stop), onPointerUp: callAllHandlers(props.onPointerUp, spinner.stop), disabled, 'aria-disabled': dataAttr(disabled), }; }, [counter.isAtMax, keepWithinRange, spinUp, spinner.stop, isDisabled], ); const getDecrementButtonProps: PropGetter = useCallback( (props = {}, ref = null) => { const disabled = isDisabled || (keepWithinRange && counter.isAtMin); return { ...props, ref: mergeRefs(ref, decrementButtonRef), role: 'button', tabIndex: -1, onPointerDown: callAllHandlers(props.onPointerDown, (event) => { if (event.button !== 0 || disabled) return; spinDown(event); }), onPointerLeave: callAllHandlers(props.onPointerLeave, spinner.stop), onPointerUp: callAllHandlers(props.onPointerUp, spinner.stop), disabled, 'aria-disabled': dataAttr(disabled), }; }, [counter.isAtMin, keepWithinRange, spinDown, spinner.stop, isDisabled], ); const getInputProps: PropGetter<InputDOMAttributes, InputDOMAttributes> = useCallback( (props = {}, ref = null) => ({ name, inputMode, type: 'text', pattern, 'aria-labelledby': ariaLabelledBy, 'aria-label': ariaLabel, 'aria-describedby': ariaDescBy, id, disabled: isDisabled, ...props, readOnly: props.readOnly ?? isReadOnly, 'aria-readonly': props.readOnly ?? isReadOnly, 'aria-required': props.required ?? isRequired, required: props.required ?? isRequired, ref: mergeRefs(inputRef, ref), value: format(counter.value), role: 'spinbutton', 'aria-valuemin': min, 'aria-valuemax': max, 'aria-valuenow': Number.isNaN(counter.valueAsNumber) ? undefined : counter.valueAsNumber, 'aria-invalid': dataAttr(isInvalid ?? counter.isOutOfRange), 'aria-valuetext': ariaValueText, autoComplete: 'off', autoCorrect: 'off', onChange: callAllHandlers(props.onChange, onChange), onKeyDown: callAllHandlers(props.onKeyDown, onKeyDown), onFocus: callAllHandlers(props.onFocus, _onFocus, () => setFocused(true)), onBlur: callAllHandlers(props.onBlur, onBlur, onInputBlur), }), [ name, inputMode, pattern, ariaLabelledBy, ariaLabel, format, ariaDescBy, id, isDisabled, isRequired, isReadOnly, isInvalid, counter.value, counter.valueAsNumber, counter.isOutOfRange, min, max, ariaValueText, onChange, onKeyDown, _onFocus, onBlur, onInputBlur, ], ); return { value: format(counter.value), valueAsNumber: counter.valueAsNumber, isFocused, isDisabled, isReadOnly, getIncrementButtonProps, getDecrementButtonProps, getInputProps, htmlProps, }; } export type UseNumberInputReturn = ReturnType<typeof useNumberInput>;