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.
1578 lines
48 KiB
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 };
|