@3mo/date-time-fields 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,247 @@
1
+ import { DirectionsByLanguage, LocalizableString, Localizer } from '@3mo/localization';
2
+ import { FieldDateTimePrecision } from '../FieldDateTimePrecision.js';
3
+ import { isEditableDateTimeSegmentType } from './DateTimeSegment.js';
4
+ const hourCycles = ['h11', 'h12', 'h23', 'h24'];
5
+ /** Reads an `hourCycle` attribute, ignoring anything which is not one of the four cycles. */
6
+ export const hourCycleConverter = (value) => hourCycles.includes(value) ? value : undefined;
7
+ const yearLimit = 9999;
8
+ const placeholderWidths = { year: 4, month: 2, week: 2, day: 2 };
9
+ const timePlaceholder = '--';
10
+ /**
11
+ * Derives the segments of a date-time value for a language, calendar and precision from
12
+ * `Intl.DateTimeFormat.formatToParts`, and edits them with Temporal's calendar arithmetic.
13
+ */
14
+ export class DateTimeSegmenter {
15
+ static { this.pageSteps = { year: 5, month: 2, week: 4, day: 7, hour: 2, minute: 15, second: 15 }; }
16
+ static defaultHourCycle(language) {
17
+ return (new Intl.DateTimeFormat(language, { hour: 'numeric' }).resolvedOptions().hourCycle ?? 'h23');
18
+ }
19
+ constructor(options) {
20
+ this.precision = options.precision;
21
+ this.language = options.language ?? Localizer.languages.current;
22
+ this.calendar = options.calendar ?? DateTime.getCalendar(this.language);
23
+ this.timeZone = options.timeZone ?? DateTime.getTimeZone(this.language);
24
+ this.hourCycle = options.hourCycle ?? DateTimeSegmenter.defaultHourCycle(this.language);
25
+ this.timeOnly = options.timeOnly ?? false;
26
+ this.direction = DirectionsByLanguage.get(this.language);
27
+ this.formatOptions = this.buildFormatOptions();
28
+ this.formatter = new Intl.DateTimeFormat(this.language, this.formatOptions);
29
+ this.monthFormatter = new Intl.DateTimeFormat(this.language, { month: 'long', calendar: this.calendar, timeZone: this.timeZone });
30
+ this.hourFormatter = new Intl.DateTimeFormat(this.language, { hour: 'numeric', hourCycle: this.hourCycle, timeZone: this.timeZone });
31
+ try {
32
+ this.displayNames = new Intl.DisplayNames(this.language, { type: 'dateTimeField' });
33
+ }
34
+ catch {
35
+ this.displayNames = undefined;
36
+ }
37
+ const numberFormat = new Intl.NumberFormat(this.language, { useGrouping: false });
38
+ this.digits = Array.from({ length: 10 }, (_, digit) => numberFormat.format(digit)).join('');
39
+ const dayPeriodOf = (hour) => new Intl.DateTimeFormat(this.language, { hour: 'numeric', hour12: true, timeZone: 'UTC' })
40
+ .formatToParts(new Date(Date.UTC(2026, 0, 1, hour)))
41
+ .find(part => part.type === 'dayPeriod')?.value ?? '';
42
+ this.dayPeriods = [dayPeriodOf(9), dayPeriodOf(21)];
43
+ }
44
+ /** Identifies the configuration, so a consumer can tell when its segmenter is stale. */
45
+ get key() {
46
+ return [this.precision.key, this.language, this.calendar, this.timeZone, this.hourCycle, this.timeOnly].join('|');
47
+ }
48
+ get twelveHours() {
49
+ return this.hourCycle === 'h11' || this.hourCycle === 'h12';
50
+ }
51
+ buildFormatOptions() {
52
+ const precision = this.precision === FieldDateTimePrecision.Week ? FieldDateTimePrecision.Day : this.precision;
53
+ const options = { ...precision.formatOptions, calendar: this.calendar, timeZone: this.timeZone };
54
+ if (this.timeOnly) {
55
+ delete options.year;
56
+ delete options.month;
57
+ delete options.day;
58
+ }
59
+ if (options.hour !== undefined) {
60
+ options.hourCycle = this.hourCycle;
61
+ }
62
+ return options;
63
+ }
64
+ /** Brings a date into this segmenter's calendar and time zone. */
65
+ adopt(date) {
66
+ return date.calendarId === this.calendar && date.timeZoneId === this.timeZone
67
+ ? date
68
+ : DateTime.from(date.valueOf(), this.calendar, this.timeZone);
69
+ }
70
+ /** The editable unit types present, in rendering order. */
71
+ get types() {
72
+ return this.parts(new DateTime).map(part => part.type).filter(isEditableDateTimeSegmentType);
73
+ }
74
+ segments(date, filled) {
75
+ date = this.adopt(date);
76
+ let literals = 0;
77
+ return this.parts(date).map(part => {
78
+ if (!isEditableDateTimeSegmentType(part.type)) {
79
+ // A key identifies a segment within its group, and a date has several literals.
80
+ return { key: part.type === 'literal' ? `literal-${literals++}` : part.type, type: part.type, editable: false, text: part.value };
81
+ }
82
+ const type = part.type;
83
+ const isFilled = filled.has(type);
84
+ const placeholder = this.placeholderOf(type);
85
+ const label = this.labelOf(type);
86
+ const limits = this.limits(date, type);
87
+ return {
88
+ key: type,
89
+ type,
90
+ editable: true,
91
+ filled: isFilled,
92
+ text: isFilled ? part.value : placeholder,
93
+ label,
94
+ // As many digits as the largest value takes, so that typing moves on once it cannot grow.
95
+ capacity: type === 'dayPeriod' ? 1 : String(limits.max).length,
96
+ inputMode: type === 'dayPeriod' ? undefined : 'numeric',
97
+ ...limits,
98
+ value: this.valueOf(date, type),
99
+ valueText: this.valueTextOf(date, type, part.value),
100
+ };
101
+ });
102
+ }
103
+ parts(date) {
104
+ if (this.precision === FieldDateTimePrecision.Week) {
105
+ return [
106
+ { type: 'year', value: (date.yearOfWeek ?? date.year).format(this.language) },
107
+ { type: 'literal', value: ` ${LocalizableString.get('✂Week').localize(this.language)}` },
108
+ { type: 'week', value: (date.weekOfYear ?? 1).format(this.language).padStart(2, this.digits[0]) },
109
+ ];
110
+ }
111
+ return this.formatter.formatToParts(date).map(part => ({ type: this.typeOf(part.type), value: part.value }));
112
+ }
113
+ typeOf(partType) {
114
+ switch (partType) {
115
+ case 'relatedYear':
116
+ return 'year';
117
+ case 'year':
118
+ case 'month':
119
+ case 'day':
120
+ case 'hour':
121
+ case 'minute':
122
+ case 'second':
123
+ case 'dayPeriod':
124
+ case 'era':
125
+ return partType;
126
+ default:
127
+ return 'literal';
128
+ }
129
+ }
130
+ labelOf(type) {
131
+ return this.displayNames?.of(type === 'week' ? 'weekOfYear' : type) ?? type;
132
+ }
133
+ placeholderOf(type) {
134
+ const width = placeholderWidths[type];
135
+ if (!width) {
136
+ return timePlaceholder;
137
+ }
138
+ const word = this.labelOf(type);
139
+ return /^\p{Script=Latin}/u.test(word) ? word[0].toLocaleLowerCase(this.language).repeat(width) : word;
140
+ }
141
+ limits(date, type) {
142
+ date = this.adopt(date);
143
+ switch (type) {
144
+ case 'year': return { min: 1, max: yearLimit };
145
+ case 'month': return { min: 1, max: date.monthsInYear };
146
+ case 'week': return { min: 1, max: DateTimeSegmenter.weeksInYear(date) };
147
+ case 'day': return { min: 1, max: date.daysInMonth };
148
+ case 'hour': return this.hourCycle === 'h12' ? { min: 1, max: 12 } : this.hourCycle === 'h11' ? { min: 0, max: 11 } : { min: 0, max: 23 };
149
+ case 'minute':
150
+ case 'second': return { min: 0, max: 59 };
151
+ case 'dayPeriod': return { min: 0, max: 1 };
152
+ }
153
+ }
154
+ static weeksInYear(date) {
155
+ const lastWeek = date.with({ month: date.monthsInYear, day: 28 }).weekOfYear ?? 52;
156
+ return lastWeek === 1 ? 52 : lastWeek;
157
+ }
158
+ valueOf(date, type) {
159
+ date = this.adopt(date);
160
+ switch (type) {
161
+ case 'year': return date.eraYear ?? date.year;
162
+ case 'month': return date.month;
163
+ case 'week': return date.weekOfYear ?? 1;
164
+ case 'day': return date.day;
165
+ case 'hour': return this.hourCycle === 'h12' ? (date.hour % 12 || 12) : this.hourCycle === 'h11' ? date.hour % 12 : date.hour;
166
+ case 'minute': return date.minute;
167
+ case 'second': return date.second;
168
+ case 'dayPeriod': return date.hour >= 12 ? 1 : 0;
169
+ }
170
+ }
171
+ valueTextOf(date, type, text) {
172
+ switch (type) {
173
+ case 'month':
174
+ return this.monthFormatter.format(date);
175
+ case 'hour':
176
+ return this.hourFormatter.format(date);
177
+ default:
178
+ return text;
179
+ }
180
+ }
181
+ set(date, type, value) {
182
+ date = this.adopt(date);
183
+ switch (type) {
184
+ case 'year':
185
+ return date.era !== undefined && date.eraYear !== undefined
186
+ ? date.with({ era: date.era, eraYear: value })
187
+ : date.with({ year: value });
188
+ case 'month':
189
+ return date.with({ month: value });
190
+ case 'week':
191
+ return date.add({ weeks: value - (date.weekOfYear ?? value) });
192
+ case 'day':
193
+ return date.with({ day: value });
194
+ case 'hour': {
195
+ const afternoon = date.hour >= 12;
196
+ const hour = this.twelveHours ? (value % 12) + (afternoon ? 12 : 0) : value % 24;
197
+ return date.with({ hour });
198
+ }
199
+ case 'minute':
200
+ return date.with({ minute: value });
201
+ case 'second':
202
+ return date.with({ second: value });
203
+ case 'dayPeriod': {
204
+ const afternoon = date.hour >= 12;
205
+ return value >= 1 === afternoon ? date : date.with({ hour: afternoon ? date.hour - 12 : date.hour + 12 });
206
+ }
207
+ }
208
+ }
209
+ /** Moves a unit by `delta`, wrapping at its limits — except the year, which is clamped. */
210
+ step(date, type, delta) {
211
+ date = this.adopt(date);
212
+ const { min, max } = this.limits(date, type);
213
+ const current = this.valueOf(date, type);
214
+ const span = max - min + 1;
215
+ const next = type === 'year'
216
+ ? Math.min(max, Math.max(min, current + delta))
217
+ : ((((current - min + delta) % span) + span) % span) + min;
218
+ return this.set(date, type, next);
219
+ }
220
+ /** The numeric value of a typed character in the language's numbering system or ASCII, if it is a digit. */
221
+ digitOf(character) {
222
+ const index = this.digits.indexOf(character);
223
+ if (index >= 0) {
224
+ return index;
225
+ }
226
+ return /^[0-9]$/.test(character) ? Number(character) : undefined;
227
+ }
228
+ /** Which day period a typed character selects, matching the first character of the localized "AM"/"PM". */
229
+ dayPeriodOf(character) {
230
+ const matches = (period) => !!period && period.localeCompare(character, this.language, { sensitivity: 'base' }) === 0
231
+ || period.toLocaleLowerCase(this.language).startsWith(character.toLocaleLowerCase(this.language));
232
+ if (matches(this.dayPeriods[0])) {
233
+ return 0;
234
+ }
235
+ if (matches(this.dayPeriods[1])) {
236
+ return 1;
237
+ }
238
+ return undefined;
239
+ }
240
+ /** The whole value as one localized string, for the group's accessible description. */
241
+ describe(date) {
242
+ date = this.adopt(date);
243
+ return this.precision === FieldDateTimePrecision.Week
244
+ ? date.format(this.language, { week: 'medium' })
245
+ : this.formatter.format(date);
246
+ }
247
+ }
@@ -0,0 +1,90 @@
1
+ import { Controller, type ReactiveControllerHost } from '@a11d/lit';
2
+ import { type LanguageCode } from '@3mo/localization';
3
+ import { SegmentedInputController } from '@3mo/segmented-input';
4
+ import { type FieldDateTimePrecision } from '../FieldDateTimePrecision.js';
5
+ import { type DateTimeSegment, type EditableDateTimeSegmentType } from './DateTimeSegment.js';
6
+ import { DateTimeSegmenter, type HourCycle } from './DateTimeSegmenter.js';
7
+ export type DateTimeSegmentsControllerOptions = {
8
+ readonly value?: DateTime;
9
+ readonly precision: FieldDateTimePrecision;
10
+ /** Completes the units the user leaves out, and anchors relative shortcuts. Defaults to now. */
11
+ readonly referenceDate?: DateTime;
12
+ readonly language?: LanguageCode;
13
+ readonly calendar?: string;
14
+ readonly timeZone?: string;
15
+ readonly hourCycle?: HourCycle;
16
+ readonly timeOnly?: boolean;
17
+ /** The field's label, appended to every segment's own name. */
18
+ readonly label?: string;
19
+ readonly disabled?: boolean;
20
+ readonly readonly?: boolean;
21
+ readonly required?: boolean;
22
+ readonly invalid?: boolean;
23
+ /** Fired while editing, once every unit is filled and the value differs. */
24
+ handleInput?(value: DateTime | undefined): void;
25
+ /** Fired on commit — leaving the group, Enter, a shortcut or a paste. */
26
+ handleChange?(value: DateTime | undefined): void;
27
+ handleFocusChange?(focused: boolean): void;
28
+ /** Fired when the focus would move past the first or last segment. */
29
+ handleMoveBeyond?(direction: -1 | 1): void;
30
+ /** Resolves a typed shortcut ("+1", "h", "adw") or pasted text to a value. Defaults to `DateTime.parseAsDateTime`. */
31
+ parseShortcut?(text: string, referenceDate: DateTime): DateTime | undefined;
32
+ };
33
+ type OptionsOrFactory<THost> = DateTimeSegmentsControllerOptions | ((host: THost) => DateTimeSegmentsControllerOptions);
34
+ /**
35
+ * A segmented input whose units are the units of a date: one focusable spinbutton each, edited by
36
+ * typing digits in the language's own numbering system or by stepping through the calendar.
37
+ *
38
+ * ```html
39
+ * <div ${this.segments.group.ref()}>
40
+ * ${this.segments.segments.map(segment => html`<span ${this.segments.segment.ref(segment)}></span>`)}
41
+ * </div>
42
+ * ```
43
+ */
44
+ export declare class DateTimeSegmentsController<THost extends ReactiveControllerHost = ReactiveControllerHost> extends Controller {
45
+ protected readonly host: THost;
46
+ protected readonly options: DateTimeSegmentsControllerOptions;
47
+ private _segmenter?;
48
+ private date;
49
+ private filled;
50
+ private syncedValue?;
51
+ readonly input: SegmentedInputController<DateTimeSegment, THost>;
52
+ constructor(host: THost, options: OptionsOrFactory<THost>);
53
+ get segmenter(): DateTimeSegmenter;
54
+ get segments(): ReadonlyArray<DateTimeSegment>;
55
+ /** The reference the unfilled units are taken from. */
56
+ get referenceDate(): import("@3mo/date-time/DateTime.js").DateTime;
57
+ get isEmpty(): boolean;
58
+ get isComplete(): boolean;
59
+ /** The value the filled units describe, normalized to the precision; undefined while a unit is missing. */
60
+ get value(): DateTime | undefined;
61
+ private get editable();
62
+ get group(): import("@a11d/lit").ElementRef<HTMLElement, void>;
63
+ get segment(): import("@a11d/lit").ElementRefs<HTMLElement, DateTimeSegment>;
64
+ focus(type?: EditableDateTimeSegmentType): void;
65
+ focusFirst(): void;
66
+ focusLast(): void;
67
+ hostUpdate(): void;
68
+ private sync;
69
+ private normalize;
70
+ /** A digit which would take the unit past its largest value starts it over: a month holding "1" typed "3" reads "3". */
71
+ private accept;
72
+ private isSegmentComplete;
73
+ /** Sets the unit, unless what is typed so far is not yet a value it could hold — "0" of "05". */
74
+ private handleSegmentInput;
75
+ private handleStep;
76
+ private set;
77
+ private step;
78
+ private clearSegment;
79
+ private replace;
80
+ private lastInput?;
81
+ private edited;
82
+ private applyShortcut;
83
+ private handleCommit;
84
+ private stamp;
85
+ commit(): void;
86
+ /** Empties every segment and clears the host's value. */
87
+ clear(): void;
88
+ }
89
+ export {};
90
+ //# sourceMappingURL=DateTimeSegmentsController.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DateTimeSegmentsController.d.ts","sourceRoot":"","sources":["../../segments/DateTimeSegmentsController.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,235 @@
1
+ import { Controller } from '@a11d/lit';
2
+ import { Localizer } from '@3mo/localization';
3
+ import { SegmentedInputController } from '@3mo/segmented-input';
4
+ import { DateTimeSegmenter } from './DateTimeSegmenter.js';
5
+ Localizer.dictionaries.add('de', {
6
+ 'Empty': 'Leer',
7
+ });
8
+ /**
9
+ * A segmented input whose units are the units of a date: one focusable spinbutton each, edited by
10
+ * typing digits in the language's own numbering system or by stepping through the calendar.
11
+ *
12
+ * ```html
13
+ * <div ${this.segments.group.ref()}>
14
+ * ${this.segments.segments.map(segment => html`<span ${this.segments.segment.ref(segment)}></span>`)}
15
+ * </div>
16
+ * ```
17
+ */
18
+ export class DateTimeSegmentsController extends Controller {
19
+ constructor(host, options) {
20
+ super(host);
21
+ this.host = host;
22
+ this.filled = new Set();
23
+ this.options = typeof options === 'function' ? options(host) : options;
24
+ this.sync();
25
+ const controller = this;
26
+ this.input = new SegmentedInputController(host, {
27
+ get segments() { return controller.segments; },
28
+ get direction() { return controller.segmenter.direction; },
29
+ get label() { return controller.options.label; },
30
+ get description() { return controller.isEmpty ? undefined : controller.segmenter.describe(controller.date); },
31
+ get disabled() { return controller.options.disabled; },
32
+ get readonly() { return controller.options.readonly; },
33
+ get required() { return controller.options.required; },
34
+ get invalid() { return controller.options.invalid; },
35
+ accept: (segment, typed, character) => controller.accept(segment, typed, character),
36
+ isComplete: (segment, text) => controller.isSegmentComplete(segment, text),
37
+ handleSegmentInput: (segment, text) => text ? controller.handleSegmentInput(segment, text) : controller.clearSegment(segment.type),
38
+ handleStep: (segment, step) => controller.handleStep(segment, step),
39
+ // Every keyword resolves to a day, which a time-only field cannot show.
40
+ get handleShortcut() { return controller.options.timeOnly && !controller.options.parseShortcut ? undefined : (text) => controller.applyShortcut(text); },
41
+ handleCommit: () => controller.handleCommit(),
42
+ handleFocusChange: focused => controller.options.handleFocusChange?.(focused),
43
+ handleMoveBeyond: direction => controller.options.handleMoveBeyond?.(direction),
44
+ stamp: (element, segment) => controller.stamp(element, segment),
45
+ });
46
+ }
47
+ get segmenter() {
48
+ const segmenter = new DateTimeSegmenter({
49
+ precision: this.options.precision,
50
+ language: this.options.language,
51
+ calendar: this.options.calendar,
52
+ // The reference's zone, never the value's: a zone which flips between commits would move the wall time.
53
+ timeZone: this.options.timeZone ?? this.options.referenceDate?.timeZoneId,
54
+ hourCycle: this.options.hourCycle,
55
+ timeOnly: this.options.timeOnly,
56
+ });
57
+ if (this._segmenter?.key !== segmenter.key) {
58
+ this._segmenter = segmenter;
59
+ if (this.date) {
60
+ this.date = segmenter.adopt(this.date);
61
+ }
62
+ }
63
+ return this._segmenter;
64
+ }
65
+ get segments() {
66
+ return this.segmenter.segments(this.date, this.filled);
67
+ }
68
+ /** The reference the unfilled units are taken from. */
69
+ get referenceDate() {
70
+ return this.segmenter.adopt(this.options.referenceDate ?? new DateTime);
71
+ }
72
+ get isEmpty() {
73
+ return this.filled.size === 0;
74
+ }
75
+ get isComplete() {
76
+ return this.segmenter.types.every(type => this.filled.has(type));
77
+ }
78
+ /** The value the filled units describe, normalized to the precision; undefined while a unit is missing. */
79
+ get value() {
80
+ return this.isComplete ? this.normalize(this.date) : undefined;
81
+ }
82
+ get editable() {
83
+ return !this.options.disabled && !this.options.readonly;
84
+ }
85
+ // #region Parts
86
+ get group() {
87
+ return this.input.group;
88
+ }
89
+ get segment() {
90
+ return this.input.segment;
91
+ }
92
+ focus(type) {
93
+ this.input.focus(type);
94
+ }
95
+ focusFirst() {
96
+ this.input.focusFirst();
97
+ }
98
+ focusLast() {
99
+ this.input.focusLast();
100
+ }
101
+ // #endregion
102
+ hostUpdate() {
103
+ this.sync();
104
+ }
105
+ sync() {
106
+ const value = this.options.value;
107
+ const segmenter = this.segmenter;
108
+ if (value?.valueOf() !== this.syncedValue || !this.date) {
109
+ this.syncedValue = value?.valueOf();
110
+ this.date = value ? segmenter.adopt(value) : this.referenceDate;
111
+ this.filled = new Set(value ? segmenter.types : []);
112
+ }
113
+ else if (this.filled.size === 0) {
114
+ this.date = this.referenceDate;
115
+ }
116
+ }
117
+ normalize(date) {
118
+ const precision = this.options.precision;
119
+ return this.segmenter.adopt(precision.key === 'week' ? date.weekStart.dayStart : precision.getRange(date).start);
120
+ }
121
+ // #region Editing
122
+ /** A digit which would take the unit past its largest value starts it over: a month holding "1" typed "3" reads "3". */
123
+ accept(segment, typed, character) {
124
+ if (segment.type === 'dayPeriod') {
125
+ const period = this.segmenter.dayPeriodOf(character);
126
+ return period === undefined ? undefined : String(period);
127
+ }
128
+ const digit = this.segmenter.digitOf(character);
129
+ if (digit === undefined) {
130
+ return undefined;
131
+ }
132
+ const text = typed + digit;
133
+ return Number(text) > (segment.max ?? Infinity) ? String(digit) : text;
134
+ }
135
+ isSegmentComplete(segment, text) {
136
+ const max = segment.max ?? 0;
137
+ return Number(text) * 10 > max || text.length >= String(max).length;
138
+ }
139
+ /** Sets the unit, unless what is typed so far is not yet a value it could hold — "0" of "05". */
140
+ handleSegmentInput(segment, text) {
141
+ const value = Number(text);
142
+ if (!isNaN(value) && value >= (segment.min ?? 0)) {
143
+ this.set(segment.type, value);
144
+ }
145
+ }
146
+ handleStep(segment, step) {
147
+ const type = segment.type;
148
+ const page = DateTimeSegmenter.pageSteps[type] ?? 1;
149
+ switch (step) {
150
+ case 'increment': return this.step(type, 1);
151
+ case 'decrement': return this.step(type, -1);
152
+ case 'incrementPage': return this.step(type, page);
153
+ case 'decrementPage': return this.step(type, -page);
154
+ case 'min': return this.set(type, this.segmenter.limits(this.date, type).min);
155
+ case 'max': return this.set(type, this.segmenter.limits(this.date, type).max);
156
+ }
157
+ }
158
+ set(type, value) {
159
+ if (!this.editable) {
160
+ return;
161
+ }
162
+ this.date = this.segmenter.set(this.date, type, value);
163
+ this.filled.add(type);
164
+ this.edited();
165
+ }
166
+ step(type, delta) {
167
+ if (!this.editable) {
168
+ return;
169
+ }
170
+ if (this.filled.has(type)) {
171
+ this.date = this.segmenter.step(this.date, type, delta);
172
+ }
173
+ this.filled.add(type);
174
+ this.edited();
175
+ }
176
+ clearSegment(type) {
177
+ this.filled.delete(type);
178
+ this.date = this.segmenter.set(this.date, type, this.segmenter.valueOf(this.referenceDate, type));
179
+ this.edited();
180
+ }
181
+ replace(date) {
182
+ this.date = this.segmenter.adopt(date);
183
+ this.filled = new Set(this.segmenter.types);
184
+ this.edited();
185
+ }
186
+ edited() {
187
+ const value = this.value;
188
+ // A unit still being typed ("202" of "2026") is not a value anyone wants to hear about yet.
189
+ if (!this.input.typedText && value && value.valueOf() !== this.lastInput && value.valueOf() !== this.options.value?.valueOf()) {
190
+ this.lastInput = value.valueOf();
191
+ this.options.handleInput?.(value);
192
+ }
193
+ }
194
+ // #endregion
195
+ applyShortcut(text) {
196
+ const parsed = this.options.parseShortcut
197
+ ? this.options.parseShortcut(text, this.referenceDate)
198
+ : DateTime.parseAsDateTime(text, this.referenceDate);
199
+ if (parsed) {
200
+ this.replace(parsed);
201
+ }
202
+ else {
203
+ // The host may have taken the value itself (a range keyword sets both ends); follow it.
204
+ this.sync();
205
+ }
206
+ return true;
207
+ }
208
+ handleCommit() {
209
+ let value;
210
+ if (!this.isEmpty) {
211
+ this.filled = new Set(this.segmenter.types);
212
+ value = this.value;
213
+ }
214
+ if (value?.valueOf() !== this.options.value?.valueOf()) {
215
+ this.syncedValue = value?.valueOf();
216
+ this.lastInput = value?.valueOf();
217
+ this.options.handleChange?.(value);
218
+ }
219
+ }
220
+ stamp(element, segment) {
221
+ segment.filled ? element.setAttribute('aria-valuenow', String(segment.value)) : element.removeAttribute('aria-valuenow');
222
+ element.setAttribute('aria-valuemin', String(segment.min));
223
+ element.setAttribute('aria-valuemax', String(segment.max));
224
+ element.setAttribute('aria-valuetext', segment.filled ? segment.valueText ?? segment.text : String(t('Empty')));
225
+ }
226
+ commit() {
227
+ this.input.commit();
228
+ }
229
+ /** Empties every segment and clears the host's value. */
230
+ clear() {
231
+ this.filled = new Set();
232
+ this.date = this.referenceDate;
233
+ this.input.commit();
234
+ }
235
+ }
@@ -0,0 +1,5 @@
1
+ export * from './DateTimeSegment.js';
2
+ export * from './DateTimeSegmenter.js';
3
+ export * from './DateTimeSegmentsController.js';
4
+ export * from './segmentsStyles.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../segments/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,iCAAiC,CAAA;AAC/C,cAAc,qBAAqB,CAAA"}
@@ -0,0 +1,4 @@
1
+ export * from './DateTimeSegment.js';
2
+ export * from './DateTimeSegmenter.js';
3
+ export * from './DateTimeSegmentsController.js';
4
+ export * from './segmentsStyles.js';
@@ -0,0 +1,3 @@
1
+ /** The styling a field gives to a group of date-time segments. */
2
+ export declare const segmentsStyles: import("@a11d/lit").CSSResult;
3
+ //# sourceMappingURL=segmentsStyles.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"segmentsStyles.d.ts","sourceRoot":"","sources":["../../segments/segmentsStyles.ts"],"names":[],"mappings":"AAEA,kEAAkE;AAClE,eAAO,MAAM,cAAc,+BA2C1B,CAAA"}
@@ -0,0 +1,46 @@
1
+ import { css } from '@a11d/lit';
2
+ /** The styling a field gives to a group of date-time segments. */
3
+ export const segmentsStyles = css `
4
+ [part=segments], [part=segments-range] {
5
+ white-space: nowrap;
6
+ cursor: text;
7
+ /* What a browser gives the <input> of the other fields, which never set either themselves. */
8
+ font-size: 13.333px;
9
+ line-height: normal;
10
+ font-variant-numeric: tabular-nums;
11
+ }
12
+
13
+ [part=segments] {
14
+ display: inline;
15
+ }
16
+
17
+ [part=segment], [part=literal] {
18
+ display: inline;
19
+ }
20
+
21
+ [part=segment] {
22
+ border-radius: 2px;
23
+ padding-inline: 1px;
24
+ outline: none;
25
+ caret-color: transparent;
26
+ user-select: none;
27
+
28
+ &:focus {
29
+ background: var(--mo-color-selected);
30
+ color: var(--mo-color-on-selected);
31
+ }
32
+
33
+ &[data-placeholder] {
34
+ color: transparent;
35
+ }
36
+ }
37
+
38
+ [part=literal] {
39
+ color: transparent;
40
+ }
41
+
42
+ mo-field[active] :is([part=segment][data-placeholder], [part=literal]),
43
+ mo-field[populated] :is([part=segment][data-placeholder], [part=literal]) {
44
+ color: var(--mo-color-gray);
45
+ }
46
+ `;
@@ -1 +1 @@
1
- {"version":3,"file":"Calendar.d.ts","sourceRoot":"","sources":["../../selection/Calendar.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAmE,MAAM,WAAW,CAAA;AAGtG,OAAO,EAAE,sBAAsB,EAAE,MAAM,8BAA8B,CAAA;AAErE;;GAEG;AACH,qBACa,QAAS,SAAQ,SAAS;IAC7B,QAAQ,CAAC,SAAS,EAAG,eAAe,CAAC,QAAQ,CAAC,CAAA;IAE3B,KAAK,CAAC,EAAE,aAAa,CAAA;IACqC,SAAS,EAAG,sBAAsB,CAAA;IAC5E,WAAW,UAAQ;IACnC,GAAG,CAAC,EAAE,QAAQ,CAAA;IACd,GAAG,CAAC,EAAE,QAAQ,CAAA;IACF,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,OAAO,CAAA;IAElF,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAoC;IAE3D,IAAI,yBAA6B;IAC1C,OAAO,CAAC,IAAI,EAAE,sBAAsB,EAAE,cAAc,gDAAsC;IAK1F,IAAI,cAAc,kDAAiD;IAC7D,kBAAkB,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,GAAE,SAAS,GAAG,QAAoB;IAWnF,WAAoB,MAAM,kCAwKzB;IAED,OAAO,KAAK,iBAAiB,GAE5B;IAED,OAAO,KAAK,OAAO,GAKlB;IAED,OAAO,CAAC,aAAa;IAIrB,cAAuB,QAAQ,0CAM9B;IAED,OAAO,CAAC,eAAe;IAsBvB,OAAO,CAAC,MAAM,KAAK,gBAAgB,GAiBlC;IAED,OAAO,CAAC,gBAAgB;IA0BxB,SAAS,CAAC,eAAe,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,aAAa,CAAC,QAAQ,CAAC;IA0BxE,OAAO,CAAC,cAAc;IAqBtB,OAAO,CAAC,eAAe,CAmBtB;IAED,OAAO,CAAC,UAAU;IAOlB,OAAO,CAAC,YAAY;IAepB,OAAO,CAAC,KAAK;IAIb,OAAO,CAAC,OAAO;IAIf,OAAO,CAAC,KAAK;IAIb,OAAO,CAAC,SAAS;CAKjB;AAED,OAAO,CAAC,MAAM,CAAC;IACd,UAAU,qBAAqB;QAC9B,aAAa,EAAE,QAAQ,CAAA;KACvB;CACD"}
1
+ {"version":3,"file":"Calendar.d.ts","sourceRoot":"","sources":["../../selection/Calendar.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAmE,MAAM,WAAW,CAAA;AAGtG,OAAO,EAAE,sBAAsB,EAAE,MAAM,8BAA8B,CAAA;AAErE;;GAEG;AACH,qBACa,QAAS,SAAQ,SAAS;IAC7B,QAAQ,CAAC,SAAS,EAAG,eAAe,CAAC,QAAQ,CAAC,CAAA;IAE3B,KAAK,CAAC,EAAE,aAAa,CAAA;IACqC,SAAS,EAAG,sBAAsB,CAAA;IAC5E,WAAW,UAAQ;IACnC,GAAG,CAAC,EAAE,QAAQ,CAAA;IACd,GAAG,CAAC,EAAE,QAAQ,CAAA;IACF,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,OAAO,CAAA;IAElF,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAoC;IAE3D,IAAI,yBAA6B;IAC1C,OAAO,CAAC,IAAI,EAAE,sBAAsB,EAAE,cAAc,gDAAsC;IAK1F,IAAI,cAAc,kDAAiD;IAC7D,kBAAkB,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,GAAE,SAAS,GAAG,QAAoB;IAcnF,WAAoB,MAAM,kCAwKzB;IAED,OAAO,KAAK,iBAAiB,GAE5B;IAED,OAAO,KAAK,OAAO,GAKlB;IAED,OAAO,CAAC,aAAa;IAIrB,cAAuB,QAAQ,0CAM9B;IAED,OAAO,CAAC,eAAe;IAsBvB,OAAO,CAAC,MAAM,KAAK,gBAAgB,GAiBlC;IAED,OAAO,CAAC,gBAAgB;IA0BxB,SAAS,CAAC,eAAe,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,aAAa,CAAC,QAAQ,CAAC;IA0BxE,OAAO,CAAC,cAAc;IAqBtB,OAAO,CAAC,eAAe,CAmBtB;IAED,OAAO,CAAC,UAAU;IAOlB,OAAO,CAAC,YAAY;IAepB,OAAO,CAAC,KAAK;IAIb,OAAO,CAAC,OAAO;IAIf,OAAO,CAAC,KAAK;IAIb,OAAO,CAAC,SAAS;CAKjB;AAED,OAAO,CAAC,MAAM,CAAC;IACd,UAAU,qBAAqB;QAC9B,aAAa,EAAE,QAAQ,CAAA;KACvB;CACD"}
@@ -43,6 +43,9 @@ let Calendar = Calendar_1 = class Calendar extends Component {
43
43
  async setNavigatingValue(date, behavior = 'instant') {
44
44
  this.datesController.disableObservers = true;
45
45
  this.datesController.navigationDate = date;
46
+ // Only a date beyond the generated range requests an update by itself, so without this the
47
+ // item marked as navigating - and thereby scrolled to - would still be the previous one.
48
+ this.requestUpdate();
46
49
  await this.updateComplete;
47
50
  await new Promise(r => setTimeout(r, 10));
48
51
  this.renderRoot.querySelector(`.${this.view}[data-navigating]`)