1- import { useEffect , useId , useState } from "react" ;
1+ import { useEffect , useId , useMemo , useRef , useState } from "react" ;
22import { Tabs } from "@base-ui/react/tabs" ;
33import { create } from "zustand" ;
44import {
@@ -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" ;
1011import { Button } from "./ui/button" ;
11- import { CalendarIcon } from "lucide-react" ;
12+ import { CalendarIcon , ClockIcon } from "lucide-react" ;
1213import { Calendar } from "./ui/calendar" ;
1314import { Popover , PopoverTrigger , PopoverPopup } from "./ui/popover" ;
14- import { Input } from "./ui/input" ;
1515import { Label } from "./ui/label" ;
16+ import { useClientSettings } from "../hooks/useSettings" ;
17+ import { cn } from "~/lib/utils" ;
1618import { toggleVariants } from "./ui/toggle" ;
1719import { Select , SelectTrigger , SelectValue , SelectPopup , SelectItem } from "./ui/select" ;
1820import {
@@ -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