11'use client'
22
3- import { memo , useEffect , useRef , useState } from 'react'
3+ import { memo , useCallback , useEffect , useRef , useState } from 'react'
44import { motion } from 'framer-motion'
55import { useTheme } from 'next-themes'
66import { Minus , Plus } from 'lucide-react'
7+ import {
8+ STEPPER_HOLD_REPEAT_DELAY_MS ,
9+ STEPPER_HOLD_REPEAT_INTERVAL_MS ,
10+ } from '@/components/ui/constants'
11+
12+ type StepperHoldAction = 'increment' | 'decrement'
13+
14+ interface HoldToRepeatCallbacks {
15+ onIncrement : ( ) => void
16+ onDecrement : ( ) => void
17+ }
18+
19+ interface StopHoldAtBoundaryOptions {
20+ disabled : boolean
21+ isHolding : StepperHoldAction | null
22+ max : number
23+ min : number
24+ stopHold : ( ) => void
25+ value : number
26+ }
727
828/**
929 * Props for GlassNumberStepper component.
@@ -29,31 +49,31 @@ interface GlassNumberStepperProps {
2949}
3050
3151/**
32- * Custom hook for hold-to-repeat behavior.
33- * Waits 300ms, then repeats every 100ms.
34- * Automatically cleans up timers on unmount.
52+ * Runs the latest +/- callback while a stepper button is held by GlassNumberStepper.
53+ * @param callbacks - Latest increment and decrement handlers from the rendered stepper.
54+ * @returns Hold state plus start/stop handlers for pointer, blur, and boundary cleanup.
55+ * @example
56+ * const hold = useHoldToRepeat({ onIncrement: addOne, onDecrement: subtractOne })
3557 */
36- function useHoldToRepeat ( ) {
37- const [ isHolding , setIsHolding ] = useState < 'increment' | 'decrement' | null > (
38- null ,
39- )
40- const holdTimeoutRef = useRef < NodeJS . Timeout | null > ( null )
41- const holdIntervalRef = useRef < NodeJS . Timeout | null > ( null )
58+ function useHoldToRepeat ( { onIncrement , onDecrement } : HoldToRepeatCallbacks ) {
59+ const [ isHolding , setIsHolding ] = useState < StepperHoldAction | null > ( null )
60+ const incrementCallbackRef = useRef ( onIncrement )
61+ const decrementCallbackRef = useRef ( onDecrement )
62+ const holdTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
63+ const holdIntervalRef = useRef < ReturnType < typeof setInterval > | null > ( null )
4264
43- const startHold = (
44- action : 'increment' | 'decrement' ,
45- callback : ( ) => void ,
46- ) => {
47- setIsHolding ( action )
48- callback ( )
49-
50- holdTimeoutRef . current = setTimeout ( ( ) => {
51- holdIntervalRef . current = setInterval ( callback , 100 )
52- } , 300 )
53- }
65+ useEffect ( ( ) => {
66+ incrementCallbackRef . current = onIncrement
67+ decrementCallbackRef . current = onDecrement
68+ } , [ onDecrement , onIncrement ] )
5469
55- const stopHold = ( ) => {
56- setIsHolding ( null )
70+ /**
71+ * Clears queued repeat timers when a hold ends, unmounts, or hits a limit.
72+ * @returns Nothing; it only clears browser timer handles stored in refs.
73+ * @example
74+ * clearHoldTimers()
75+ */
76+ const clearHoldTimers = ( ) => {
5777 if ( holdTimeoutRef . current ) {
5878 clearTimeout ( holdTimeoutRef . current )
5979 holdTimeoutRef . current = null
@@ -64,17 +84,93 @@ function useHoldToRepeat() {
6484 }
6585 }
6686
87+ /**
88+ * Invokes the current action callback so repeat ticks use the newest prop value.
89+ * @param action - Which side of the stepper is being held.
90+ * @returns Nothing; the callback owns whether the value actually changes.
91+ * @example
92+ * runHoldAction('increment')
93+ */
94+ const runHoldAction = ( action : StepperHoldAction ) => {
95+ if ( action === 'increment' ) {
96+ incrementCallbackRef . current ( )
97+ return
98+ }
99+
100+ decrementCallbackRef . current ( )
101+ }
102+
103+ /**
104+ * Starts a hold from pointer down with one immediate change, then repeats after a delay.
105+ * @param action - Which side of the stepper started the hold.
106+ * @returns Nothing; timers are stored for stopHold and unmount cleanup.
107+ * @example
108+ * startHold('decrement')
109+ */
110+ const startHold = ( action : StepperHoldAction ) => {
111+ clearHoldTimers ( )
112+
113+ setIsHolding ( action )
114+ runHoldAction ( action )
115+
116+ holdTimeoutRef . current = setTimeout ( ( ) => {
117+ holdIntervalRef . current = setInterval (
118+ ( ) => runHoldAction ( action ) ,
119+ STEPPER_HOLD_REPEAT_INTERVAL_MS ,
120+ )
121+ } , STEPPER_HOLD_REPEAT_DELAY_MS )
122+ }
123+
124+ /**
125+ * Stops a hold when the user releases, leaves, cancels, blurs, or hits a limit.
126+ * @returns Nothing; it resets visual hold state and clears repeat timers.
127+ * @example
128+ * stopHold()
129+ */
130+ const stopHold = ( ) => {
131+ setIsHolding ( null )
132+ clearHoldTimers ( )
133+ }
134+
67135 // Cleanup timers on unmount to prevent memory leaks
68136 useEffect ( ( ) => {
69137 return ( ) => {
70- if ( holdTimeoutRef . current ) clearTimeout ( holdTimeoutRef . current )
71- if ( holdIntervalRef . current ) clearInterval ( holdIntervalRef . current )
138+ clearHoldTimers ( )
72139 }
73140 } , [ ] )
74141
75142 return { isHolding, startHold, stopHold }
76143}
77144
145+ /**
146+ * Stops an active hold when the next repeat would be blocked by disabled state or bounds.
147+ * @param options - Current value, range, disabled state, hold action, and stop callback.
148+ * @returns Nothing; it only stops active hold timers after render.
149+ * @example
150+ * useStopHoldAtBoundary({ disabled: false, isHolding: 'increment', max: 59, min: 0, stopHold, value: 59 })
151+ */
152+ function useStopHoldAtBoundary ( {
153+ disabled,
154+ isHolding,
155+ max,
156+ min,
157+ stopHold,
158+ value,
159+ } : StopHoldAtBoundaryOptions ) {
160+ useEffect ( ( ) => {
161+ if ( ! isHolding ) return
162+
163+ // Stop once limits disable the pressed button; disabled buttons may not fire pointerup.
164+ if (
165+ disabled ||
166+ ( isHolding === 'increment' && value >= max ) ||
167+ ( isHolding === 'decrement' && value <= min )
168+ ) {
169+ stopHold ( )
170+ }
171+ } , [ disabled , isHolding , max , min , stopHold , value ] )
172+ }
173+
78174/**
79175 * GlassNumberStepper - Theme-aware numeric stepper with +/- buttons
80176 *
@@ -109,29 +205,81 @@ export const GlassNumberStepper = memo(function GlassNumberStepper({
109205} : GlassNumberStepperProps ) {
110206 const { resolvedTheme } = useTheme ( )
111207 const isLiquidGlass = resolvedTheme ?. startsWith ( 'liquid-glass' ) ?? false
112- const { startHold, stopHold } = useHoldToRepeat ( )
113208
114209 /**
115210 * Clamp value within min/max bounds.
116- * @param v - Value to clamp
117- * @returns Clamped value
211+ * @param candidateValue - Value requested by input, keyboard, or stepper button.
212+ * @returns The candidate value forced into the component's allowed range.
213+ * @example
214+ * clamp(61) // => max when max is lower than 61
118215 */
119- const clamp = ( v : number ) : number => Math . max ( min , Math . min ( max , v ) )
216+ const clamp = useCallback (
217+ ( candidateValue : number ) : number =>
218+ Math . max ( min , Math . min ( max , candidateValue ) ) ,
219+ [ max , min ] ,
220+ )
120221
121- const increment = ( ) => {
222+ /**
223+ * Increases the value by one step when the plus button, arrow key, or hold repeat fires.
224+ * @returns Nothing; emits onChange only when the clamped value differs.
225+ * @example
226+ * increment()
227+ */
228+ const increment = useCallback ( ( ) => {
122229 if ( disabled ) return
123230 const newValue = clamp ( value + step )
124231 if ( newValue !== value ) {
125232 onChange ( newValue )
126233 }
127- }
234+ } , [ clamp , disabled , onChange , step , value ] )
128235
129- const decrement = ( ) => {
236+ /**
237+ * Decreases the value by one step when the minus button, arrow key, or hold repeat fires.
238+ * @returns Nothing; emits onChange only when the clamped value differs.
239+ * @example
240+ * decrement()
241+ */
242+ const decrement = useCallback ( ( ) => {
130243 if ( disabled ) return
131244 const newValue = clamp ( value - step )
132245 if ( newValue !== value ) {
133246 onChange ( newValue )
134247 }
248+ } , [ clamp , disabled , onChange , step , value ] )
249+
250+ const { isHolding, startHold, stopHold } = useHoldToRepeat ( {
251+ onDecrement : decrement ,
252+ onIncrement : increment ,
253+ } )
254+
255+ useStopHoldAtBoundary ( { disabled, isHolding, max, min, stopHold, value } )
256+
257+ /**
258+ * Starts decrement hold only for primary pointer presses so alternate clicks never mutate value.
259+ * @param event - Pointer down event from the minus button.
260+ * @returns Nothing; non-primary pointers are ignored before hold timers start.
261+ * @example
262+ * handleDecrementPointerDown(primaryPointerEvent)
263+ */
264+ const handleDecrementPointerDown = (
265+ event : React . PointerEvent < HTMLButtonElement > ,
266+ ) => {
267+ if ( ! event . isPrimary || event . button !== 0 ) return
268+ startHold ( 'decrement' )
269+ }
270+
271+ /**
272+ * Starts increment hold only for primary pointer presses so alternate clicks never mutate value.
273+ * @param event - Pointer down event from the plus button.
274+ * @returns Nothing; non-primary pointers are ignored before hold timers start.
275+ * @example
276+ * handleIncrementPointerDown(primaryPointerEvent)
277+ */
278+ const handleIncrementPointerDown = (
279+ event : React . PointerEvent < HTMLButtonElement > ,
280+ ) => {
281+ if ( ! event . isPrimary || event . button !== 0 ) return
282+ startHold ( 'increment' )
135283 }
136284
137285 const handleInputChange = ( e : React . ChangeEvent < HTMLInputElement > ) => {
@@ -173,9 +321,11 @@ export const GlassNumberStepper = memo(function GlassNumberStepper({
173321 { /* Decrement Button */ }
174322 < motion . button
175323 type = "button"
176- onPointerDown = { ( ) => startHold ( 'decrement' , decrement ) }
324+ onPointerDown = { handleDecrementPointerDown }
177325 onPointerUp = { stopHold }
178326 onPointerLeave = { stopHold }
327+ onPointerCancel = { stopHold }
328+ onBlur = { stopHold }
179329 disabled = { disabled || value <= min }
180330 aria-label = { `Decrease ${ label || 'value' } ` }
181331 className = { `
@@ -223,9 +373,11 @@ export const GlassNumberStepper = memo(function GlassNumberStepper({
223373 { /* Increment Button */ }
224374 < motion . button
225375 type = "button"
226- onPointerDown = { ( ) => startHold ( 'increment' , increment ) }
376+ onPointerDown = { handleIncrementPointerDown }
227377 onPointerUp = { stopHold }
228378 onPointerLeave = { stopHold }
379+ onPointerCancel = { stopHold }
380+ onBlur = { stopHold }
229381 disabled = { disabled || value >= max }
230382 aria-label = { `Increase ${ label || 'value' } ` }
231383 className = { `
@@ -261,9 +413,11 @@ export const GlassNumberStepper = memo(function GlassNumberStepper({
261413 { /* Decrement Button */ }
262414 < button
263415 type = "button"
264- onPointerDown = { ( ) => startHold ( 'decrement' , decrement ) }
416+ onPointerDown = { handleDecrementPointerDown }
265417 onPointerUp = { stopHold }
266418 onPointerLeave = { stopHold }
419+ onPointerCancel = { stopHold }
420+ onBlur = { stopHold }
267421 disabled = { disabled || value <= min }
268422 aria-label = { `Decrease ${ label || 'value' } ` }
269423 className = { `
@@ -309,9 +463,11 @@ export const GlassNumberStepper = memo(function GlassNumberStepper({
309463 { /* Increment Button */ }
310464 < button
311465 type = "button"
312- onPointerDown = { ( ) => startHold ( 'increment' , increment ) }
466+ onPointerDown = { handleIncrementPointerDown }
313467 onPointerUp = { stopHold }
314468 onPointerLeave = { stopHold }
469+ onPointerCancel = { stopHold }
470+ onBlur = { stopHold }
315471 disabled = { disabled || value >= max }
316472 aria-label = { `Increase ${ label || 'value' } ` }
317473 className = { `
0 commit comments