Portal: add reusable 12-hour TimeInput component
TimeInput displays and accepts AM/PM times, offers the 5-minute options as suggestions, and allows typing any minute 00-59; its value model is a 24h HH:MM string. Adds isomorphic parseTimeInput/hmToMinutes/fiveMinuteOptions helpers and unit + component tests for them.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
fiveMinuteOptions,
|
||||
formatTime,
|
||||
hmToMinutes,
|
||||
parseTimeInput,
|
||||
toHm
|
||||
} from '$lib/schedule-utils';
|
||||
|
||||
// A reusable 12-hour clock input. Displays and accepts AM/PM times, offers
|
||||
// the 5-minute options as suggestions, and allows typing any minute 00-59.
|
||||
// Its value model is a 24-hour "HH:MM" string.
|
||||
let {
|
||||
value = '00:00',
|
||||
label,
|
||||
onchange,
|
||||
onvalidity,
|
||||
required = false,
|
||||
disabled = false
|
||||
}: {
|
||||
value?: string;
|
||||
label?: string;
|
||||
onchange?: (value: string) => void;
|
||||
onvalidity?: (valid: boolean) => void;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
const inputId = $props.id();
|
||||
const options = fiveMinuteOptions();
|
||||
|
||||
// While the user is typing we show their raw text (`editing`); otherwise we
|
||||
// show the canonical 12-hour label derived from the 24-hour value.
|
||||
let editing = $state<string | null>(null);
|
||||
const valueMinutes = $derived(hmToMinutes(value) ?? 0);
|
||||
const display = $derived(editing ?? formatTime(valueMinutes));
|
||||
const error = $derived.by(() => {
|
||||
if (editing === null) return '';
|
||||
const result = parseTimeInput(editing);
|
||||
return result.ok ? '' : result.error;
|
||||
});
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const raw = (event.currentTarget as HTMLInputElement).value;
|
||||
editing = raw;
|
||||
const result = parseTimeInput(raw);
|
||||
if (result.ok) {
|
||||
onvalidity?.(true);
|
||||
onchange?.(toHm(result.minutes));
|
||||
} else {
|
||||
onvalidity?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (editing === null) return;
|
||||
const result = parseTimeInput(editing);
|
||||
if (result.ok) {
|
||||
// Drop back to the canonical label derived from the value.
|
||||
editing = null;
|
||||
onvalidity?.(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus(event: FocusEvent) {
|
||||
editing = (event.currentTarget as HTMLInputElement).value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if label}
|
||||
<label for={inputId} class="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{label}
|
||||
</label>
|
||||
{/if}
|
||||
<input
|
||||
id={inputId}
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
list={`${inputId}-options`}
|
||||
{required}
|
||||
{disabled}
|
||||
aria-label={label}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={error ? `${inputId}-error` : undefined}
|
||||
value={display}
|
||||
oninput={handleInput}
|
||||
onfocus={handleFocus}
|
||||
onblur={handleBlur}
|
||||
/>
|
||||
<datalist id={`${inputId}-options`}>
|
||||
{#each options as minutes (minutes)}
|
||||
<option value={formatTime(minutes)}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
{#if error}
|
||||
<p id={`${inputId}-error`} role="alert" class="text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { page } from 'vitest/browser';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import TimeInput from './TimeInput.svelte';
|
||||
|
||||
function mount(props: Record<string, unknown> = {}) {
|
||||
return render(TimeInput, { value: '07:05', label: 'Time', ...props });
|
||||
}
|
||||
|
||||
async function type(text: string) {
|
||||
const input = (await page.getByRole('combobox', { name: 'Time' }).element()) as HTMLInputElement;
|
||||
input.value = text;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
describe('TimeInput', () => {
|
||||
it('displays the 24-hour value as a 12-hour label', async () => {
|
||||
mount({ value: '21:05' });
|
||||
|
||||
await expect.element(page.getByRole('combobox', { name: 'Time' })).toHaveValue('9:05 PM');
|
||||
});
|
||||
|
||||
it('calls onchange with a 24-hour value for tolerant input', async () => {
|
||||
const onchange = vi.fn();
|
||||
mount({ onchange });
|
||||
|
||||
await type('7:05 pm');
|
||||
|
||||
expect(onchange).toHaveBeenCalledWith('19:05');
|
||||
});
|
||||
|
||||
it('accepts typed minutes that are not 5-minute steps', async () => {
|
||||
const onchange = vi.fn();
|
||||
mount({ value: '00:00', onchange });
|
||||
|
||||
await type('7:07');
|
||||
|
||||
expect(onchange).toHaveBeenCalledWith('07:07');
|
||||
});
|
||||
|
||||
it('accepts an hour with no minutes', async () => {
|
||||
const onchange = vi.fn();
|
||||
mount({ onchange });
|
||||
|
||||
await type('7pm');
|
||||
|
||||
expect(onchange).toHaveBeenCalledWith('19:00');
|
||||
});
|
||||
|
||||
it('shows clear feedback and reports invalid input', async () => {
|
||||
const onvalidity = vi.fn();
|
||||
mount({ onvalidity });
|
||||
|
||||
await type('13:00 PM');
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
expect(onvalidity).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('offers the 5-minute clock options', async () => {
|
||||
mount();
|
||||
|
||||
const options = document.querySelectorAll('datalist option');
|
||||
expect(options).toHaveLength((24 * 60) / 5);
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,11 @@ import {
|
||||
blockDuration,
|
||||
blockSegments,
|
||||
findOverlap,
|
||||
fiveMinuteOptions,
|
||||
formatTime,
|
||||
hmToMinutes,
|
||||
isOvernight,
|
||||
parseTimeInput,
|
||||
snapMinutes,
|
||||
toHm,
|
||||
validateBlock,
|
||||
@@ -44,6 +47,52 @@ describe('toHm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hmToMinutes', () => {
|
||||
it('parses 24-hour HH:MM strings', () => {
|
||||
expect(hmToMinutes('00:00')).toBe(0);
|
||||
expect(hmToMinutes('07:05')).toBe(7 * 60 + 5);
|
||||
expect(hmToMinutes('23:59')).toBe(23 * 60 + 59);
|
||||
});
|
||||
|
||||
it('rejects malformed or out-of-range values', () => {
|
||||
expect(hmToMinutes('7:05')).toBeNull();
|
||||
expect(hmToMinutes('24:00')).toBeNull();
|
||||
expect(hmToMinutes('12:60')).toBeNull();
|
||||
expect(hmToMinutes('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTimeInput', () => {
|
||||
it('parses tolerant 12-hour input', () => {
|
||||
expect(parseTimeInput('7:05 pm')).toEqual({ ok: true, minutes: 19 * 60 + 5 });
|
||||
expect(parseTimeInput('7:05PM')).toEqual({ ok: true, minutes: 19 * 60 + 5 });
|
||||
expect(parseTimeInput('7pm')).toEqual({ ok: true, minutes: 19 * 60 });
|
||||
expect(parseTimeInput('7:7')).toEqual({ ok: true, minutes: 7 * 60 + 7 });
|
||||
expect(parseTimeInput('12:00 am')).toEqual({ ok: true, minutes: 0 });
|
||||
expect(parseTimeInput('12:30 PM')).toEqual({ ok: true, minutes: 12 * 60 + 30 });
|
||||
expect(parseTimeInput('11:59 pm')).toEqual({ ok: true, minutes: 23 * 60 + 59 });
|
||||
expect(parseTimeInput('0:30')).toEqual({ ok: true, minutes: 30 });
|
||||
});
|
||||
|
||||
it('rejects ambiguous or out-of-range input', () => {
|
||||
expect(parseTimeInput('13:00 PM').ok).toBe(false);
|
||||
expect(parseTimeInput('7:70').ok).toBe(false);
|
||||
expect(parseTimeInput('25:00').ok).toBe(false);
|
||||
expect(parseTimeInput('').ok).toBe(false);
|
||||
expect(parseTimeInput('noon').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fiveMinuteOptions', () => {
|
||||
it('lists every 5-minute step across the day', () => {
|
||||
const options = fiveMinuteOptions();
|
||||
expect(options[0]).toBe(0);
|
||||
expect(options[1]).toBe(5);
|
||||
expect(options[options.length - 1]).toBe(23 * 60 + 55);
|
||||
expect(options).toHaveLength((24 * 60) / 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapMinutes', () => {
|
||||
it('rounds to the nearest 15 minutes and clamps to the day', () => {
|
||||
expect(snapMinutes(0)).toBe(0);
|
||||
|
||||
@@ -45,6 +45,62 @@ export function toHm(minutes: number): string {
|
||||
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a 24-hour `HH:MM` string into minutes from midnight. Returns null when
|
||||
* the input is not a valid 24-hour time.
|
||||
*/
|
||||
export function hmToMinutes(hm: string): number | null {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(hm.trim());
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (hours > 23 || minutes > 59) return null;
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
export type TimeInputResult = { ok: true; minutes: number } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Parse a user-typed 12-hour clock time into minutes from midnight. Tolerant of
|
||||
* "7:05 pm", "7:05PM", "7pm", "7:7", "12:00 am". A period is optional; when
|
||||
* present the hour must be 1-12, so "13:00 PM" is rejected. Minutes may be any
|
||||
* value 00-59, not just the 5-minute steps.
|
||||
*/
|
||||
export function parseTimeInput(raw: string): TimeInputResult {
|
||||
const text = raw.trim().toLowerCase();
|
||||
if (!text) return { ok: false, error: 'Enter a time.' };
|
||||
|
||||
const match = /^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm|a|p)?$/.exec(text);
|
||||
if (!match) return { ok: false, error: 'Enter a time like 7:05 PM.' };
|
||||
|
||||
const hour = Number(match[1]);
|
||||
const minute = match[2] === undefined ? 0 : Number(match[2]);
|
||||
const period = match[3];
|
||||
|
||||
if (minute > 59) return { ok: false, error: 'Minutes must be 00-59.' };
|
||||
|
||||
if (period) {
|
||||
if (hour < 1 || hour > 12) {
|
||||
return { ok: false, error: 'Hour must be 1-12 when AM/PM is given.' };
|
||||
}
|
||||
const isPm = period === 'pm' || period === 'p';
|
||||
const hours24 = (hour % 12) + (isPm ? 12 : 0);
|
||||
return { ok: true, minutes: hours24 * 60 + minute };
|
||||
}
|
||||
|
||||
if (hour > 23) return { ok: false, error: 'Hour must be 0-23.' };
|
||||
return { ok: true, minutes: hour * 60 + minute };
|
||||
}
|
||||
|
||||
/** The 5-minute clock options (00:00, 00:05, ..., 23:55) in play order. */
|
||||
export function fiveMinuteOptions(): number[] {
|
||||
const options: number[] = [];
|
||||
for (let minutes = 0; minutes < MINUTES_PER_DAY; minutes += 5) {
|
||||
options.push(minutes);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Snap a minute value to the nearest `SNAP_MINUTES` interval, clamped to 24h. */
|
||||
export function snapMinutes(minutes: number): number {
|
||||
const snapped = Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES;
|
||||
|
||||
Reference in New Issue
Block a user