Skip to content

Commit d302622

Browse files
committed
Implement a new primitive UI component for picking durations
1 parent 0da6a19 commit d302622

File tree

1 file changed

+177
-0
lines changed

1 file changed

+177
-0
lines changed
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { Input } from "~/components/primitives/Input";
2+
import { cn } from "~/utils/cn";
3+
import React, { useRef, useState, useEffect } from "react";
4+
import { Button } from "./Buttons";
5+
6+
export interface DurationPickerProps {
7+
id?: string; // used for the hidden input for form submission
8+
name?: string; // used for the hidden input for form submission
9+
defaultValueSeconds?: number;
10+
onChange?: (totalSeconds: number) => void;
11+
variant?: "small" | "medium";
12+
showClearButton?: boolean;
13+
}
14+
15+
export function DurationPicker({
16+
name,
17+
defaultValueSeconds: defaultValue = 0,
18+
onChange,
19+
variant = "small",
20+
showClearButton = true,
21+
}: DurationPickerProps) {
22+
const defaultHours = Math.floor(defaultValue / 3600);
23+
const defaultMinutes = Math.floor((defaultValue % 3600) / 60);
24+
const defaultSeconds = defaultValue % 60;
25+
26+
const [hours, setHours] = useState<number>(defaultHours);
27+
const [minutes, setMinutes] = useState<number>(defaultMinutes);
28+
const [seconds, setSeconds] = useState<number>(defaultSeconds);
29+
30+
const minuteRef = useRef<HTMLInputElement>(null);
31+
const hourRef = useRef<HTMLInputElement>(null);
32+
const secondRef = useRef<HTMLInputElement>(null);
33+
34+
const totalSeconds = hours * 3600 + minutes * 60 + seconds;
35+
36+
const isEmpty = hours === 0 && minutes === 0 && seconds === 0;
37+
38+
useEffect(() => {
39+
onChange?.(totalSeconds);
40+
}, [totalSeconds, onChange]);
41+
42+
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>) => {
43+
const value = parseInt(e.target.value) || 0;
44+
setHours(Math.max(0, value));
45+
};
46+
47+
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
48+
const value = parseInt(e.target.value) || 0;
49+
if (value >= 60) {
50+
setHours((prev) => prev + Math.floor(value / 60));
51+
setMinutes(value % 60);
52+
return;
53+
}
54+
55+
setMinutes(Math.max(0, Math.min(59, value)));
56+
};
57+
58+
const handleSecondsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
59+
const value = parseInt(e.target.value) || 0;
60+
if (value >= 60) {
61+
setMinutes((prev) => {
62+
const newMinutes = prev + Math.floor(value / 60);
63+
if (newMinutes >= 60) {
64+
setHours((prevHours) => prevHours + Math.floor(newMinutes / 60));
65+
return newMinutes % 60;
66+
}
67+
return newMinutes;
68+
});
69+
setSeconds(value % 60);
70+
return;
71+
}
72+
73+
setSeconds(Math.max(0, Math.min(59, value)));
74+
};
75+
76+
const handleKeyDown = (
77+
e: React.KeyboardEvent<HTMLInputElement>,
78+
nextRef?: React.RefObject<HTMLInputElement>,
79+
prevRef?: React.RefObject<HTMLInputElement>
80+
) => {
81+
if (e.key === "Tab") {
82+
return;
83+
}
84+
85+
if (e.key === "ArrowRight" && nextRef) {
86+
e.preventDefault();
87+
nextRef.current?.focus();
88+
nextRef.current?.select();
89+
return;
90+
}
91+
92+
if (e.key === "ArrowLeft" && prevRef) {
93+
e.preventDefault();
94+
prevRef.current?.focus();
95+
prevRef.current?.select();
96+
return;
97+
}
98+
};
99+
100+
const clearDuration = () => {
101+
setHours(0);
102+
setMinutes(0);
103+
setSeconds(0);
104+
hourRef.current?.focus();
105+
};
106+
107+
return (
108+
<div className="flex items-center gap-3">
109+
<input type="hidden" name={name} value={totalSeconds} />
110+
111+
<div className="flex items-center gap-1">
112+
<div className="flex items-center gap-1">
113+
<Input
114+
variant={variant}
115+
ref={hourRef}
116+
className={cn(
117+
"w-10 text-center font-mono tabular-nums caret-transparent [&::-webkit-inner-spin-button]:appearance-none",
118+
isEmpty && "text-text-dimmed"
119+
)}
120+
value={hours.toString()}
121+
onChange={handleHoursChange}
122+
onKeyDown={(e) => handleKeyDown(e, minuteRef)}
123+
onFocus={(e) => e.target.select()}
124+
type="number"
125+
min={0}
126+
inputMode="numeric"
127+
/>
128+
<span className="text-sm text-text-dimmed">h</span>
129+
</div>
130+
<div className="flex items-center gap-1">
131+
<Input
132+
variant={variant}
133+
ref={minuteRef}
134+
className={cn(
135+
"w-10 text-center font-mono tabular-nums caret-transparent [&::-webkit-inner-spin-button]:appearance-none",
136+
isEmpty && "text-text-dimmed"
137+
)}
138+
value={minutes.toString()}
139+
onChange={handleMinutesChange}
140+
onKeyDown={(e) => handleKeyDown(e, secondRef, hourRef)}
141+
onFocus={(e) => e.target.select()}
142+
type="number"
143+
min={0}
144+
max={59}
145+
inputMode="numeric"
146+
/>
147+
<span className="text-sm text-text-dimmed">m</span>
148+
</div>
149+
<div className="flex items-center gap-1">
150+
<Input
151+
variant={variant}
152+
ref={secondRef}
153+
className={cn(
154+
"w-10 text-center font-mono tabular-nums caret-transparent [&::-webkit-inner-spin-button]:appearance-none",
155+
isEmpty && "text-text-dimmed"
156+
)}
157+
value={seconds.toString()}
158+
onChange={handleSecondsChange}
159+
onKeyDown={(e) => handleKeyDown(e, undefined, minuteRef)}
160+
onFocus={(e) => e.target.select()}
161+
type="number"
162+
min={0}
163+
max={59}
164+
inputMode="numeric"
165+
/>
166+
<span className="text-sm text-text-dimmed">s</span>
167+
</div>
168+
</div>
169+
170+
{showClearButton && (
171+
<Button type="button" variant={`tertiary/${variant}`} onClick={clearDuration}>
172+
Clear
173+
</Button>
174+
)}
175+
</div>
176+
);
177+
}

0 commit comments

Comments
 (0)