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.
1094 lines
34 KiB
1094 lines
34 KiB
import { untrack } from 'svelte';
|
|
import { watch } from 'runed';
|
|
import { context, type ProviderOpts, type WithRefOpts } from '../../provider';
|
|
import { boolToEmptyStrOrUndef, boolToStr } from '$adom';
|
|
import {
|
|
readableActive,
|
|
state,
|
|
type Active,
|
|
type ActiveProps,
|
|
type StateProps
|
|
} from '$libs/reactive';
|
|
import type { SomaFocusEvent, SomaKeyboardEvent, OnChangeFn } from '../../types';
|
|
import { KEYS, getDirectionalKeys } from '../../keyboard';
|
|
import { isNumberString } from '../../types/guards';
|
|
import { useId } from '$active-uix/id';
|
|
import { Soma } from '../../core/soma.svelte';
|
|
import {
|
|
DateFormatter,
|
|
Time,
|
|
convertTimeValueToDateValue,
|
|
convertTimeValueToTime,
|
|
isTimeBefore,
|
|
isZonedDateTime,
|
|
inferTimeGranularity,
|
|
initializeTimeSegmentValues,
|
|
createTimeSegmentContent,
|
|
isEditableTimeSegmentPart,
|
|
EDITABLE_TIME_SEGMENT_PARTS,
|
|
type HourCycle,
|
|
type TimeGranularity,
|
|
type TimeValue,
|
|
type TimeValidator,
|
|
type TimeOnInvalid,
|
|
type DateOnInvalid,
|
|
type EditableTimeSegmentPart,
|
|
type TimeSegmentObj,
|
|
type TimeSegmentPart,
|
|
type SegmentPart,
|
|
type DayPeriod
|
|
} from '$libs/days';
|
|
import {
|
|
areAllTimeSegmentsFilledDOM,
|
|
getAnnouncer,
|
|
getFirstSegment,
|
|
handleSegmentNavigation,
|
|
initTimeSegmentStates,
|
|
isAcceptableSegmentKey,
|
|
isFirstSegment,
|
|
isSegmentNavigationKey,
|
|
moveToNextSegment,
|
|
moveToPrevSegment,
|
|
removeDescriptionElement,
|
|
setDescription,
|
|
type Announcer
|
|
} from '../../datetime';
|
|
import { FieldProvider } from '../field';
|
|
import { TIME_FIELD_LANGS } from './langs';
|
|
|
|
// ── Contract ─────────────────────────────────────────────────────────────────
|
|
|
|
import { timeFieldMorfo } from '../../../morfo/components/time-field';
|
|
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
|
|
|
// ── Segment config ──────────────────────────────────────────────────────────
|
|
|
|
interface SegmentConfig {
|
|
min: number | ((provider: TimeFieldProvider) => number);
|
|
max: number | ((provider: TimeFieldProvider) => number);
|
|
cycle: number;
|
|
padZero?: boolean;
|
|
}
|
|
|
|
const SEGMENT_CONFIGS: Record<'hour' | 'minute' | 'second', SegmentConfig> = {
|
|
hour: {
|
|
min: (p) => (p.resolvedHourCycle === 12 ? 1 : 0),
|
|
max: (p) => (p.resolvedHourCycle === 24 ? 23 : 12),
|
|
cycle: 1,
|
|
padZero: true
|
|
},
|
|
minute: { min: 0, max: 59, cycle: 1, padZero: true },
|
|
second: { min: 0, max: 59, cycle: 1, padZero: true }
|
|
};
|
|
|
|
// ── Root provider ───────────────────────────────────────────────────────────
|
|
|
|
export interface TimeFieldOpts
|
|
extends
|
|
ProviderOpts,
|
|
StateProps<{
|
|
value: TimeValue | undefined;
|
|
placeholder: TimeValue;
|
|
}>,
|
|
ActiveProps<{
|
|
validate: TimeValidator<TimeValue> | undefined;
|
|
onInvalid: TimeOnInvalid | undefined;
|
|
minValue: TimeValue | undefined;
|
|
maxValue: TimeValue | undefined;
|
|
disabled: boolean;
|
|
readonly: boolean;
|
|
readonlySegments: EditableTimeSegmentPart[];
|
|
granularity: TimeGranularity | undefined;
|
|
hideTimeZone: boolean;
|
|
required: boolean;
|
|
errorMessageId: string | undefined;
|
|
locale: string;
|
|
dir: 'ltr' | 'rtl';
|
|
hourCycle: HourCycle | undefined;
|
|
}> {}
|
|
|
|
export class TimeFieldProvider {
|
|
readonly opts: TimeFieldOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
readonly runtime: SomaRuntime;
|
|
static readonly ctx = context<TimeFieldProvider>('TimeField');
|
|
static get(): TimeFieldProvider | undefined {
|
|
return this.ctx.getOr(undefined) as TimeFieldProvider | undefined;
|
|
}
|
|
static require(): TimeFieldProvider {
|
|
return this.ctx.get();
|
|
}
|
|
static create(opts: TimeFieldOpts) {
|
|
return new TimeFieldProvider(opts);
|
|
}
|
|
|
|
readonly soma = Soma.require();
|
|
readonly field = FieldProvider.get();
|
|
|
|
descriptionId = useId('time-field-description');
|
|
segmentValues = $state() as TimeSegmentObj;
|
|
states = initTimeSegmentStates();
|
|
announcer: Announcer = getAnnouncer(null);
|
|
fieldNode = $state<HTMLElement | null>(null);
|
|
labelNode = $state<HTMLElement | null>(null);
|
|
dayPeriodNode = $state<HTMLElement | null>(null);
|
|
name = $state('');
|
|
|
|
readonly formatter = {
|
|
getLocale: () => this.effectiveLocale,
|
|
dayPeriod: (date: Date, hourCycle?: HourCycle): 'AM' | 'PM' => {
|
|
const parts = new DateFormatter(this.effectiveLocale, {
|
|
hour: 'numeric',
|
|
minute: 'numeric',
|
|
hourCycle: hourCycle === 24 ? 'h23' : undefined
|
|
}).formatToParts(date);
|
|
return parts.find((p) => p.type === 'dayPeriod')?.value === 'PM' ? 'PM' : 'AM';
|
|
},
|
|
selectedTime: (value: TimeValue): string => {
|
|
const dv = convertTimeValueToDateValue(value);
|
|
const tz = isZonedDateTime(dv) ? (dv as unknown as { timeZone: string }).timeZone : 'UTC';
|
|
const options: Intl.DateTimeFormatOptions = { timeStyle: 'long' };
|
|
return new DateFormatter(this.effectiveLocale, options).format(dv.toDate(tz));
|
|
}
|
|
};
|
|
|
|
private constructor(opts: TimeFieldOpts) {
|
|
this.opts = opts;
|
|
this.runtime = this.soma.runtime(timeFieldMorfo, {
|
|
props: {
|
|
disabled: () => this.isDisabled,
|
|
readonly: () => this.isReadonly,
|
|
required: () => this.isRequired,
|
|
invalid: () => this.isInvalid
|
|
},
|
|
parts: {
|
|
label: () => this.labelNode?.id || undefined
|
|
}
|
|
});
|
|
this.runtimePart = this.runtime.part('provider', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
context: TimeFieldProvider.ctx,
|
|
syncAttrs: true
|
|
});
|
|
|
|
this.segmentValues = initializeTimeSegmentValues();
|
|
|
|
$effect(() => {
|
|
this.announcer = getAnnouncer(this.soma.dom, this.fieldNode);
|
|
});
|
|
|
|
// Populate description on value change.
|
|
$effect(() => {
|
|
if (this.opts.value.current) {
|
|
const descId = untrack(() => this.descriptionId);
|
|
setDescription({
|
|
id: descId,
|
|
value: this.formatter.selectedTime(this.opts.value.current),
|
|
dom: this.soma.dom,
|
|
target: this.fieldNode
|
|
});
|
|
}
|
|
});
|
|
|
|
// Cleanup description on unmount.
|
|
$effect(() => {
|
|
return () => {
|
|
const descId = untrack(() => this.descriptionId);
|
|
removeDescriptionElement(descId, this.soma.dom, this.fieldNode);
|
|
};
|
|
});
|
|
|
|
// Sync segmentValues ⇆ value.
|
|
if (this.opts.value.current) this.syncSegmentValues(this.opts.value.current);
|
|
|
|
$effect(() => {
|
|
this.opts.locale.current;
|
|
if (this.opts.value.current) this.syncSegmentValues(this.opts.value.current);
|
|
this.clearUpdating();
|
|
});
|
|
|
|
$effect(() => {
|
|
if (this.opts.value.current === undefined) {
|
|
this.segmentValues = initializeTimeSegmentValues();
|
|
}
|
|
});
|
|
|
|
watch(
|
|
() => this.validationStatus,
|
|
() => {
|
|
if (this.validationStatus !== false) {
|
|
this.opts.onInvalid.current?.(
|
|
this.validationStatus.reason as never,
|
|
this.validationStatus.message as never
|
|
);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Dev warning: readonlySegments without a value has no concrete anchor.
|
|
{
|
|
let lastWarnedKey: string | null = null;
|
|
$effect(() => {
|
|
const segs = this.opts.readonlySegments.current;
|
|
const hasValue = this.opts.value.current !== undefined;
|
|
const key = segs.length > 0 && !hasValue ? [...segs].sort().join(',') : null;
|
|
if (key && key !== lastWarnedKey) {
|
|
lastWarnedKey = key;
|
|
this.soma?.logger.warn(
|
|
'TimeField',
|
|
'`readonlySegments` is set but `value` is undefined — lock has no concrete anchor; falling back to placeholder. Supply an initial `value` so the locked segment has a defined meaning.',
|
|
{ context: { readonlySegments: [...segs] } }
|
|
);
|
|
} else if (!key) {
|
|
lastWarnedKey = null;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Merged flags ────────────────────────────────────────────────────────
|
|
|
|
readonly isDisabled = $derived.by(
|
|
() => this.opts.disabled.current || (this.field?.isDisabled ?? false)
|
|
);
|
|
readonly isReadonly = $derived.by(
|
|
() => this.opts.readonly.current || (this.field?.isReadonly ?? false)
|
|
);
|
|
readonly isRequired = $derived.by(
|
|
() => this.opts.required.current || (this.field?.isRequired ?? false)
|
|
);
|
|
|
|
// ── Derived ─────────────────────────────────────────────────────────────
|
|
|
|
readonly effectiveLocale = $derived.by(
|
|
() => this.opts.locale.current || this.soma?.langs.getLocale() || 'en'
|
|
);
|
|
|
|
readonly inferredGranularity = $derived.by(() =>
|
|
inferTimeGranularity(this.opts.granularity.current)
|
|
);
|
|
|
|
readonly resolvedHourCycle: HourCycle = $derived.by(() => {
|
|
const explicit = this.opts.hourCycle.current;
|
|
if (explicit === 12 || explicit === 24) return explicit;
|
|
const fromService = this.soma?.dates?.getHourCycle?.();
|
|
if (fromService === 12 || fromService === 24) return fromService;
|
|
// Infer from locale using a 12-hour check.
|
|
const fmt = new DateFormatter(this.effectiveLocale, { hour: 'numeric', minute: 'numeric' });
|
|
return fmt.resolvedOptions().hour12 ? 12 : 24;
|
|
});
|
|
|
|
readonly readonlySegmentsSet = $derived.by(() => new Set(this.opts.readonlySegments.current));
|
|
|
|
readonly validationStatus = $derived.by(() => {
|
|
const value = this.opts.value.current;
|
|
if (!value) return false as const;
|
|
const msg = this.opts.validate.current?.(value as never);
|
|
if (msg) return { reason: 'custom', message: msg } as const;
|
|
const vTime = convertTimeValueToTime(value);
|
|
const minValue = this.opts.minValue.current;
|
|
if (minValue && isTimeBefore(vTime, convertTimeValueToTime(minValue))) {
|
|
return { reason: 'min' } as const;
|
|
}
|
|
const maxValue = this.opts.maxValue.current;
|
|
if (maxValue && isTimeBefore(convertTimeValueToTime(maxValue), vTime)) {
|
|
return { reason: 'max' } as const;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
readonly isInvalid = $derived.by(
|
|
() => this.validationStatus !== false || (this.field?.isInvalid ?? false)
|
|
);
|
|
|
|
readonly timeRef = $derived.by(() =>
|
|
this.opts.value.current !== undefined
|
|
? convertTimeValueToTime(this.opts.value.current)
|
|
: convertTimeValueToTime(this.opts.placeholder.current)
|
|
);
|
|
|
|
readonly allSegmentContent = $derived.by(() =>
|
|
createTimeSegmentContent({
|
|
segmentValues: this.segmentValues,
|
|
timeRef: this.timeRef,
|
|
locale: this.effectiveLocale,
|
|
granularity: this.inferredGranularity,
|
|
hideTimeZone: this.opts.hideTimeZone.current,
|
|
hourCycle: this.resolvedHourCycle
|
|
})
|
|
);
|
|
|
|
readonly segmentContents = $derived.by(() => this.allSegmentContent.arr);
|
|
|
|
readonly sharedSegmentAttrs = {
|
|
role: 'spinbutton' as const,
|
|
contenteditable: 'true' as const,
|
|
tabindex: 0,
|
|
spellcheck: false,
|
|
inputmode: 'numeric' as const,
|
|
autocorrect: 'off' as const,
|
|
enterkeyhint: 'next' as const,
|
|
style: 'caret-color: transparent;',
|
|
onbeforeinput: (e: Event) => e.preventDefault()
|
|
};
|
|
|
|
// ── Label / field helpers ───────────────────────────────────────────────
|
|
|
|
getLabelledBy(segmentId: string): string {
|
|
return `${segmentId} ${this.labelNode?.id ?? ''}`;
|
|
}
|
|
|
|
getLabelNode(): HTMLElement | null {
|
|
return this.labelNode;
|
|
}
|
|
|
|
getEmptyLabel(): string {
|
|
return this.langs(TIME_FIELD_LANGS.EMPTY);
|
|
}
|
|
|
|
getSegmentLabel(part: string): string {
|
|
switch (part) {
|
|
case 'hour':
|
|
return this.langs(TIME_FIELD_LANGS.HOUR);
|
|
case 'minute':
|
|
return this.langs(TIME_FIELD_LANGS.MINUTE);
|
|
case 'second':
|
|
return this.langs(TIME_FIELD_LANGS.SECOND);
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
getDayPeriodLabel(): string {
|
|
return this.langs(TIME_FIELD_LANGS.DAY_PERIOD);
|
|
}
|
|
|
|
getTimeZoneLabel(): string {
|
|
return this.langs(TIME_FIELD_LANGS.TIME_ZONE);
|
|
}
|
|
|
|
langs(idlangref: string): string {
|
|
return this.soma?.langs.ts(idlangref) ?? idlangref;
|
|
}
|
|
|
|
getFieldNode(): HTMLElement | null {
|
|
return this.fieldNode;
|
|
}
|
|
|
|
setFieldNode(el: HTMLElement | null) {
|
|
this.fieldNode = el;
|
|
}
|
|
|
|
setName(name: string) {
|
|
this.name = name;
|
|
}
|
|
|
|
// ── Segment updates ─────────────────────────────────────────────────────
|
|
|
|
updateSegment<P extends EditableTimeSegmentPart>(
|
|
part: P,
|
|
updater: (prev: TimeSegmentObj[P]) => TimeSegmentObj[P]
|
|
): void {
|
|
const previousValue = this.opts.value.current?.toString();
|
|
const prev = { ...this.segmentValues } as TimeSegmentObj;
|
|
const prevVal = prev[part];
|
|
const nextVal = updater(prevVal as TimeSegmentObj[P]);
|
|
const newSegmentValues: TimeSegmentObj = { ...prev, [part]: nextVal };
|
|
this.segmentValues = newSegmentValues;
|
|
|
|
if (areAllTimeSegmentsFilledDOM(newSegmentValues, this.fieldNode)) {
|
|
// Reconstruct the TimeValue.
|
|
let time: TimeValue = this.timeRef;
|
|
const usedSegments = (
|
|
EDITABLE_TIME_SEGMENT_PARTS as readonly EditableTimeSegmentPart[]
|
|
).filter((p) => isEditableTimeSegmentPart(p));
|
|
for (const p of usedSegments) {
|
|
const v = newSegmentValues[p];
|
|
if (v === null) continue;
|
|
if (p === 'dayPeriod') {
|
|
// Handled via hour conversion below.
|
|
continue;
|
|
}
|
|
time = time.set({ [p]: Number.parseInt(v as string) } as never);
|
|
}
|
|
// Apply dayPeriod semantics in 12-hour mode.
|
|
if (this.resolvedHourCycle === 12 && 'dayPeriod' in newSegmentValues) {
|
|
const dp = newSegmentValues.dayPeriod;
|
|
const h = (time as unknown as { hour: number }).hour;
|
|
if (dp === 'PM' && h < 12) time = time.set({ hour: h + 12 } as never);
|
|
if (dp === 'AM' && h >= 12) time = time.set({ hour: h - 12 } as never);
|
|
}
|
|
this.setValue(time);
|
|
} else {
|
|
this.setValue(undefined);
|
|
}
|
|
// Fire the `commit-set` sema event on value change — mirrors date-field so
|
|
// every incrementable field emits the same commit event (morfo declares it;
|
|
// soma fires it). `partRef('input')` is registered by BOTH the visible
|
|
// segment group and the hidden form input; the kebab-keyed runtime registry
|
|
// keeps the last one (the hidden input, whose ref can be null) → target
|
|
// error. Pass the visible field box explicitly so the commit ring (eidos
|
|
// `commit-settle`) lands on it.
|
|
const nextValue = this.opts.value.current?.toString();
|
|
if (previousValue !== nextValue) {
|
|
void this.runtime.trigger('commit-set', { fallbackTarget: this.fieldNode ?? undefined });
|
|
}
|
|
}
|
|
|
|
setValue(v: TimeValue | undefined): void {
|
|
this.opts.value.current = v;
|
|
}
|
|
|
|
syncSegmentValues(value: TimeValue): void {
|
|
const next: TimeSegmentObj = {
|
|
hour: this.padZero('hour', (value as unknown as { hour: number }).hour),
|
|
minute: this.padZero('minute', (value as unknown as { minute: number }).minute),
|
|
second: this.padZero('second', (value as unknown as { second: number }).second),
|
|
dayPeriod: this.states.dayPeriod.updating
|
|
? (this.states.dayPeriod.updating as DayPeriod)
|
|
: this.resolvedHourCycle === 12
|
|
? this.formatter.dayPeriod(
|
|
(() => {
|
|
const dv = convertTimeValueToDateValue(value);
|
|
const tz = isZonedDateTime(dv)
|
|
? (dv as unknown as { timeZone: string }).timeZone
|
|
: 'UTC';
|
|
return dv.toDate(tz);
|
|
})()
|
|
)
|
|
: 'AM'
|
|
};
|
|
this.segmentValues = next;
|
|
this.clearUpdating();
|
|
}
|
|
|
|
private padZero(part: 'hour' | 'minute' | 'second', n: number): string {
|
|
let display = n;
|
|
if (part === 'hour' && this.resolvedHourCycle === 12) {
|
|
if (display === 0) display = 12;
|
|
else if (display > 12) display = display - 12;
|
|
}
|
|
return display < 10 ? `0${display}` : `${display}`;
|
|
}
|
|
|
|
clearUpdating(): void {
|
|
(Object.keys(this.states) as EditableTimeSegmentPart[]).forEach((k) => {
|
|
this.states[k].updating = null;
|
|
});
|
|
}
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
dir: this.opts.dir.current,
|
|
'aria-invalid': this.isInvalid || undefined
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Input ───────────────────────────────────────────────────────────────────
|
|
|
|
export interface TimeFieldInputOpts
|
|
extends
|
|
WithRefOpts,
|
|
ActiveProps<{
|
|
name: string;
|
|
ariaLabel: string | undefined;
|
|
ariaLabelledBy: string | undefined;
|
|
}> {}
|
|
|
|
export class TimeFieldInputProvider {
|
|
readonly opts: TimeFieldInputOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: TimeFieldInputOpts) {
|
|
return new TimeFieldInputProvider(opts);
|
|
}
|
|
|
|
readonly provider: TimeFieldProvider;
|
|
|
|
private constructor(opts: TimeFieldInputOpts) {
|
|
this.opts = opts;
|
|
this.provider = TimeFieldProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('input', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
onRefChange: (v) => this.provider.setFieldNode(v),
|
|
// Per-part source for the morfo's `aria-label` ← `ariaLabel` prop, so
|
|
// `renderProps()` resolves it instead of the provider re-declaring it.
|
|
// (`disabled`/`readonly`/`invalid` come from the component-level sources.)
|
|
props: { ariaLabel: () => this.opts.ariaLabel.current }
|
|
});
|
|
this.provider.setFieldNode(opts.ref.current);
|
|
|
|
watch(
|
|
() => this.opts.name.current,
|
|
(v) => this.provider.setName(v)
|
|
);
|
|
|
|
$effect(() => {
|
|
const field = this.provider.field;
|
|
if (!field) return;
|
|
field.inputId.current = opts.id.current;
|
|
});
|
|
}
|
|
|
|
readonly segments = $derived.by(() => this.provider.segmentContents);
|
|
|
|
readonly ariaDescribedBy = $derived.by(() => {
|
|
try {
|
|
const doc = this.provider.soma.dom.getDocument(this.opts.ref.current);
|
|
const exists = doc.getElementById(this.provider.descriptionId);
|
|
return exists ? this.provider.descriptionId : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
});
|
|
|
|
readonly props = $derived.by(() => {
|
|
const labelledBy = this.opts.ariaLabelledBy.current ?? this.provider.labelNode?.id;
|
|
return this.runtimePart.assert({
|
|
// Morfo-declared attrs (role=group, aria-label, aria-disabled/readonly,
|
|
// aria-invalid, aria-labelledby from the Label part), resolved against
|
|
// this part's registered sources — no longer re-declared here.
|
|
...this.runtimePart.renderProps(),
|
|
// Soma-only: aria-labelledby honours an explicit `ariaLabelledBy` prop
|
|
// (the morfo only knows the Label part); aria-describedby is the computed
|
|
// hidden-description element id (not the morfo's prop); data-invalid/
|
|
// data-disabled are morfo-declared WITHOUT a value.
|
|
'aria-labelledby': labelledBy,
|
|
'aria-describedby': this.ariaDescribedBy,
|
|
'data-invalid': boolToEmptyStrOrUndef(this.provider.isInvalid),
|
|
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled)
|
|
} as const);
|
|
});
|
|
}
|
|
|
|
// ── Label ───────────────────────────────────────────────────────────────────
|
|
|
|
export interface TimeFieldLabelOpts extends WithRefOpts {}
|
|
|
|
export class TimeFieldLabelProvider {
|
|
readonly opts: TimeFieldLabelOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: TimeFieldLabelOpts) {
|
|
return new TimeFieldLabelProvider(opts);
|
|
}
|
|
|
|
readonly provider: TimeFieldProvider;
|
|
|
|
private constructor(opts: TimeFieldLabelOpts) {
|
|
this.opts = opts;
|
|
this.provider = TimeFieldProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('label', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
onRefChange: (v) => {
|
|
this.provider.labelNode = v;
|
|
},
|
|
syncAttrs: true
|
|
});
|
|
}
|
|
|
|
readonly onclick = () => {
|
|
const first = getFirstSegment(this.provider.getFieldNode());
|
|
this.provider.soma.dom.focus(first);
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
onclick: this.onclick
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Hidden input ────────────────────────────────────────────────────────────
|
|
|
|
export interface TimeFieldHiddenInputOpts extends ProviderOpts {}
|
|
|
|
export class TimeFieldHiddenInputProvider {
|
|
readonly opts: TimeFieldHiddenInputOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: TimeFieldHiddenInputOpts) {
|
|
return new TimeFieldHiddenInputProvider(opts);
|
|
}
|
|
|
|
readonly provider: TimeFieldProvider;
|
|
|
|
private constructor(opts: TimeFieldHiddenInputOpts) {
|
|
this.opts = opts;
|
|
this.provider = TimeFieldProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('input', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this
|
|
});
|
|
}
|
|
|
|
readonly props = $derived.by(() => {
|
|
const value = this.provider.opts.value.current;
|
|
// ISO time string (HH:MM:SS[.sss][+TZ]).
|
|
const isoValue = value ? value.toString() : '';
|
|
return {
|
|
type: 'hidden' as const,
|
|
name: this.provider.name,
|
|
value: isoValue,
|
|
required: this.provider.isRequired || undefined,
|
|
'aria-hidden': 'true' as const,
|
|
tabindex: -1
|
|
};
|
|
});
|
|
}
|
|
|
|
// ── Base segment (hour / minute / second) ───────────────────────────────────
|
|
|
|
export abstract class BaseTimeNumericSegmentProvider {
|
|
readonly opts: WithRefOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
readonly provider: TimeFieldProvider;
|
|
protected readonly part: 'hour' | 'minute' | 'second';
|
|
protected readonly config: SegmentConfig;
|
|
|
|
protected constructor(
|
|
opts: WithRefOpts,
|
|
part: 'hour' | 'minute' | 'second',
|
|
config: SegmentConfig
|
|
) {
|
|
this.opts = opts;
|
|
this.part = part;
|
|
this.config = config;
|
|
this.provider = TimeFieldProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('segment', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this
|
|
});
|
|
}
|
|
|
|
protected get announcer(): Announcer {
|
|
return this.provider.announcer;
|
|
}
|
|
|
|
protected getMin(): number {
|
|
return typeof this.config.min === 'function' ? this.config.min(this.provider) : this.config.min;
|
|
}
|
|
|
|
protected getMax(): number {
|
|
return typeof this.config.max === 'function' ? this.config.max(this.provider) : this.config.max;
|
|
}
|
|
|
|
protected formatValue(n: number): string {
|
|
return this.config.padZero && n < 10 ? `0${n}` : `${n}`;
|
|
}
|
|
|
|
protected get directionalKeys() {
|
|
return getDirectionalKeys(this.provider.opts.dir.current, 'horizontal');
|
|
}
|
|
|
|
handleArrowUp(): void {
|
|
const stateKey = this.part as EditableTimeSegmentPart;
|
|
this.provider.states[stateKey].hasLeftFocus = false;
|
|
|
|
this.provider.updateSegment(this.part, (prev) => {
|
|
if (prev === null) {
|
|
const min = this.getMin();
|
|
this.announcer.announce(min);
|
|
return this.formatValue(min);
|
|
}
|
|
const n = Number.parseInt(prev as string);
|
|
const next = n >= this.getMax() ? this.getMin() : n + this.config.cycle;
|
|
this.announcer.announce(next);
|
|
return this.formatValue(next);
|
|
});
|
|
}
|
|
|
|
handleArrowDown(): void {
|
|
const stateKey = this.part as EditableTimeSegmentPart;
|
|
this.provider.states[stateKey].hasLeftFocus = false;
|
|
|
|
this.provider.updateSegment(this.part, (prev) => {
|
|
if (prev === null) {
|
|
const max = this.getMax();
|
|
this.announcer.announce(max);
|
|
return this.formatValue(max);
|
|
}
|
|
const n = Number.parseInt(prev as string);
|
|
const next = n <= this.getMin() ? this.getMax() : n - this.config.cycle;
|
|
this.announcer.announce(next);
|
|
return this.formatValue(next);
|
|
});
|
|
}
|
|
|
|
handleNumberKey(e: SomaKeyboardEvent): void {
|
|
const stateKey = this.part as EditableTimeSegmentPart;
|
|
const num = Number.parseInt(e.key);
|
|
const max = this.getMax();
|
|
const maxStart = Math.floor(max / 10);
|
|
|
|
this.provider.updateSegment(this.part, (prev) => {
|
|
if (this.provider.states[stateKey].hasLeftFocus) {
|
|
prev = null;
|
|
this.provider.states[stateKey].hasLeftFocus = false;
|
|
}
|
|
|
|
if (prev === null) {
|
|
if (num === 0 && !this.config.padZero) {
|
|
return null;
|
|
}
|
|
// First digit — if it would exceed max, treat as complete.
|
|
if (num > maxStart) {
|
|
this.announcer.announce(num);
|
|
moveToNextSegment(
|
|
this.provider.soma.dom,
|
|
e as unknown as KeyboardEvent,
|
|
this.provider.getFieldNode()
|
|
);
|
|
return this.formatValue(num);
|
|
}
|
|
return `${num}`;
|
|
}
|
|
|
|
const prevN = Number.parseInt(prev as string);
|
|
const candidate = prevN * 10 + num;
|
|
|
|
if (candidate <= max) {
|
|
this.announcer.announce(candidate);
|
|
moveToNextSegment(
|
|
this.provider.soma.dom,
|
|
e as unknown as KeyboardEvent,
|
|
this.provider.getFieldNode()
|
|
);
|
|
return this.formatValue(candidate);
|
|
}
|
|
|
|
// Replace instead of append.
|
|
this.announcer.announce(num);
|
|
if (num > maxStart) {
|
|
moveToNextSegment(
|
|
this.provider.soma.dom,
|
|
e as unknown as KeyboardEvent,
|
|
this.provider.getFieldNode()
|
|
);
|
|
}
|
|
return `${num}`;
|
|
});
|
|
}
|
|
|
|
handleBackspace(e: SomaKeyboardEvent): void {
|
|
const stateKey = this.part as EditableTimeSegmentPart;
|
|
this.provider.states[stateKey].hasLeftFocus = false;
|
|
|
|
let moveToPrev = false;
|
|
this.provider.updateSegment(this.part, (prev) => {
|
|
if (prev === null) {
|
|
moveToPrev = true;
|
|
this.announcer.announce(null);
|
|
return null;
|
|
}
|
|
const str = (prev as string).toString();
|
|
if (str.length === 1) {
|
|
this.announcer.announce(null);
|
|
return null;
|
|
}
|
|
const next = Number.parseInt(str.slice(0, -1));
|
|
this.announcer.announce(next);
|
|
return `${next}`;
|
|
});
|
|
|
|
if (moveToPrev)
|
|
moveToPrevSegment(
|
|
this.provider.soma.dom,
|
|
e as unknown as KeyboardEvent,
|
|
this.provider.getFieldNode()
|
|
);
|
|
}
|
|
|
|
protected handleKeydown(e: SomaKeyboardEvent): void {
|
|
if (e.ctrlKey || e.metaKey || this.provider.isDisabled) return;
|
|
if (e.key !== KEYS.TAB) e.preventDefault();
|
|
if (!isAcceptableSegmentKey(e.key)) return;
|
|
|
|
if (this.provider.readonlySegmentsSet.has(this.part)) return;
|
|
|
|
if (e.key === KEYS.ARROW_UP) {
|
|
this.handleArrowUp();
|
|
return;
|
|
}
|
|
if (e.key === KEYS.ARROW_DOWN) {
|
|
this.handleArrowDown();
|
|
return;
|
|
}
|
|
if (isNumberString(e.key)) {
|
|
this.handleNumberKey(e);
|
|
return;
|
|
}
|
|
if (e.key === KEYS.BACKSPACE) {
|
|
this.handleBackspace(e);
|
|
return;
|
|
}
|
|
if (isSegmentNavigationKey(e.key)) {
|
|
const { nextKey, prevKey } = this.directionalKeys;
|
|
handleSegmentNavigation(
|
|
this.provider.soma.dom,
|
|
e as unknown as KeyboardEvent,
|
|
this.provider.getFieldNode(),
|
|
nextKey,
|
|
prevKey
|
|
);
|
|
}
|
|
}
|
|
|
|
onkeydown = (e: SomaKeyboardEvent) => this.handleKeydown(e);
|
|
|
|
onfocusout = (_: SomaFocusEvent) => {
|
|
const stateKey = this.part as EditableTimeSegmentPart;
|
|
this.provider.states[stateKey].hasLeftFocus = true;
|
|
if (this.config.padZero) {
|
|
this.provider.updateSegment(this.part, (prev) => {
|
|
if (prev && (prev as string).length === 1) return `0${prev}`;
|
|
return prev;
|
|
});
|
|
}
|
|
};
|
|
|
|
getSegmentProps() {
|
|
const value = this.provider.segmentValues[this.part];
|
|
const isEmpty = value === null || value === undefined;
|
|
const valueNow = isEmpty ? undefined : Number.parseInt(value as string);
|
|
const valueMin = this.getMin();
|
|
const valueMax = this.getMax();
|
|
let valueText = isEmpty ? this.provider.getEmptyLabel() : `${valueNow}`;
|
|
if (this.part === 'hour' && this.provider.resolvedHourCycle === 12) {
|
|
const dp = (this.provider.segmentValues as TimeSegmentObj).dayPeriod;
|
|
valueText = isEmpty ? this.provider.getEmptyLabel() : `${valueNow} ${dp ?? 'AM'}`;
|
|
}
|
|
return {
|
|
'aria-label': `${this.provider.getSegmentLabel(this.part)}, `,
|
|
'aria-valuemin': valueMin,
|
|
'aria-valuemax': valueMax,
|
|
'aria-valuenow': valueNow,
|
|
'aria-valuetext': valueText
|
|
};
|
|
}
|
|
|
|
readonly props = $derived.by(() => {
|
|
const p = this.getSegmentProps();
|
|
const isReadonly = this.provider.isReadonly || this.provider.readonlySegmentsSet.has(this.part);
|
|
return this.runtimePart.assert({
|
|
// Morfo-declared attrs (role=spinbutton, aria-disabled, aria-invalid),
|
|
// resolved against the component-level sources — no longer re-declared.
|
|
...this.runtimePart.renderProps(),
|
|
...this.provider.sharedSegmentAttrs,
|
|
// Soma-only: data-* are morfo-declared WITHOUT a value; the value-aria
|
|
// (`...p`), per-segment aria-readonly and aria-labelledby are computed
|
|
// here and override the morfo's raw declaration.
|
|
'data-segment': this.part,
|
|
'data-placeholder': boolToEmptyStrOrUndef(this.provider.segmentValues[this.part] === null),
|
|
'data-invalid': boolToEmptyStrOrUndef(this.provider.isInvalid),
|
|
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
|
|
'data-readonly': boolToEmptyStrOrUndef(isReadonly),
|
|
'aria-readonly': boolToStr(isReadonly),
|
|
...p,
|
|
'aria-labelledby': this.provider.getLabelledBy(this.opts.id.current),
|
|
onkeydown: this.onkeydown,
|
|
onfocusout: this.onfocusout
|
|
} as const);
|
|
});
|
|
}
|
|
|
|
class TimeFieldHourSegmentProvider extends BaseTimeNumericSegmentProvider {
|
|
constructor(opts: WithRefOpts) {
|
|
super(opts, 'hour', SEGMENT_CONFIGS.hour);
|
|
}
|
|
}
|
|
class TimeFieldMinuteSegmentProvider extends BaseTimeNumericSegmentProvider {
|
|
constructor(opts: WithRefOpts) {
|
|
super(opts, 'minute', SEGMENT_CONFIGS.minute);
|
|
}
|
|
}
|
|
class TimeFieldSecondSegmentProvider extends BaseTimeNumericSegmentProvider {
|
|
constructor(opts: WithRefOpts) {
|
|
super(opts, 'second', SEGMENT_CONFIGS.second);
|
|
}
|
|
}
|
|
|
|
// ── DayPeriod segment ───────────────────────────────────────────────────────
|
|
|
|
export class TimeFieldDayPeriodSegmentProvider {
|
|
readonly opts: WithRefOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: WithRefOpts) {
|
|
return new TimeFieldDayPeriodSegmentProvider(opts);
|
|
}
|
|
|
|
readonly provider: TimeFieldProvider;
|
|
|
|
private constructor(opts: WithRefOpts) {
|
|
this.opts = opts;
|
|
this.provider = TimeFieldProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('segment', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
onRefChange: (v) => {
|
|
this.provider.dayPeriodNode = v;
|
|
}
|
|
});
|
|
}
|
|
|
|
onkeydown = (e: SomaKeyboardEvent) => {
|
|
if (e.ctrlKey || e.metaKey || this.provider.isDisabled) return;
|
|
if (this.provider.readonlySegmentsSet.has('dayPeriod')) return;
|
|
if (e.key !== KEYS.TAB) e.preventDefault();
|
|
|
|
const flip = () => {
|
|
this.provider.updateSegment('dayPeriod', (prev) => {
|
|
const next: DayPeriod = prev === 'PM' ? 'AM' : 'PM';
|
|
this.provider.announcer.announce(next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
if (
|
|
e.key === KEYS.ARROW_UP ||
|
|
e.key === KEYS.ARROW_DOWN ||
|
|
e.key === KEYS.BACKSPACE ||
|
|
e.key === KEYS.SPACE
|
|
) {
|
|
flip();
|
|
return;
|
|
}
|
|
if (e.key === 'a' || e.key === 'A') {
|
|
this.provider.updateSegment('dayPeriod', () => {
|
|
this.provider.announcer.announce('AM');
|
|
return 'AM';
|
|
});
|
|
return;
|
|
}
|
|
if (e.key === 'p' || e.key === 'P') {
|
|
this.provider.updateSegment('dayPeriod', () => {
|
|
this.provider.announcer.announce('PM');
|
|
return 'PM';
|
|
});
|
|
return;
|
|
}
|
|
if (isSegmentNavigationKey(e.key)) {
|
|
const { nextKey, prevKey } = getDirectionalKeys(this.provider.opts.dir.current, 'horizontal');
|
|
handleSegmentNavigation(
|
|
this.provider.soma.dom,
|
|
e as unknown as KeyboardEvent,
|
|
this.provider.getFieldNode(),
|
|
nextKey,
|
|
prevKey
|
|
);
|
|
}
|
|
};
|
|
|
|
readonly props = $derived.by(() => {
|
|
const value = (this.provider.segmentValues as TimeSegmentObj).dayPeriod;
|
|
const isReadonly =
|
|
this.provider.isReadonly || this.provider.readonlySegmentsSet.has('dayPeriod');
|
|
return this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
...this.provider.sharedSegmentAttrs,
|
|
'data-segment': 'dayPeriod' as const,
|
|
'data-invalid': boolToEmptyStrOrUndef(this.provider.isInvalid),
|
|
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
|
|
'data-readonly': boolToEmptyStrOrUndef(isReadonly),
|
|
'aria-label': this.provider.getDayPeriodLabel(),
|
|
'aria-valuemin': 0,
|
|
'aria-valuemax': 12,
|
|
'aria-valuenow': value === 'AM' ? 0 : 12,
|
|
'aria-valuetext': value ?? 'AM',
|
|
'aria-labelledby': this.provider.getLabelledBy(this.opts.id.current),
|
|
onkeydown: this.onkeydown
|
|
} as const);
|
|
});
|
|
}
|
|
|
|
// ── Literal segment ─────────────────────────────────────────────────────────
|
|
|
|
export class TimeFieldLiteralSegmentProvider {
|
|
readonly opts: WithRefOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: WithRefOpts) {
|
|
return new TimeFieldLiteralSegmentProvider(opts);
|
|
}
|
|
|
|
private constructor(opts: WithRefOpts) {
|
|
this.opts = opts;
|
|
const provider = TimeFieldProvider.require();
|
|
this.runtimePart = provider.runtime.part('segment', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this
|
|
});
|
|
}
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
'aria-hidden': 'true' as const,
|
|
'data-segment': 'literal' as const
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── TimeZone segment (read-only) ────────────────────────────────────────────
|
|
|
|
export class TimeFieldTimeZoneSegmentProvider {
|
|
readonly opts: WithRefOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: WithRefOpts) {
|
|
return new TimeFieldTimeZoneSegmentProvider(opts);
|
|
}
|
|
|
|
readonly provider: TimeFieldProvider;
|
|
|
|
private constructor(opts: WithRefOpts) {
|
|
this.opts = opts;
|
|
this.provider = TimeFieldProvider.require();
|
|
this.runtimePart = this.provider.runtime.part('segment', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this
|
|
});
|
|
}
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
'aria-label': this.provider.getTimeZoneLabel(),
|
|
'data-segment': 'timeZoneName' as const,
|
|
'data-invalid': boolToEmptyStrOrUndef(this.provider.isInvalid),
|
|
'data-readonly': '' as const,
|
|
'aria-readonly': 'true' as const,
|
|
tabindex: -1
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Dispatcher ──────────────────────────────────────────────────────────────
|
|
|
|
export function createTimeSegmentProvider(part: SegmentPart, opts: WithRefOpts) {
|
|
switch (part) {
|
|
case 'hour':
|
|
return new TimeFieldHourSegmentProvider(opts);
|
|
case 'minute':
|
|
return new TimeFieldMinuteSegmentProvider(opts);
|
|
case 'second':
|
|
return new TimeFieldSecondSegmentProvider(opts);
|
|
case 'dayPeriod':
|
|
return TimeFieldDayPeriodSegmentProvider.create(opts);
|
|
case 'timeZoneName':
|
|
return TimeFieldTimeZoneSegmentProvider.create(opts);
|
|
case 'literal':
|
|
default:
|
|
return TimeFieldLiteralSegmentProvider.create(opts);
|
|
}
|
|
}
|