import { HTMLAttributes, KeyboardEvent, useCallback, useEffect, useRef } from 'react' import { PressProps } from '../../interactions/press' const STEP_DELAY = 400 const STEP_TIMEOUT = 70 export interface UseSpinButtonProps { min?: number max?: number value?: number textValue?: string disabled?: boolean readOnly?: boolean required?: boolean onIncrement?: () => void onExtraIncrement?: () => void onDecrement?: () => void onExtraDecrement?: () => void onIncrementToMax?: () => void onDecrementToMin?: () => void } export interface UseSpinButtonResult { spinButtonProps: HTMLAttributes incrementButtonProps: PressProps decrementButtonProps: PressProps } export function useSpinButton( props: UseSpinButtonProps, ): UseSpinButtonResult { const { value = NaN, min = NaN, max = NaN, textValue = Number.isFinite(value) ? value.toString() : '', disabled, readOnly, required, onIncrement, onExtraIncrement, onDecrement, onExtraDecrement, onDecrementToMin, onIncrementToMax, } = props const isInteractive = !(readOnly || disabled) const timerRef = useRef() const onKeyDown = useCallback( (event: KeyboardEvent) => { if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { return } const handlers: Record void) | undefined> = { ArrowUp: onIncrement, // fallback to increment PageUp: onExtraIncrement ?? onIncrement, ArrowDown: onDecrement, // fallback to decrement PageDown: onExtraDecrement ?? onDecrement, End: onIncrementToMax, Home: onDecrementToMin, } const handler = handlers[event.key] if (handler) { event.preventDefault() handler() } }, [ onIncrement, onExtraIncrement, onIncrementToMax, onDecrement, onExtraDecrement, onDecrementToMin, ], ) const resetTimer = useCallback(() => clearTimeout(timerRef.current), []) const pressStartHandler = useCallback((callback?: () => void) => { function repeat(delay: number) { callback?.() timerRef.current = window.setTimeout(repeat, delay, STEP_TIMEOUT) } repeat(STEP_DELAY) }, []) const onIncrementPressStart = useCallback(() => { pressStartHandler(onIncrement) }, [pressStartHandler, onIncrement]) const onDecrementPressStart = useCallback(() => { pressStartHandler(onDecrement) }, [pressStartHandler, onDecrement]) useEffect(() => resetTimer, [resetTimer]) const spinButtonProps: HTMLAttributes = { role: 'spinbutton', 'aria-valuenow': Number.isFinite(value) ? value : undefined, // TODO: localize message 'aria-valuetext': textValue === '' ? 'Empty' : textValue, 'aria-valuemin': Number.isFinite(min) ? min : undefined, 'aria-valuemax': Number.isFinite(max) ? max : undefined, 'aria-disabled': disabled || undefined, 'aria-readonly': readOnly || undefined, 'aria-required': required || undefined, } if (isInteractive) { spinButtonProps.onKeyDown = onKeyDown } return { spinButtonProps, incrementButtonProps: { disabled: !isInteractive, onPressStart: onIncrementPressStart, onPressEnd: resetTimer, }, decrementButtonProps: { disabled: !isInteractive, onPressStart: onDecrementPressStart, onPressEnd: resetTimer, }, } }