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.
svelte-kit-vice/src/uix/soma/components/date-field/date-field-provider.svelte.ts

1578 lines
48 KiB

import type { Updater } from 'svelte/store';
import { untrack } from 'svelte';
import { watch } from 'runed';
import { context, type ProviderOpts, type WithRefOpts } from '../../provider';
import { attachRef, type RefAttachment } from '$libs/reactive';
import { boolToEmptyStrOrUndef, boolToStr, boolToTrueOrUndef } from '$adom';
import {
readableActive,
state,
type Active,
type State,
type ActiveProps,
type StateProps
} from '$libs/reactive';
import type { SomaFocusEvent, SomaKeyboardEvent, SomaMouseEvent, OnChangeFn } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { isNumberString } from '../../types/guards';
import { useId } from '../../id';
import { Soma } from '../../core/soma.svelte';
import {
DateFormatter,
dateValueToDate as toDate,
getDaysInMonth,
getDefaultHourCycle,
inferGranularity,
initializeSegmentValues,
isBefore,
resolveDateOrder,
isDateAndTimeSegmentObj,
isDateSegmentPart,
createSegmentContent,
DATE_SEGMENT_PARTS,
EDITABLE_TIME_SEGMENT_PARTS,
type DateValue,
type DateOnInvalid,
type DateOrder,
type DateValidator,
type DateAndTimeSegmentObj,
type DateSegmentObj,
type DateSegmentPart,
type EditableSegmentPart,
type EditableTimeSegmentPart,
type Granularity,
type HourCycle,
type SegmentPart,
type SegmentValueObj,
type TimeSegmentObj
} from '$libs/days';
import {
areAllSegmentsFilledDOM,
getAnnouncer,
getFirstSegment,
getValueFromSegmentsDOM,
handleSegmentNavigation,
initSegmentStates,
isAcceptableSegmentKey,
isFirstSegment,
isSegmentNavigationKey,
moveToNextSegment,
moveToPrevSegment,
removeDescriptionElement,
setDescription,
type Announcer
} from '../../datetime';
import { FieldProvider } from '../field/field-provider.svelte';
import { DATE_FIELD_LANGS } from './langs';
// ── Contract ─────────────────────────────────────────────────────────────────
import { dateFieldMorfo } from '../../../morfo/components/date-field';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
// ── Segment configuration ────────────────────────────────────────────────────
interface SegmentConfig {
min: number | ((provider: DateFieldProvider) => number);
max: number | ((provider: DateFieldProvider) => number);
cycle: number;
canBeZero?: boolean;
padZero?: boolean;
getAnnouncement?: (value: number, provider: DateFieldProvider) => string | number;
}
const SEGMENT_CONFIGS: Record<
'day' | 'month' | 'year' | 'hour' | 'minute' | 'second',
SegmentConfig
> = {
day: {
min: 1,
max: (provider) => {
const segmentMonthValue = (provider.segmentValues as DateSegmentObj).month;
const placeholder = provider.opts.value.current ?? provider.opts.placeholder.current;
return segmentMonthValue
? getDaysInMonth(placeholder.set({ month: Number.parseInt(segmentMonthValue) }))
: getDaysInMonth(placeholder);
},
cycle: 1,
padZero: true
},
month: {
min: 1,
max: 12,
cycle: 1,
padZero: true,
getAnnouncement: (month, provider) => {
const placeholder = provider.opts.placeholder.current;
if (!placeholder) return '';
return `${month} - ${provider.formatter.fullMonth(toDate(placeholder.set({ month })))}`;
}
},
year: { min: 1, max: 9999, cycle: 1, padZero: false },
hour: {
min: (provider) => (provider.resolvedHourCycle === 12 ? 1 : 0),
max: (provider) => {
const hc = provider.resolvedHourCycle;
if (hc === 24) return 23;
return 12;
},
cycle: 1,
canBeZero: true,
padZero: true
},
minute: { min: 0, max: 59, cycle: 1, canBeZero: true, padZero: true },
second: { min: 0, max: 59, cycle: 1, canBeZero: true, padZero: true }
};
type NumericSegmentPart = keyof typeof SEGMENT_CONFIGS;
type NumericDateValue = DateValue & {
cycle(field: NumericSegmentPart, amount: number): DateValue;
};
function readNumericSegment(value: DateValue, part: NumericSegmentPart): number {
return (value as DateValue & Record<NumericSegmentPart, number>)[part];
}
function cycleNumericSegment(value: DateValue, part: NumericSegmentPart, amount: number): number {
return readNumericSegment((value as NumericDateValue).cycle(part, amount), part);
}
// ── Root provider ────────────────────────────────────────────────────────────
export interface DateFieldOpts
extends
ProviderOpts,
StateProps<{
value: DateValue | undefined;
placeholder: DateValue;
}>,
ActiveProps<{
readonlySegments: EditableSegmentPart[];
validate: DateValidator | undefined;
onInvalid: DateOnInvalid | undefined;
minValue: DateValue | undefined;
maxValue: DateValue | undefined;
disabled: boolean;
readonly: boolean;
granularity: Granularity | undefined;
hourCycle: HourCycle | undefined;
locale: string;
hideTimeZone: boolean;
required: boolean;
errorMessageId: string | undefined;
}> {}
export class DateFieldProvider {
readonly opts: DateFieldOpts;
readonly runtimePart: SomaRuntimePart;
readonly runtime: SomaRuntime;
static readonly ctx = context<DateFieldProvider>('DateField');
static get(): DateFieldProvider | undefined {
return this.ctx.getOr(undefined) as DateFieldProvider | undefined;
}
static require(): DateFieldProvider {
return this.ctx.get();
}
static create(opts: DateFieldOpts) {
return new DateFieldProvider(opts);
}
readonly soma = Soma.require();
/** Optional Field parent — when present DateField inherits disabled/readonly/
* required/invalid and its Input registers into Field for `Label`/`HelperText`
* wiring. Mirrors NumberField's integration. */
readonly field = FieldProvider.get();
/** Runtime state. Reactive via Svelte runes in the class body. */
descriptionId = useId('date-field-description');
segmentValues = $state() as SegmentValueObj;
states = initSegmentStates();
announcer: Announcer = getAnnouncer(null);
fieldNode = $state<HTMLElement | null>(null);
labelNode = $state<HTMLElement | null>(null);
dayPeriodNode = $state<HTMLElement | null>(null);
name = $state('');
/**
* Small facade over `DateFormatter` that reads the reactive locale from
* `this.effectiveLocale` on each call. Replaces the former `createFormatter`
* wrapper (which duplicated what dias already provides).
*/
readonly formatter = {
getLocale: () => this.effectiveLocale,
fullMonth: (date: Date): string =>
new DateFormatter(this.effectiveLocale, { month: 'long' }).format(date),
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';
},
selectedDate: (date: DateValue): string => {
const hasTime = 'hour' in date;
const options: Intl.DateTimeFormatOptions = hasTime
? { dateStyle: 'long', timeStyle: 'long' }
: { dateStyle: 'long' };
return new DateFormatter(this.effectiveLocale, options).format(toDate(date));
}
};
private constructor(opts: DateFieldOpts) {
this.opts = opts;
this.runtime = this.soma.runtime(dateFieldMorfo, {
props: {
disabled: () => this.opts.disabled.current,
readonly: () => this.opts.readonly.current,
required: () => this.opts.required.current,
invalid: () => this.isInvalid
},
parts: {
label: () => this.labelNode?.id || undefined
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: DateFieldProvider.ctx,
syncAttrs: true
});
this.segmentValues = initializeSegmentValues(this.inferredGranularity);
$effect(() => {
this.announcer = getAnnouncer(this.soma.dom, opts.ref?.current);
});
// When value is set, populate the hidden description element used for
// screen-reader announcement on focus.
$effect(() => {
if (this.opts.value.current) {
const descId = untrack(() => this.descriptionId);
setDescription({
id: descId,
value: this.formatter.selectedDate(this.opts.value.current),
dom: this.soma.dom,
target: opts.ref?.current
});
}
const placeholder = untrack(() => this.opts.placeholder.current);
if (this.opts.value.current && placeholder !== this.opts.value.current) {
untrack(() => {
if (this.opts.value.current) {
this.opts.placeholder.current = this.opts.value.current;
}
});
}
});
// Initial sync when the provider is created with a value.
if (this.opts.value.current) {
this.syncSegmentValues(this.opts.value.current);
}
// Resync whenever locale changes or value changes.
$effect(() => {
this.opts.locale.current;
if (this.opts.value.current) {
this.syncSegmentValues(this.opts.value.current);
}
this.clearUpdating();
});
// Clearing the value clears the segments too.
$effect(() => {
if (this.opts.value.current === undefined) {
this.segmentValues = initializeSegmentValues(this.inferredGranularity);
}
});
// Fire onInvalid when validation status changes to invalid.
watch(
() => this.validationStatus,
() => {
if (this.validationStatus !== false) {
this.opts.onInvalid.current?.(
this.validationStatus.reason,
this.validationStatus.message
);
}
}
);
// Dev warning: `readonlySegments` without a `value` has no anchor to
// preserve — the lock falls back to the placeholder but that is a
// misconfiguration (placeholder is the empty-state display, not the
// committed value). Warn once per distinct segment list.
{
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(
'DateField',
'`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;
}
});
}
// Cleanup the hidden description element on destroy.
$effect(() => {
return () => {
removeDescriptionElement(this.descriptionId, this.soma.dom, opts.ref?.current);
};
});
}
// ── Resolved services ────────────────────────────────────────────────────
readonly effectiveLocale = $derived.by(
() => this.opts.locale.current || this.soma?.langs.getLocale() || 'en'
);
/**
* Resolved date-field order for segment reordering:
* 1. explicit `locale` prop → resolve from that locale
* 2. App-level service preference (`soma.dates.getDateOrder`)
* 3. locale default via `resolveDateOrder(effectiveLocale)`
* Priority 1 ensures the prop takes precedence over app-wide config so the
* component can be rendered side-by-side in different locales.
*/
readonly dateOrder: DateOrder = $derived.by(() => {
if (this.opts.locale.current) return resolveDateOrder(this.effectiveLocale);
const fromService = this.soma?.dates?.getDateOrder?.();
if (fromService) return fromService as DateOrder;
return resolveDateOrder(this.effectiveLocale);
});
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;
return getDefaultHourCycle(this.effectiveLocale) ?? 24;
});
readonly inferredGranularity = $derived.by(() => {
const granularity = this.opts.granularity.current;
return granularity ?? inferGranularity(this.opts.placeholder.current, granularity);
});
readonly readonlySegmentsSet = $derived.by(() => new Set(this.opts.readonlySegments.current));
// ── Helpers ──────────────────────────────────────────────────────────────
langs(idlangref: string): string {
return this.soma?.langs.ts(idlangref) ?? idlangref;
}
getSegmentLabel(part: string): string {
switch (part) {
case 'year':
return this.langs(DATE_FIELD_LANGS.YEAR);
case 'month':
return this.langs(DATE_FIELD_LANGS.MONTH);
case 'day':
return this.langs(DATE_FIELD_LANGS.DAY);
case 'hour':
return this.langs(DATE_FIELD_LANGS.HOUR);
case 'minute':
return this.langs(DATE_FIELD_LANGS.MINUTE);
case 'second':
return this.langs(DATE_FIELD_LANGS.SECOND);
default:
return part;
}
}
getEmptyLabel(): string {
return this.langs(DATE_FIELD_LANGS.EMPTY);
}
getDayPeriodLabel(): string {
return this.langs(DATE_FIELD_LANGS.DAY_PERIOD);
}
getTimeZoneLabel(): string {
return this.langs(DATE_FIELD_LANGS.TIME_ZONE);
}
setName(name: string): void {
this.name = name;
}
setFieldNode(node: HTMLElement | null): void {
this.fieldNode = node;
}
getFieldNode = (): HTMLElement | null => this.fieldNode;
setLabelNode(node: HTMLElement | null): void {
this.labelNode = node;
}
getLabelNode(): HTMLElement | null {
return this.labelNode;
}
clearUpdating(): void {
this.states.day.updating = null;
this.states.month.updating = null;
this.states.year.updating = null;
this.states.hour.updating = null;
this.states.minute.updating = null;
this.states.second.updating = null;
this.states.dayPeriod.updating = null;
}
setValue(value: DateValue | undefined): void {
this.opts.value.current = value;
}
// ── Sync segment <-> value ───────────────────────────────────────────────
syncSegmentValues(value: DateValue): void {
const dateValues = DATE_SEGMENT_PARTS.map((part) => {
const partValue = value[part];
if (part === 'month') {
if (this.states.month.updating) return [part, this.states.month.updating];
if (partValue < 10) return [part, `0${partValue}`];
}
if (part === 'day') {
if (this.states.day.updating) return [part, this.states.day.updating];
if (partValue < 10) return [part, `0${partValue}`];
}
if (part === 'year') {
if (this.states.year.updating) return [part, this.states.year.updating];
const valueDigits = `${partValue}`.length;
const diff = 4 - valueDigits;
if (diff > 0) return [part, `${'0'.repeat(diff)}${partValue}`];
}
return [part, `${partValue}`];
});
if ('hour' in value) {
const timeValues = EDITABLE_TIME_SEGMENT_PARTS.map((part) => {
if (part === 'dayPeriod') {
if (this.states.dayPeriod.updating) return [part, this.states.dayPeriod.updating];
return [part, this.formatter.dayPeriod(toDate(value))];
}
if (part === 'hour') {
if (this.states.hour.updating) return [part, this.states.hour.updating];
const h = value.hour;
if (h < 10) return [part, `0${h}`];
if (h === 0 && this.dayPeriodNode) {
// 12-hour clock — displayed hour is 12, not 0.
return [part, '12'];
}
}
if (part === 'minute') {
if (this.states.minute.updating) return [part, this.states.minute.updating];
const m = value.minute;
if (m < 10) return [part, `0${m}`];
}
if (part === 'second') {
if (this.states.second.updating) return [part, this.states.second.updating];
const s = value.second;
if (s < 10) return [part, `0${s}`];
}
return [part, `${value[part as keyof typeof value]}`];
});
const merged = [...dateValues, ...timeValues];
this.segmentValues = Object.fromEntries(merged) as SegmentValueObj;
this.clearUpdating();
return;
}
this.segmentValues = Object.fromEntries(dateValues) as SegmentValueObj;
}
// ── Validation ───────────────────────────────────────────────────────────
readonly validationStatus = $derived.by(() => {
const value = this.opts.value.current;
if (!value) return false as const;
const msg = this.opts.validate.current?.(value);
if (msg) return { reason: 'custom', message: msg } as const;
const minValue = this.opts.minValue.current;
if (minValue && isBefore(value, minValue)) return { reason: 'min' } as const;
const maxValue = this.opts.maxValue.current;
if (maxValue && isBefore(maxValue, value)) return { reason: 'max' } as const;
return false;
});
readonly isInvalid = $derived.by(
() => this.validationStatus !== false || (this.field?.isInvalid ?? false)
);
/** Effective disabled — merges own `disabled` with parent Field's. */
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || (this.field?.isDisabled ?? false)
);
/** Effective readonly — merges own `readonly` with parent Field's. */
readonly isReadonly = $derived.by(
() => this.opts.readonly.current || (this.field?.isReadonly ?? false)
);
/** Effective required — merges own `required` with parent Field's. */
readonly isRequired = $derived.by(
() => this.opts.required.current || (this.field?.isRequired ?? false)
);
readonly dateRef = $derived.by(() =>
this.opts.value.current !== undefined ? this.opts.value.current : this.opts.placeholder.current
);
readonly allSegmentContent = $derived.by(() =>
createSegmentContent({
segmentValues: this.segmentValues,
locale: this.effectiveLocale,
granularity: this.inferredGranularity,
dateRef: this.dateRef,
hideTimeZone: this.opts.hideTimeZone.current,
hourCycle: this.resolvedHourCycle,
dateOrder: this.dateOrder
})
);
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;',
// Block every form of text insertion (typed chars, paste, drop, IME
// composition, mobile autocomplete). `keydown.preventDefault()` is not
// enough on its own — IME / composition paths bypass it. The segment
// content is driven entirely by `segmentValues`, so the contenteditable
// surface must never accept direct mutations.
onbeforeinput: (e: Event) => e.preventDefault()
};
getLabelledBy(segmentId: string): string {
return `${segmentId} ${this.getLabelNode()?.id ?? ''}`;
}
// ── Segment update routing ───────────────────────────────────────────────
updateSegment = <T extends keyof DateAndTimeSegmentObj>(
part: T,
cb: T extends DateSegmentPart
? Updater<DateSegmentObj[T]>
: T extends EditableTimeSegmentPart
? Updater<TimeSegmentObj[T]>
: Updater<DateAndTimeSegmentObj[T]>
): void => {
const disabled = this.opts.disabled.current;
const readonly = this.opts.readonly.current;
const readonlySet = this.readonlySegmentsSet;
if (disabled || readonly || readonlySet.has(part as EditableSegmentPart)) return;
const prev = this.segmentValues;
let newSegmentValues: SegmentValueObj = prev;
const dateRef = this.opts.placeholder.current;
if (isDateAndTimeSegmentObj(prev)) {
const pVal = prev[part];
const castCb = cb as Updater<DateAndTimeSegmentObj[T]>;
if (part === 'month') {
const next = castCb(pVal) as DateAndTimeSegmentObj['month'];
this.states.month.updating = next;
if (next !== null && prev.day !== null) {
const date = dateRef.set({ month: Number.parseInt(next) });
const daysInMonth = getDaysInMonth(toDate(date));
const prevDay = Number.parseInt(prev.day);
if (prevDay > daysInMonth) prev.day = `${daysInMonth}`;
}
newSegmentValues = { ...prev, [part]: next };
} else if (part === 'dayPeriod') {
const next = castCb(pVal) as DateAndTimeSegmentObj['dayPeriod'];
this.states.dayPeriod.updating = next;
const date = this.opts.value.current;
if (date && 'hour' in date) {
const trueHour = date.hour;
if (next === 'AM' && trueHour >= 12) prev.hour = `${trueHour - 12}`;
else if (next === 'PM' && trueHour < 12) prev.hour = `${trueHour + 12}`;
}
newSegmentValues = { ...prev, [part]: next };
} else if (part === 'hour') {
const next = castCb(pVal) as DateAndTimeSegmentObj['hour'];
this.states.hour.updating = next;
if (next !== null && prev.dayPeriod !== null) {
const dayPeriod = this.formatter.dayPeriod(
toDate(dateRef.set({ hour: Number.parseInt(next) })),
this.resolvedHourCycle
);
if (dayPeriod === 'AM' || dayPeriod === 'PM') prev.dayPeriod = dayPeriod;
}
newSegmentValues = { ...prev, [part]: next };
} else if (part === 'minute' || part === 'second' || part === 'year' || part === 'day') {
const next = castCb(pVal);
(this.states as Record<string, { updating: unknown }>)[part].updating = next;
newSegmentValues = { ...prev, [part]: next };
} else {
const next = castCb(pVal);
newSegmentValues = { ...prev, [part]: next };
}
} else if (isDateSegmentPart(part)) {
const pVal = prev[part as DateSegmentPart];
const castCb = cb as unknown as Updater<DateSegmentObj[DateSegmentPart]>;
const next = castCb(pVal);
if (part === 'month' && next !== null && prev.day !== null) {
this.states.month.updating = next;
const date = dateRef.set({ month: Number.parseInt(next) });
const daysInMonth = getDaysInMonth(toDate(date));
if (Number.parseInt(prev.day) > daysInMonth) prev.day = `${daysInMonth}`;
newSegmentValues = { ...prev, [part]: next };
} else if (part === 'year' || part === 'day') {
(this.states as Record<string, { updating: unknown }>)[part].updating = next;
newSegmentValues = { ...prev, [part]: next };
} else {
newSegmentValues = { ...prev, [part]: next };
}
}
this.segmentValues = newSegmentValues;
if (areAllSegmentsFilledDOM(newSegmentValues, this.fieldNode)) {
this.setValue(
getValueFromSegmentsDOM({
segmentObj: newSegmentValues,
fieldNode: this.fieldNode,
dateRef: this.opts.placeholder.current
})
);
} else {
this.setValue(undefined);
this.segmentValues = newSegmentValues;
}
};
handleSegmentClick = (e: SomaMouseEvent) => {
if (this.opts.disabled.current) e.preventDefault();
};
getBaseSegmentAttrs(part: SegmentPart, segmentId: string, descriptionNodeId?: string) {
const inReadonly = this.readonlySegmentsSet.has(part as EditableSegmentPart);
const defaultAttrs = {
'aria-invalid': boolToTrueOrUndef(this.isInvalid),
'aria-disabled': boolToStr(this.opts.disabled.current),
'aria-readonly': boolToStr(this.opts.readonly.current || inReadonly),
'data-invalid': boolToEmptyStrOrUndef(this.isInvalid),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current || inReadonly),
'data-segment': `${part}`
};
if (part === 'literal') return defaultAttrs;
const hasDescription = isFirstSegment(segmentId, this.fieldNode) && descriptionNodeId;
const errorMsgId = this.opts.errorMessageId.current;
const describedBy = hasDescription
? `${descriptionNodeId} ${this.isInvalid && errorMsgId ? errorMsgId : ''}`.trim()
: undefined;
const contenteditable = !(
this.opts.readonly.current ||
inReadonly ||
this.opts.disabled.current
);
return {
...defaultAttrs,
'aria-labelledby': this.getLabelledBy(segmentId),
contenteditable: contenteditable ? 'true' : undefined,
'aria-describedby': describedBy,
tabindex: this.opts.disabled.current ? undefined : 0
};
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props
} as const)
);
}
// ── Input (segment container) ───────────────────────────────────────────────
export interface DateFieldInputOpts
extends
WithRefOpts,
ActiveProps<{
name: string;
ariaLabel: string | undefined;
ariaLabelledBy: string | undefined;
}> {}
export class DateFieldInputProvider {
readonly opts: DateFieldInputOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: DateFieldInputOpts) {
return new DateFieldInputProvider(opts);
}
readonly provider: DateFieldProvider;
private constructor(opts: DateFieldInputOpts) {
this.opts = opts;
this.provider = DateFieldProvider.require();
this.runtimePart = this.provider.runtime.part('input', {
id: opts.id,
ref: opts.ref,
owner: this,
onRefChange: (v) => this.provider.setFieldNode(v)
});
this.provider.setFieldNode(opts.ref.current);
watch(
() => this.opts.name.current,
(v) => this.provider.setName(v)
);
// Field integration: register our id so a parent Field can wire its
// Label / HelperText / ErrorText against the Input element.
$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.getLabelNode()?.id ?? undefined;
return this.runtimePart.assert({
...this.runtimePart.props,
role: 'group' as const,
'aria-label': this.opts.ariaLabel.current,
'aria-labelledby': labelledBy,
'aria-describedby': this.ariaDescribedBy,
'aria-disabled': boolToStr(this.provider.opts.disabled.current),
'data-invalid': boolToEmptyStrOrUndef(this.provider.isInvalid),
'data-disabled': boolToEmptyStrOrUndef(this.provider.opts.disabled.current)
} as const);
});
}
// ── Label ──────────────────────────────────────────────────────────────────
export class DateFieldLabelProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: WithRefOpts) {
return new DateFieldLabelProvider(opts);
}
readonly provider: DateFieldProvider;
private constructor(opts: WithRefOpts) {
this.opts = opts;
this.provider = DateFieldProvider.require();
this.runtimePart = this.provider.runtime.part('label', {
id: opts.id,
ref: opts.ref,
owner: this,
onRefChange: (v) => this.provider.setLabelNode(v),
syncAttrs: true
});
this.provider.setLabelNode(opts.ref.current);
}
readonly onclick = (_: SomaMouseEvent) => {
if (this.provider.opts.disabled.current) return;
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)
);
}
// ── HiddenInput ─────────────────────────────────────────────────────────────
export class DateFieldHiddenInputProvider {
static create() {
return new DateFieldHiddenInputProvider();
}
readonly provider: DateFieldProvider;
private constructor() {
this.provider = DateFieldProvider.require();
}
readonly shouldRender = $derived.by(() => this.provider.name !== '');
readonly isoValue = $derived.by(() =>
this.provider.opts.value.current ? this.provider.opts.value.current.toString() : ''
);
readonly props = $derived.by(
() =>
({
type: 'hidden' as const,
name: this.provider.name,
value: this.isoValue,
required: this.provider.opts.required.current,
disabled: this.provider.opts.disabled.current,
'aria-hidden': 'true' as const,
tabindex: -1,
style: 'position:absolute;pointer-events:none;opacity:0;margin:0;width:0;height:0'
}) as const
);
}
// ── Segment providers ───────────────────────────────────────────────────────
function isAcceptableDayPeriodKey(key: string): boolean {
return isAcceptableSegmentKey(key) || key === 'a' || key === 'A' || key === 'p' || key === 'P';
}
function prependYearZeros(year: number): string {
const digits = String(year).length;
const diff = 4 - digits;
return `${'0'.repeat(Math.max(0, diff))}${year}`;
}
abstract class BaseNumericSegmentProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: DateFieldProvider;
readonly part: 'day' | 'month' | 'year' | 'hour' | 'minute' | 'second';
readonly config: SegmentConfig;
protected constructor(
opts: WithRefOpts,
part: 'day' | 'month' | 'year' | 'hour' | 'minute' | 'second',
config: SegmentConfig
) {
this.opts = opts;
this.provider = DateFieldProvider.require();
this.runtimePart = this.provider.runtime.part('segment', {
id: opts.id,
ref: opts.ref,
owner: this
});
this.part = part;
this.config = config;
}
get announcer(): Announcer {
return this.provider.announcer;
}
getMax(): number {
return typeof this.config.max === 'function' ? this.config.max(this.provider) : this.config.max;
}
getMin(): number {
return typeof this.config.min === 'function' ? this.config.min(this.provider) : this.config.min;
}
getAnnouncement(value: number): string | number {
return this.config.getAnnouncement ? this.config.getAnnouncement(value, this.provider) : value;
}
formatValue(value: number, forDisplay = true): string {
const str = String(value);
if (forDisplay && this.config.padZero && str.length === 1) return `0${value}`;
return str;
}
get directionalKeys() {
return getDirectionalKeys(this.provider.soma?.prefs.getDir() ?? 'ltr', 'horizontal');
}
/**
* Shared keydown logic. Extracted as a method so subclasses that `override`
* the `onkeydown` class field can still invoke the base behaviour — class
* fields cannot be reached via `super.`.
*/
protected handleKeydown(e: SomaKeyboardEvent): void {
const placeholder = this.provider.opts.value.current ?? this.provider.opts.placeholder.current;
if (e.ctrlKey || e.metaKey || this.provider.opts.disabled.current) return;
// Time segments only active when the placeholder has a time component.
if (
(this.part === 'hour' || this.part === 'minute' || this.part === 'second') &&
!(this.part in placeholder)
) {
return;
}
if (e.key !== KEYS.TAB) e.preventDefault();
if (!isAcceptableSegmentKey(e.key)) return;
if (e.key === KEYS.ARROW_UP) {
this.handleArrowUp(placeholder);
return;
}
if (e.key === KEYS.ARROW_DOWN) {
this.handleArrowDown(placeholder);
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,
this.provider.getFieldNode(),
nextKey,
prevKey
);
}
}
onkeydown = (e: SomaKeyboardEvent) => this.handleKeydown(e);
handleArrowUp(placeholder: DateValue): void {
const stateKey = this.part as keyof typeof this.provider.states;
if (stateKey in this.provider.states) this.provider.states[stateKey].hasLeftFocus = false;
this.provider.updateSegment(this.part, (prev: string | null) => {
if (prev === null) {
const next = readNumericSegment(placeholder, this.part);
this.announcer.announce(this.getAnnouncement(next));
return this.formatValue(next);
}
const current = placeholder.set({ [this.part]: Number.parseInt(prev) });
const next = cycleNumericSegment(current, this.part, this.config.cycle);
this.announcer.announce(this.getAnnouncement(next));
return this.formatValue(next);
});
}
handleArrowDown(placeholder: DateValue): void {
const stateKey = this.part as keyof typeof this.provider.states;
if (stateKey in this.provider.states) this.provider.states[stateKey].hasLeftFocus = false;
this.provider.updateSegment(this.part, (prev: string | null) => {
if (prev === null) {
const next = readNumericSegment(placeholder, this.part);
this.announcer.announce(this.getAnnouncement(next));
return this.formatValue(next);
}
const current = placeholder.set({ [this.part]: Number.parseInt(prev) });
const next = cycleNumericSegment(current, this.part, -this.config.cycle);
this.announcer.announce(this.getAnnouncement(next));
return this.formatValue(next);
});
}
handleNumberKey(e: SomaKeyboardEvent): void {
const num = Number.parseInt(e.key);
let moveToNext = false;
const max = this.getMax();
const maxStart = Math.floor(max / 10);
const numIsZero = num === 0;
const stateKey = this.part as keyof typeof this.provider.states;
this.provider.updateSegment(this.part, (prev: string | null) => {
if (stateKey in this.provider.states && this.provider.states[stateKey].hasLeftFocus) {
prev = null;
this.provider.states[stateKey].hasLeftFocus = false;
}
if (prev === null) {
if (numIsZero) {
if (stateKey in this.provider.states) {
this.provider.states[stateKey].lastKeyZero = true;
}
this.announcer.announce('0');
return '0';
}
if (
stateKey in this.provider.states &&
(this.provider.states[stateKey].lastKeyZero || num > maxStart)
) {
moveToNext = true;
}
if (stateKey in this.provider.states) {
this.provider.states[stateKey].lastKeyZero = false;
}
if (moveToNext && String(num).length === 1) {
this.announcer.announce(num);
return `0${num}`;
}
return `${num}`;
}
if (stateKey in this.provider.states && this.provider.states[stateKey].lastKeyZero) {
if (num !== 0) {
moveToNext = true;
this.provider.states[stateKey].lastKeyZero = false;
return `0${num}`;
}
if (this.part === 'hour' && num === 0 && this.provider.resolvedHourCycle === 24) {
moveToNext = true;
this.provider.states[stateKey].lastKeyZero = false;
return `00`;
}
if ((this.part === 'minute' || this.part === 'second') && num === 0) {
moveToNext = true;
this.provider.states[stateKey].lastKeyZero = false;
return '00';
}
return prev;
}
const total = Number.parseInt(prev + num.toString());
if (total > max) {
moveToNext = true;
return `0${num}`;
}
moveToNext = true;
return `${total}`;
});
if (moveToNext) moveToNextSegment(this.provider.soma.dom, e, this.provider.getFieldNode());
}
handleBackspace(e: SomaKeyboardEvent): void {
const stateKey = this.part as keyof typeof this.provider.states;
if (stateKey in this.provider.states) this.provider.states[stateKey].hasLeftFocus = false;
let moveToPrev = false;
this.provider.updateSegment(this.part, (prev: string | null) => {
if (prev === null) {
moveToPrev = true;
this.announcer.announce(null);
return null;
}
if (prev.length === 2 && prev.startsWith('0')) {
this.announcer.announce(null);
return null;
}
const str = prev.toString();
if (str.length === 1) {
this.announcer.announce(null);
return null;
}
const next = Number.parseInt(str.slice(0, -1));
this.announcer.announce(this.getAnnouncement(next));
return `${next}`;
});
if (moveToPrev) moveToPrevSegment(this.provider.soma.dom, e, this.provider.getFieldNode());
}
onfocusout = (_: SomaFocusEvent) => {
const stateKey = this.part as keyof typeof this.provider.states;
if (stateKey in this.provider.states) this.provider.states[stateKey].hasLeftFocus = true;
if (this.config.padZero) {
this.provider.updateSegment(this.part, (prev: string | null) => {
if (prev && prev.length === 1) return `0${prev}`;
return prev;
});
}
};
getSegmentProps() {
const segmentValues = this.provider.segmentValues;
const placeholder = this.provider.opts.placeholder.current;
const value = (segmentValues as Record<string, unknown>)[this.part];
const isEmpty = value === null || value === undefined;
let date = placeholder;
if (value) {
date = placeholder.set({ [this.part]: Number.parseInt(value as string) });
}
const valueNow = (date as unknown as Record<string, number>)[this.part];
const valueMin = this.getMin();
const valueMax = this.getMax();
let valueText = isEmpty ? this.provider.getEmptyLabel() : `${valueNow}`;
if (this.part === 'hour' && 'dayPeriod' in segmentValues && segmentValues.dayPeriod) {
valueText = isEmpty
? this.provider.getEmptyLabel()
: `${valueNow} ${segmentValues.dayPeriod}`;
}
return {
'aria-label': `${this.provider.getSegmentLabel(this.part)}, `,
'aria-valuemin': valueMin,
'aria-valuemax': valueMax,
'aria-valuenow': valueNow,
'aria-valuetext': valueText
};
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.provider.sharedSegmentAttrs,
...this.runtimePart.props,
...this.getSegmentProps(),
...this.provider.getBaseSegmentAttrs(
this.part as SegmentPart,
this.opts.id.current,
this.provider.descriptionId
),
onkeydown: this.onkeydown,
onfocusout: this.onfocusout,
onclick: this.provider.handleSegmentClick
} as const)
);
}
class DateFieldDaySegmentProvider extends BaseNumericSegmentProvider {
constructor(opts: WithRefOpts) {
super(opts, 'day', SEGMENT_CONFIGS.day);
}
}
class DateFieldMonthSegmentProvider extends BaseNumericSegmentProvider {
constructor(opts: WithRefOpts) {
super(opts, 'month', SEGMENT_CONFIGS.month);
}
}
class DateFieldHourSegmentProvider extends BaseNumericSegmentProvider {
constructor(opts: WithRefOpts) {
super(opts, 'hour', SEGMENT_CONFIGS.hour);
}
override onkeydown = (e: SomaKeyboardEvent) => {
// Mirror terra: after a number key on hour in 12-hour mode, display 12 instead of 0.
const shouldNormalizeZeroHour =
isNumberString(e.key) &&
!e.ctrlKey &&
!e.metaKey &&
!this.provider.opts.disabled.current &&
!this.provider.opts.readonly.current &&
!this.provider.readonlySegmentsSet.has('hour');
this.handleKeydown(e);
if (shouldNormalizeZeroHour) this.normalizeZeroHour();
};
private normalizeZeroHour(): void {
if (!('hour' in this.provider.segmentValues)) return;
const hourValue = (this.provider.segmentValues as DateAndTimeSegmentObj).hour;
if (
hourValue === '0' &&
this.provider.dayPeriodNode &&
this.provider.resolvedHourCycle !== 24
) {
(this.provider.segmentValues as DateAndTimeSegmentObj).hour = '12';
}
}
}
class DateFieldMinuteSegmentProvider extends BaseNumericSegmentProvider {
constructor(opts: WithRefOpts) {
super(opts, 'minute', SEGMENT_CONFIGS.minute);
}
}
class DateFieldSecondSegmentProvider extends BaseNumericSegmentProvider {
constructor(opts: WithRefOpts) {
super(opts, 'second', SEGMENT_CONFIGS.second);
}
}
class DateFieldYearSegmentProvider extends BaseNumericSegmentProvider {
pressedKeys: string[] = [];
backspaceCount = 0;
constructor(opts: WithRefOpts) {
super(opts, 'year', SEGMENT_CONFIGS.year);
}
override onkeydown = (e: SomaKeyboardEvent) => {
if (e.ctrlKey || e.metaKey || this.provider.opts.disabled.current) return;
if (e.key !== KEYS.TAB) e.preventDefault();
if (!isAcceptableSegmentKey(e.key)) return;
if (e.key === KEYS.ARROW_UP) {
this.resetBackspaceCount();
this.handleArrowUp(
this.provider.opts.value.current ?? this.provider.opts.placeholder.current
);
return;
}
if (e.key === KEYS.ARROW_DOWN) {
this.resetBackspaceCount();
this.handleArrowDown(
this.provider.opts.value.current ?? this.provider.opts.placeholder.current
);
return;
}
if (isNumberString(e.key)) {
this.handleYearNumberKey(e);
return;
}
if (e.key === KEYS.BACKSPACE) {
this.handleYearBackspace(e);
return;
}
if (isSegmentNavigationKey(e.key)) {
const { nextKey, prevKey } = this.directionalKeys;
handleSegmentNavigation(
this.provider.soma.dom,
e,
this.provider.getFieldNode(),
nextKey,
prevKey
);
}
};
resetBackspaceCount() {
this.backspaceCount = 0;
}
handleYearNumberKey(e: SomaKeyboardEvent) {
this.pressedKeys.push(e.key);
let moveToNext = false;
const num = Number.parseInt(e.key);
this.provider.updateSegment('year', (prev) => {
if (this.provider.states.year.hasLeftFocus) {
prev = null;
this.provider.states.year.hasLeftFocus = false;
}
if (prev === null) {
this.announcer.announce(num);
return `000${num}`;
}
const str = prev.toString() + num.toString();
const mergedInt = Number.parseInt(str);
const mergedIntDigits = String(mergedInt).length;
if (mergedIntDigits < 4) {
if (
this.backspaceCount > 0 &&
this.pressedKeys.length <= this.backspaceCount &&
str.length <= 4
) {
this.announcer.announce(mergedInt);
return str;
}
this.announcer.announce(mergedInt);
return prependYearZeros(mergedInt);
}
this.announcer.announce(mergedInt);
moveToNext = true;
const mergedIntStr = `${mergedInt}`;
if (mergedIntStr.length > 4) return mergedIntStr.slice(0, 4);
return mergedIntStr;
});
if (this.pressedKeys.length === 4 || this.pressedKeys.length === this.backspaceCount) {
moveToNext = true;
}
if (moveToNext) moveToNextSegment(this.provider.soma.dom, e, this.provider.getFieldNode());
}
handleYearBackspace(e: SomaKeyboardEvent) {
this.pressedKeys = [];
this.backspaceCount++;
let moveToPrev = false;
this.provider.updateSegment('year', (prev) => {
this.provider.states.year.hasLeftFocus = false;
if (prev === null) {
moveToPrev = true;
this.announcer.announce(null);
return null;
}
const str = prev.toString();
if (str.length === 1) {
this.announcer.announce(null);
return null;
}
const next = str.slice(0, -1);
this.announcer.announce(next);
return `${next}`;
});
if (moveToPrev) moveToPrevSegment(this.provider.soma.dom, e, this.provider.getFieldNode());
}
override onfocusout = (_: SomaFocusEvent) => {
this.provider.states.year.hasLeftFocus = true;
this.pressedKeys = [];
this.resetBackspaceCount();
this.provider.updateSegment('year', (prev) => {
if (prev && prev.length !== 4) return prependYearZeros(Number.parseInt(prev));
return prev;
});
};
}
class DateFieldDayPeriodSegmentProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: DateFieldProvider;
constructor(opts: WithRefOpts) {
this.opts = opts;
this.provider = DateFieldProvider.require();
this.runtimePart = this.provider.runtime.part('segment', {
id: opts.id,
ref: opts.ref,
owner: this,
onRefChange: (v) => {
this.provider.dayPeriodNode = v;
}
});
}
get announcer(): Announcer {
return this.provider.announcer;
}
get directionalKeys() {
return getDirectionalKeys(this.provider.soma?.prefs.getDir() ?? 'ltr', 'horizontal');
}
onkeydown = (e: SomaKeyboardEvent) => {
if (e.ctrlKey || e.metaKey || this.provider.opts.disabled.current) return;
if (e.key !== KEYS.TAB) e.preventDefault();
if (!isAcceptableDayPeriodKey(e.key)) return;
if (e.key === KEYS.ARROW_UP || e.key === KEYS.ARROW_DOWN) {
this.provider.updateSegment('dayPeriod', (prev) => {
const next = prev === 'AM' ? 'PM' : 'AM';
this.announcer.announce(next);
return next;
});
return;
}
if (e.key === KEYS.BACKSPACE) {
this.provider.states.dayPeriod.hasLeftFocus = false;
this.provider.updateSegment('dayPeriod', () => {
this.announcer.announce('AM');
return 'AM';
});
return;
}
if (e.key === 'a' || e.key === 'A' || e.key === 'p' || e.key === 'P') {
this.provider.updateSegment('dayPeriod', () => {
const next = e.key === 'a' || e.key === 'A' ? 'AM' : 'PM';
this.announcer.announce(next);
return next;
});
return;
}
if (isSegmentNavigationKey(e.key)) {
const { nextKey, prevKey } = this.directionalKeys;
handleSegmentNavigation(
this.provider.soma.dom,
e,
this.provider.getFieldNode(),
nextKey,
prevKey
);
}
};
readonly props = $derived.by(() => {
const segmentValues = this.provider.segmentValues;
if (!('dayPeriod' in segmentValues)) return undefined;
const valueMin = 0;
const valueMax = 12;
const valueNow = segmentValues.dayPeriod === 'AM' ? 0 : 12;
const valueText = segmentValues.dayPeriod ?? 'AM';
return this.runtimePart.assert({
...this.provider.sharedSegmentAttrs,
...this.runtimePart.props,
inputmode: 'text' as const,
'aria-label': this.provider.getDayPeriodLabel(),
'aria-valuemin': valueMin,
'aria-valuemax': valueMax,
'aria-valuenow': valueNow,
'aria-valuetext': valueText,
onkeydown: this.onkeydown,
onclick: this.provider.handleSegmentClick,
...this.provider.getBaseSegmentAttrs(
'dayPeriod',
this.opts.id.current,
this.provider.descriptionId
)
} as const);
});
}
class DateFieldLiteralSegmentProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: DateFieldProvider;
constructor(opts: WithRefOpts) {
this.opts = opts;
this.provider = DateFieldProvider.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-hidden': 'true' as const,
...this.provider.getBaseSegmentAttrs(
'literal',
this.opts.id.current,
this.provider.descriptionId
)
} as const)
);
}
class DateFieldTimeZoneSegmentProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: DateFieldProvider;
constructor(opts: WithRefOpts) {
this.opts = opts;
this.provider = DateFieldProvider.require();
this.runtimePart = this.provider.runtime.part('segment', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
get directionalKeys() {
return getDirectionalKeys(this.provider.soma?.prefs.getDir() ?? 'ltr', 'horizontal');
}
onkeydown = (e: SomaKeyboardEvent) => {
if (e.key !== KEYS.TAB) e.preventDefault();
if (this.provider.opts.disabled.current) return;
if (isSegmentNavigationKey(e.key)) {
const { nextKey, prevKey } = this.directionalKeys;
handleSegmentNavigation(
this.provider.soma.dom,
e,
this.provider.getFieldNode(),
nextKey,
prevKey
);
}
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'textbox' as const,
'aria-label': `${this.provider.getTimeZoneLabel()}, `,
style: 'caret-color: transparent;',
onkeydown: this.onkeydown,
...this.provider.getBaseSegmentAttrs(
'timeZoneName',
this.opts.id.current,
this.provider.descriptionId
),
'data-readonly': boolToEmptyStrOrUndef(true)
} as const)
);
}
// ── Segment factory ─────────────────────────────────────────────────────────
export type DateFieldSegmentProviderInstance =
| DateFieldDaySegmentProvider
| DateFieldMonthSegmentProvider
| DateFieldYearSegmentProvider
| DateFieldHourSegmentProvider
| DateFieldMinuteSegmentProvider
| DateFieldSecondSegmentProvider
| DateFieldDayPeriodSegmentProvider
| DateFieldLiteralSegmentProvider
| DateFieldTimeZoneSegmentProvider;
export function createSegmentProvider(
part: SegmentPart,
opts: WithRefOpts
): DateFieldSegmentProviderInstance {
switch (part) {
case 'day':
return new DateFieldDaySegmentProvider(opts);
case 'month':
return new DateFieldMonthSegmentProvider(opts);
case 'year':
return new DateFieldYearSegmentProvider(opts);
case 'hour':
return new DateFieldHourSegmentProvider(opts);
case 'minute':
return new DateFieldMinuteSegmentProvider(opts);
case 'second':
return new DateFieldSecondSegmentProvider(opts);
case 'dayPeriod':
return new DateFieldDayPeriodSegmentProvider(opts);
case 'literal':
return new DateFieldLiteralSegmentProvider(opts);
case 'timeZoneName':
return new DateFieldTimeZoneSegmentProvider(opts);
}
}
// ── Shared exports for wrappers ─────────────────────────────────────────────
export type { OnChangeFn };

Powered by TurnKey Linux.