You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
163 lines
4.9 KiB
163 lines
4.9 KiB
import * as React from 'react' |
|
import { |
|
Select, |
|
SelectContent, |
|
SelectItem, |
|
SelectTrigger, |
|
SelectValue |
|
} from '@/components/ui/select' |
|
import { Input } from '@/components/ui/input' |
|
import { cn } from '@/lib/utils' |
|
import { useTranslation } from 'react-i18next' |
|
|
|
/** Value is always 24-hour "HH:mm" */ |
|
export interface TimePickerProps { |
|
value: string |
|
onChange: (value: string) => void |
|
/** When true, show 12-hour with AM/PM; when false, show 24-hour. Default from locale (en-US -> 12h). */ |
|
hour12?: boolean |
|
onHour12Change?: (hour12: boolean) => void |
|
className?: string |
|
id?: string |
|
disabled?: boolean |
|
} |
|
|
|
function parseHHmm(value: string): { hour: number; minute: number } { |
|
const match = /^(\d{1,2}):(\d{2})$/.exec(value) |
|
if (!match) return { hour: 0, minute: 0 } |
|
const hour = Math.min(23, Math.max(0, parseInt(match[1]!, 10))) |
|
const minute = Math.min(59, Math.max(0, parseInt(match[2]!, 10))) |
|
return { hour, minute } |
|
} |
|
|
|
function toHHmm(hour: number, minute: number): string { |
|
const h = Math.min(23, Math.max(0, hour)) |
|
const m = Math.min(59, Math.max(0, minute)) |
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` |
|
} |
|
|
|
/** 24h hour (0-23) to 12h display: { displayHour 1-12, pm: boolean } */ |
|
function to12h(hour24: number): { displayHour: number; pm: boolean } { |
|
if (hour24 === 0) return { displayHour: 12, pm: false } |
|
if (hour24 < 12) return { displayHour: hour24, pm: false } |
|
if (hour24 === 12) return { displayHour: 12, pm: true } |
|
return { displayHour: hour24 - 12, pm: true } |
|
} |
|
|
|
/** 12h + AM/PM to 24h hour (0-23) */ |
|
function to24h(displayHour: number, pm: boolean): number { |
|
if (pm) return displayHour === 12 ? 12 : displayHour + 12 |
|
return displayHour === 12 ? 0 : displayHour |
|
} |
|
|
|
/** Default: use 12h for en-US, 24h otherwise */ |
|
function defaultHour12(): boolean { |
|
try { |
|
const lang = typeof navigator !== 'undefined' ? navigator.language : 'en-US' |
|
return lang.startsWith('en-US') |
|
} catch { |
|
return false |
|
} |
|
} |
|
|
|
export function TimePicker({ |
|
value, |
|
onChange, |
|
hour12: controlledHour12, |
|
onHour12Change, |
|
className, |
|
id, |
|
disabled |
|
}: TimePickerProps) { |
|
const { t } = useTranslation() |
|
const [internalHour12, setInternalHour12] = React.useState(defaultHour12) |
|
const hour12 = controlledHour12 ?? internalHour12 |
|
const setHour12 = React.useCallback( |
|
(v: boolean) => { |
|
if (onHour12Change) onHour12Change(v) |
|
else setInternalHour12(v) |
|
}, |
|
[onHour12Change] |
|
) |
|
|
|
const { hour: hour24, minute } = parseHHmm(value) |
|
const { displayHour: hour12Val, pm } = to12h(hour24) |
|
|
|
const displayHour = hour12 ? hour12Val : hour24 |
|
const hourMin = hour12 ? 1 : 0 |
|
const hourMax = hour12 ? 12 : 23 |
|
|
|
const handleHourChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
|
const v = e.target.value |
|
if (v === '') return |
|
const num = parseInt(v, 10) |
|
if (Number.isNaN(num)) return |
|
const clamped = Math.min(hourMax, Math.max(hourMin, num)) |
|
if (hour12) { |
|
const new24 = to24h(clamped, pm) |
|
onChange(toHHmm(new24, minute)) |
|
} else { |
|
onChange(toHHmm(clamped, minute)) |
|
} |
|
} |
|
|
|
const handleMinuteChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
|
const v = e.target.value |
|
if (v === '') return |
|
const num = parseInt(v, 10) |
|
if (Number.isNaN(num)) return |
|
const clamped = Math.min(59, Math.max(0, num)) |
|
onChange(toHHmm(hour24, clamped)) |
|
} |
|
|
|
const handleAmPmChange = (newPm: boolean) => { |
|
const new24 = to24h(hour12Val, newPm) |
|
onChange(toHHmm(new24, minute)) |
|
} |
|
|
|
return ( |
|
<div className={cn('flex flex-wrap items-center gap-2', className)}> |
|
<div className="flex items-center gap-1"> |
|
<Input |
|
id={id} |
|
type="number" |
|
min={hourMin} |
|
max={hourMax} |
|
value={displayHour} |
|
onChange={handleHourChange} |
|
disabled={disabled} |
|
className="h-9 w-14 px-2 text-center tabular-nums" |
|
/> |
|
<span className="text-muted-foreground">:</span> |
|
<Input |
|
type="number" |
|
min={0} |
|
max={59} |
|
value={minute} |
|
onChange={handleMinuteChange} |
|
disabled={disabled} |
|
className="h-9 w-14 px-2 text-center tabular-nums" |
|
/> |
|
</div> |
|
{hour12 && ( |
|
<Select value={pm ? 'pm' : 'am'} onValueChange={(v) => handleAmPmChange(v === 'pm')} disabled={disabled}> |
|
<SelectTrigger className="w-[72px] h-9"> |
|
<SelectValue /> |
|
</SelectTrigger> |
|
<SelectContent> |
|
<SelectItem value="am">{t('AM')}</SelectItem> |
|
<SelectItem value="pm">{t('PM')}</SelectItem> |
|
</SelectContent> |
|
</Select> |
|
)} |
|
<button |
|
type="button" |
|
onClick={() => setHour12(!hour12)} |
|
className="text-xs text-muted-foreground hover:text-foreground underline" |
|
disabled={disabled} |
|
> |
|
{hour12 ? t('24-hour') : t('12-hour (AM/PM)')} |
|
</button> |
|
</div> |
|
) |
|
}
|
|
|