refactor(time): app-wide setting-aware TimeField for all time inputs

Add shared components/common/TimeField (displays per general_time_format,
stores canonical HH:MM, parses tolerant free-text, browser-independent)
and migrate every native <input type="time"> to it: business hours,
HoursSection, HourEntryInlinePopover, CreateEventPage, Quote/Bill/Contract
editors. Removes the unreliable lang-hint plumbing.
This commit is contained in:
Luca
2026-06-02 17:24:27 +02:00
parent 2149f38cd1
commit 626ab45e0b
11 changed files with 180 additions and 137 deletions
@@ -0,0 +1,106 @@
import React, { useEffect, useState } from 'react';
import { clsx } from 'clsx';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
/**
* Parse a free-typed time into canonical 24h "HH:MM", or null if
* unparseable. Tolerant of: "13:00", "1300", "9:5", "9", "1:00 PM",
* "1pm", "12 am". Lets the field accept input in whichever format it is
* displaying (24h or 12h) and normalise it back to storage form.
*/
export const parseTimeToHHMM = (raw: string): string | null => {
const s = raw.trim().toLowerCase();
if (!s) return null;
let ampm: 'am' | 'pm' | null = null;
let core = s;
const am = s.match(/(a|p)\.?m?\.?\s*$/);
if (am) {
ampm = am[1] === 'p' ? 'pm' : 'am';
core = s.slice(0, am.index).trim();
}
let h: number;
let mi: number;
const colon = core.match(/^(\d{1,2})\s*[:.]\s*(\d{1,2})$/);
if (colon) {
h = parseInt(colon[1], 10);
mi = parseInt(colon[2], 10);
} else {
const digits = core.replace(/\D/g, '');
if (!digits) return null;
if (digits.length <= 2) { h = parseInt(digits, 10); mi = 0; }
else if (digits.length === 3) { h = parseInt(digits.slice(0, 1), 10); mi = parseInt(digits.slice(1), 10); }
else { h = parseInt(digits.slice(0, 2), 10); mi = parseInt(digits.slice(2, 4), 10); }
}
if (Number.isNaN(h) || Number.isNaN(mi)) return null;
if (ampm === 'pm' && h < 12) h += 12;
if (ampm === 'am' && h === 12) h = 0;
h = Math.min(23, Math.max(0, h));
mi = Math.min(59, Math.max(0, mi));
return `${String(h).padStart(2, '0')}:${String(mi).padStart(2, '0')}`;
};
interface TimeFieldProps {
/** Canonical 24h "HH:MM" (or '' for empty). */
value: string;
/** Emits canonical 24h "HH:MM". */
onChange: (v: string) => void;
/** Optional label rendered above the field (matches the `Input` component). */
label?: string;
ariaLabel?: string;
/** Tailwind width/extra classes for the input; defaults to w-full. */
className?: string;
disabled?: boolean;
}
/**
* Time field that DISPLAYS in the admin's `general_time_format` (24h →
* "13:00", 12h → "01:00 PM") but always stores/emits canonical 24h
* "HH:MM". A plain text input, so the rendered format is identical in
* EVERY browser — native <input type="time"> ignores our setting (its
* 12h/24h chrome is browser-locale-controlled and Safari ignores the
* `lang` hint). Free text while typing; parsed + reformatted on blur,
* reverting to the last good value if unparseable.
*/
export const TimeField: React.FC<TimeFieldProps> = ({
value, onChange, label, ariaLabel, className, disabled,
}) => {
const { formatTime: fmtTime, timeFormat } = useLocalizedDate();
const display = (v: string) => (/^\d{1,2}:\d{2}/.test(v) ? fmtTime(v) : (v || ''));
const [text, setText] = useState(() => display(value));
// Re-sync when the external value or the format setting changes.
useEffect(() => { setText(display(value)); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [value, timeFormat]);
const commit = () => {
const parsed = parseTimeToHHMM(text);
if (parsed) {
setText(display(parsed));
if (parsed !== value) onChange(parsed);
} else {
setText(display(value));
}
};
const input = (
<input
type="text"
inputMode={timeFormat === '12h' ? 'text' : 'numeric'}
aria-label={ariaLabel || label}
placeholder={timeFormat === '12h' ? '1:00 PM' : 'HH:MM'}
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
className={clsx('input', className || 'w-full')}
/>
);
if (!label) return input;
return (
<div className="w-full">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1.5">
{label}
</label>
{input}
</div>
);
};
+1
View File
@@ -3,6 +3,7 @@ export { CMSContentBlock } from './CMSContentBlock';
export { Input } from './Input';
export { CountrySelect } from './CountrySelect';
export { LocalizedDateInput } from './LocalizedDateInput';
export { TimeField, parseTimeToHHMM } from './TimeField';
export { SortableHeader, useColumnSort } from './SortableHeader';
export type { SortDir, SortPair, SortColumnMap } from './SortableHeader';
export { Card, CardHeader, CardContent, CardFooter } from './Card';