Skip to content

Commit 62bcd84

Browse files
committed
fix(web): style custom snooze time picker like the calendar
1 parent 592021f commit 62bcd84

1 file changed

Lines changed: 177 additions & 11 deletions

File tree

‎apps/web/src/components/CustomSnoozeDialog.tsx‎

Lines changed: 177 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useId, useState } from "react";
1+
import { useEffect, useId, useMemo, useRef, useState } from "react";
22
import { Tabs } from "@base-ui/react/tabs";
33
import { create } from "zustand";
44
import {
@@ -7,12 +7,14 @@ import {
77
resolveCustomSnooze,
88
type CustomSnoozeInput,
99
} from "@t3tools/client-runtime/state/thread-settled";
10+
import type { TimestampFormat } from "@t3tools/contracts/settings";
1011
import { Button } from "./ui/button";
11-
import { CalendarIcon } from "lucide-react";
12+
import { CalendarIcon, ClockIcon } from "lucide-react";
1213
import { Calendar } from "./ui/calendar";
1314
import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover";
14-
import { Input } from "./ui/input";
1515
import { Label } from "./ui/label";
16+
import { useClientSettings } from "../hooks/useSettings";
17+
import { cn } from "~/lib/utils";
1618
import { toggleVariants } from "./ui/toggle";
1719
import { Select, SelectTrigger, SelectValue, SelectPopup, SelectItem } from "./ui/select";
1820
import {
@@ -164,15 +166,11 @@ function CustomSnoozeDialog() {
164166
htmlFor={`${id}-time`}
165167
>
166168
Time
167-
<Input
168-
nativeInput
169+
<TimeField
169170
id={`${id}-time`}
170-
className="h-9 sm:h-8"
171-
type="time"
172-
required
173-
value={time}
174-
onChange={(event) => {
175-
setTime(event.target.value);
171+
time={time}
172+
onChange={(next) => {
173+
setTime(next);
176174
setError(null);
177175
}}
178176
/>
@@ -243,3 +241,171 @@ function CustomSnoozeDialog() {
243241
</Dialog>
244242
);
245243
}
244+
245+
function parseSnoozeTime(time: string): { hour: number; minute: number } {
246+
const [rawHour, rawMinute] = time.split(":");
247+
const hour = Number(rawHour);
248+
const minute = Number(rawMinute);
249+
return {
250+
hour: Number.isInteger(hour) && hour >= 0 && hour < 24 ? hour : 9,
251+
minute: Number.isInteger(minute) && minute >= 0 && minute < 60 ? minute : 0,
252+
};
253+
}
254+
255+
function formatSnoozeTime(hour: number, minute: number): string {
256+
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
257+
}
258+
259+
function resolve12HourClock(timestampFormat: TimestampFormat | undefined): boolean {
260+
if (timestampFormat === "12-hour") return true;
261+
if (timestampFormat === "24-hour") return false;
262+
// "locale" (or still hydrating): mirror what the runtime locale renders —
263+
// a dayPeriod part means the locale uses AM/PM.
264+
const parts = new Intl.DateTimeFormat(undefined, { hour: "numeric" }).formatToParts(
265+
new Date(2026, 0, 1, 13, 0),
266+
);
267+
return parts.some((part) => part.type === "dayPeriod");
268+
}
269+
270+
const HOUR_12_OPTIONS = Array.from({ length: 12 }, (_, index) => {
271+
const hour12 = index + 1;
272+
return { value: String(hour12), label: String(hour12).padStart(2, "0") };
273+
});
274+
const HOUR_24_OPTIONS = Array.from({ length: 24 }, (_, hour) => ({
275+
value: String(hour),
276+
label: String(hour).padStart(2, "0"),
277+
}));
278+
const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, minute) => ({
279+
value: String(minute),
280+
label: String(minute).padStart(2, "0"),
281+
}));
282+
const PERIOD_OPTIONS = [
283+
{ value: "AM", label: "AM" },
284+
{ value: "PM", label: "PM" },
285+
];
286+
287+
/**
288+
* Time picker that shares the date picker's styling: an outline trigger
289+
* button opening a PopoverPopup with the same dropdown-glass surface and
290+
* primary/accent selection tokens as the calendar. The native
291+
* `<input type="time">` rendered an unstyled browser popup that matched
292+
* neither the calendar nor the rest of the app.
293+
*/
294+
function TimeField({
295+
id,
296+
time,
297+
onChange,
298+
}: {
299+
id: string;
300+
time: string;
301+
onChange: (next: string) => void;
302+
}) {
303+
const timestampFormat = useClientSettings((settings) => settings.timestampFormat);
304+
const use12Hour = useMemo(() => resolve12HourClock(timestampFormat), [timestampFormat]);
305+
const [open, setOpen] = useState(false);
306+
const { hour, minute } = parseSnoozeTime(time);
307+
const period = hour >= 12 ? "PM" : "AM";
308+
const hour12 = hour % 12 === 0 ? 12 : hour % 12;
309+
310+
const display = use12Hour
311+
? `${String(hour12).padStart(2, "0")}:${String(minute).padStart(2, "0")} ${period}`
312+
: formatSnoozeTime(hour, minute);
313+
314+
return (
315+
<Popover open={open} onOpenChange={setOpen}>
316+
<PopoverTrigger
317+
render={<Button id={id} variant="outline" className="w-full justify-between font-normal" />}
318+
>
319+
{display}
320+
<ClockIcon className="size-4 text-muted-foreground" />
321+
</PopoverTrigger>
322+
<PopoverPopup
323+
align="start"
324+
aria-label="Choose snooze time"
325+
viewportClassName="py-2 [--viewport-inline-padding:--spacing(2)]"
326+
>
327+
<div className="flex gap-0.5">
328+
<TimeColumn
329+
label={use12Hour ? "Hour (12-hour)" : "Hour"}
330+
options={use12Hour ? HOUR_12_OPTIONS : HOUR_24_OPTIONS}
331+
value={use12Hour ? String(hour12) : String(hour)}
332+
onSelect={(next) => {
333+
const nextHour = use12Hour
334+
? (Number(next) % 12) + (period === "PM" ? 12 : 0)
335+
: Number(next);
336+
onChange(formatSnoozeTime(nextHour, minute));
337+
}}
338+
open={open}
339+
/>
340+
<TimeColumn
341+
label="Minute"
342+
options={MINUTE_OPTIONS}
343+
value={String(minute)}
344+
onSelect={(next) => onChange(formatSnoozeTime(hour, Number(next)))}
345+
open={open}
346+
/>
347+
{use12Hour && (
348+
<TimeColumn
349+
label="AM or PM"
350+
options={PERIOD_OPTIONS}
351+
value={period}
352+
onSelect={(next) => {
353+
const nextHour = (hour12 % 12) + (next === "PM" ? 12 : 0);
354+
onChange(formatSnoozeTime(nextHour, minute));
355+
}}
356+
open={open}
357+
/>
358+
)}
359+
</div>
360+
</PopoverPopup>
361+
</Popover>
362+
);
363+
}
364+
365+
function TimeColumn({
366+
label,
367+
options,
368+
value,
369+
onSelect,
370+
open,
371+
}: {
372+
label: string;
373+
options: ReadonlyArray<{ value: string; label: string }>;
374+
value: string;
375+
onSelect: (value: string) => void;
376+
open: boolean;
377+
}) {
378+
const listRef = useRef<HTMLDivElement>(null);
379+
useEffect(() => {
380+
if (open) {
381+
listRef.current?.querySelector('[aria-checked="true"]')?.scrollIntoView({ block: "nearest" });
382+
}
383+
}, [open]);
384+
return (
385+
<div
386+
ref={listRef}
387+
role="radiogroup"
388+
aria-label={label}
389+
className="flex max-h-48 min-w-14 flex-col overflow-y-auto p-1"
390+
>
391+
{options.map((option) => {
392+
const selected = option.value === value;
393+
return (
394+
<button
395+
key={option.value}
396+
type="button"
397+
role="radio"
398+
aria-checked={selected}
399+
onClick={() => onSelect(option.value)}
400+
className={cn(
401+
"flex h-8 shrink-0 cursor-pointer items-center justify-center rounded-md px-2 text-base outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 sm:text-sm",
402+
selected ? "bg-primary text-primary-foreground" : "text-foreground hover:bg-accent",
403+
)}
404+
>
405+
{option.label}
406+
</button>
407+
);
408+
})}
409+
</div>
410+
);
411+
}

0 commit comments

Comments
 (0)