/ components / FeedbackSurvey / useDebouncedDigitInput.ts
useDebouncedDigitInput.ts
 1  import { useEffect, useRef } from 'react'
 2  import { normalizeFullWidthDigits } from '../../utils/stringUtils.js'
 3  
 4  // Delay before accepting a digit as a response, to prevent accidental
 5  // submissions when users start messages with numbers (e.g., numbered lists).
 6  // Short enough to feel instant for intentional presses, long enough to
 7  // cancel when the user types more characters.
 8  const DEFAULT_DEBOUNCE_MS = 400
 9  
10  /**
11   * Detects when the user types a single valid digit into the prompt input,
12   * debounces to avoid accidental submissions (e.g., "1. First item"),
13   * trims the digit from the input, and fires a callback.
14   *
15   * Used by survey components that accept numeric responses typed directly
16   * into the main prompt input.
17   */
18  export function useDebouncedDigitInput<T extends string = string>({
19    inputValue,
20    setInputValue,
21    isValidDigit,
22    onDigit,
23    enabled = true,
24    once = false,
25    debounceMs = DEFAULT_DEBOUNCE_MS,
26  }: {
27    inputValue: string
28    setInputValue: (value: string) => void
29    isValidDigit: (char: string) => char is T
30    onDigit: (digit: T) => void
31    enabled?: boolean
32    once?: boolean
33    debounceMs?: number
34  }): void {
35    const initialInputValue = useRef(inputValue)
36    const hasTriggeredRef = useRef(false)
37    const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
38  
39    // Latest-ref pattern so callers can pass inline callbacks without causing
40    // the effect to re-run (which would reset the debounce timer every render).
41    const callbacksRef = useRef({ setInputValue, isValidDigit, onDigit })
42    callbacksRef.current = { setInputValue, isValidDigit, onDigit }
43  
44    useEffect(() => {
45      if (!enabled || (once && hasTriggeredRef.current)) {
46        return
47      }
48  
49      if (debounceRef.current !== null) {
50        clearTimeout(debounceRef.current)
51        debounceRef.current = null
52      }
53  
54      if (inputValue !== initialInputValue.current) {
55        const lastChar = normalizeFullWidthDigits(inputValue.slice(-1))
56        if (callbacksRef.current.isValidDigit(lastChar)) {
57          const trimmed = inputValue.slice(0, -1)
58          debounceRef.current = setTimeout(
59            (debounceRef, hasTriggeredRef, callbacksRef, trimmed, lastChar) => {
60              debounceRef.current = null
61              hasTriggeredRef.current = true
62              callbacksRef.current.setInputValue(trimmed)
63              callbacksRef.current.onDigit(lastChar)
64            },
65            debounceMs,
66            debounceRef,
67            hasTriggeredRef,
68            callbacksRef,
69            trimmed,
70            lastChar,
71          )
72        }
73      }
74  
75      return () => {
76        if (debounceRef.current !== null) {
77          clearTimeout(debounceRef.current)
78          debounceRef.current = null
79        }
80      }
81    }, [inputValue, enabled, once, debounceMs])
82  }