Skip to content

Commit eba4fea

Browse files
Merge pull request #23 from laststance/codex/long-press-time-stepper
feat: support long press time steppers
2 parents 73fca8c + 851e59e commit eba4fea

3 files changed

Lines changed: 249 additions & 36 deletions

File tree

components/ui/GlassNumberStepper.tsx

Lines changed: 192 additions & 36 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)
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={`

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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,61 @@ 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 stops at lower boundary before release', async ({
96+
page,
97+
}) => {
98+
// Arrange
99+
await page.clock.install()
100+
const secondsInput = page.getByTestId('time-input-seconds')
101+
await secondsInput.fill('02')
102+
await expect(secondsInput).toHaveValue('02')
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('01')
113+
await page.clock.runFor(1200)
114+
115+
// Assert
116+
await expect(secondsInput).toHaveValue('00')
117+
await page.clock.runFor(300)
118+
await expect(secondsInput).toHaveValue('00')
119+
await page.mouse.up()
120+
await page.clock.runFor(300)
121+
await expect(secondsInput).toHaveValue('00')
122+
})
123+
69124
test('seconds stepper respects min boundary (0)', async ({ page }) => {
70125
// Get the seconds input (default is 0)
71126
const secondsInput = page.getByTestId('time-input-seconds')

0 commit comments

Comments
 (0)