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)[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('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(null); labelNode = $state(null); dayPeriodNode = $state(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 = ( part: T, cb: T extends DateSegmentPart ? Updater : T extends EditableTimeSegmentPart ? Updater : Updater ): 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; 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)[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; 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)[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)[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)[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 };