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 | 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('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(null); labelNode = $state(null); dayPeriodNode = $state(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

( 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); } }