83 lines
2.7 KiB
TypeScript
83 lines
2.7 KiB
TypeScript
import { useEffect, useRef } from 'react'
|
|
import { normalizeFullWidthDigits } from '../../utils/stringUtils.js'
|
|
|
|
// Delay before accepting a digit as a response, to prevent accidental
|
|
// submissions when users start messages with numbers (e.g., numbered lists).
|
|
// Short enough to feel instant for intentional presses, long enough to
|
|
// cancel when the user types more characters.
|
|
const DEFAULT_DEBOUNCE_MS = 400
|
|
|
|
/**
|
|
* Detects when the user types a single valid digit into the prompt input,
|
|
* debounces to avoid accidental submissions (e.g., "1. First item"),
|
|
* trims the digit from the input, and fires a callback.
|
|
*
|
|
* Used by survey components that accept numeric responses typed directly
|
|
* into the main prompt input.
|
|
*/
|
|
export function useDebouncedDigitInput<T extends string = string>({
|
|
inputValue,
|
|
setInputValue,
|
|
isValidDigit,
|
|
onDigit,
|
|
enabled = true,
|
|
once = false,
|
|
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
}: {
|
|
inputValue: string
|
|
setInputValue: (value: string) => void
|
|
isValidDigit: (char: string) => char is T
|
|
onDigit: (digit: T) => void
|
|
enabled?: boolean
|
|
once?: boolean
|
|
debounceMs?: number
|
|
}): void {
|
|
const initialInputValue = useRef(inputValue)
|
|
const hasTriggeredRef = useRef(false)
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
// Latest-ref pattern so callers can pass inline callbacks without causing
|
|
// the effect to re-run (which would reset the debounce timer every render).
|
|
const callbacksRef = useRef({ setInputValue, isValidDigit, onDigit })
|
|
callbacksRef.current = { setInputValue, isValidDigit, onDigit }
|
|
|
|
useEffect(() => {
|
|
if (!enabled || (once && hasTriggeredRef.current)) {
|
|
return
|
|
}
|
|
|
|
if (debounceRef.current !== null) {
|
|
clearTimeout(debounceRef.current)
|
|
debounceRef.current = null
|
|
}
|
|
|
|
if (inputValue !== initialInputValue.current) {
|
|
const lastChar = normalizeFullWidthDigits(inputValue.slice(-1))
|
|
if (callbacksRef.current.isValidDigit(lastChar)) {
|
|
const trimmed = inputValue.slice(0, -1)
|
|
debounceRef.current = setTimeout(
|
|
(debounceRef, hasTriggeredRef, callbacksRef, trimmed, lastChar) => {
|
|
debounceRef.current = null
|
|
hasTriggeredRef.current = true
|
|
callbacksRef.current.setInputValue(trimmed)
|
|
callbacksRef.current.onDigit(lastChar)
|
|
},
|
|
debounceMs,
|
|
debounceRef,
|
|
hasTriggeredRef,
|
|
callbacksRef,
|
|
trimmed,
|
|
lastChar,
|
|
)
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
if (debounceRef.current !== null) {
|
|
clearTimeout(debounceRef.current)
|
|
debounceRef.current = null
|
|
}
|
|
}
|
|
}, [inputValue, enabled, once, debounceMs])
|
|
}
|