Skip to content

Commit 27917e2

Browse files
feat: support long press time steppers
1 parent 73fca8c commit 27917e2

3 files changed

Lines changed: 220 additions & 37 deletions

File tree

components/ui/GlassNumberStepper.tsx

Lines changed: 165 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,29 @@
11
'use client'
22

3-
import { memo, useEffect, useRef, useState } from 'react'
3+
import { memo, useCallback, useEffect, useRef, useState } from 'react'
44
import { motion } from 'framer-motion'
55
import { useTheme } from 'next-themes'
66
import { 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)
42-
43-
const startHold = (
44-
action: 'increment' | 'decrement',
45-
callback: () => void,
46-
) => {
47-
setIsHolding(action)
48-
callback()
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)
4964

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,30 +205,54 @@ 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
}
135-
}
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 })
136256

137257
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
138258
const inputValue = e.target.value
@@ -173,9 +293,11 @@ export const GlassNumberStepper = memo(function GlassNumberStepper({
173293
{/* Decrement Button */}
174294
<motion.button
175295
type="button"
176-
onPointerDown={() => startHold('decrement', decrement)}
296+
onPointerDown={() => startHold('decrement')}
177297
onPointerUp={stopHold}
178298
onPointerLeave={stopHold}
299+
onPointerCancel={stopHold}
300+
onBlur={stopHold}
179301
disabled={disabled || value <= min}
180302
aria-label={`Decrease ${label || 'value'}`}
181303
className={`
@@ -223,9 +345,11 @@ export const GlassNumberStepper = memo(function GlassNumberStepper({
223345
{/* Increment Button */}
224346
<motion.button
225347
type="button"
226-
onPointerDown={() => startHold('increment', increment)}
348+
onPointerDown={() => startHold('increment')}
227349
onPointerUp={stopHold}
228350
onPointerLeave={stopHold}
351+
onPointerCancel={stopHold}
352+
onBlur={stopHold}
229353
disabled={disabled || value >= max}
230354
aria-label={`Increase ${label || 'value'}`}
231355
className={`
@@ -261,9 +385,11 @@ export const GlassNumberStepper = memo(function GlassNumberStepper({
261385
{/* Decrement Button */}
262386
<button
263387
type="button"
264-
onPointerDown={() => startHold('decrement', decrement)}
388+
onPointerDown={() => startHold('decrement')}
265389
onPointerUp={stopHold}
266390
onPointerLeave={stopHold}
391+
onPointerCancel={stopHold}
392+
onBlur={stopHold}
267393
disabled={disabled || value <= min}
268394
aria-label={`Decrease ${label || 'value'}`}
269395
className={`
@@ -309,9 +435,11 @@ export const GlassNumberStepper = memo(function GlassNumberStepper({
309435
{/* Increment Button */}
310436
<button
311437
type="button"
312-
onPointerDown={() => startHold('increment', increment)}
438+
onPointerDown={() => startHold('increment')}
313439
onPointerUp={stopHold}
314440
onPointerLeave={stopHold}
441+
onPointerCancel={stopHold}
442+
onBlur={stopHold}
315443
disabled={disabled || value >= max}
316444
aria-label={`Increase ${label || 'value'}`}
317445
className={`

components/ui/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const STEPPER_HOLD_REPEAT_DELAY_MS = 300
2+
export const STEPPER_HOLD_REPEAT_INTERVAL_MS = 100

e2e/glass-number-stepper.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,59 @@ test.describe('GlassNumberStepper', () => {
6666
await expect(minutesInput).toHaveValue('03')
6767
})
6868

69+
test('holding minutes + button keeps incrementing until released', async ({
70+
page,
71+
}) => {
72+
// Arrange
73+
await page.clock.install()
74+
const minutesInput = page.getByTestId('time-input-minutes')
75+
await expect(minutesInput).toHaveValue('05')
76+
77+
const minutesStepper = minutesInput.locator('..')
78+
const incrementButton = minutesStepper.getByRole('button', {
79+
name: /increase/i,
80+
})
81+
82+
// Act
83+
await incrementButton.hover()
84+
await page.mouse.down()
85+
await expect(minutesInput).toHaveValue('06')
86+
await page.clock.runFor(600)
87+
await page.mouse.up()
88+
89+
// Assert
90+
await expect(minutesInput).toHaveValue('09')
91+
await page.clock.runFor(300)
92+
await expect(minutesInput).toHaveValue('09')
93+
})
94+
95+
test('holding seconds - button keeps decrementing until released', async ({
96+
page,
97+
}) => {
98+
// Arrange
99+
await page.clock.install()
100+
const secondsInput = page.getByTestId('time-input-seconds')
101+
await secondsInput.fill('05')
102+
await expect(secondsInput).toHaveValue('05')
103+
104+
const secondsStepper = secondsInput.locator('..')
105+
const decrementButton = secondsStepper.getByRole('button', {
106+
name: /decrease/i,
107+
})
108+
109+
// Act
110+
await decrementButton.hover()
111+
await page.mouse.down()
112+
await expect(secondsInput).toHaveValue('04')
113+
await page.clock.runFor(600)
114+
await page.mouse.up()
115+
116+
// Assert
117+
await expect(secondsInput).toHaveValue('01')
118+
await page.clock.runFor(300)
119+
await expect(secondsInput).toHaveValue('01')
120+
})
121+
69122
test('seconds stepper respects min boundary (0)', async ({ page }) => {
70123
// Get the seconds input (default is 0)
71124
const secondsInput = page.getByTestId('time-input-seconds')

0 commit comments

Comments
 (0)