@0001-labs/design-system-1 0.3.0-alpha.20 → 0.3.0-alpha.21

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.
Files changed (43) hide show
  1. package/DS1/0-face/bento.ts +84 -0
  2. package/DS1/0-face/calendar.ts +2 -2
  3. package/DS1/1-root/bento.css +97 -0
  4. package/DS1/1-root/card.css +6 -1
  5. package/DS1/1-root/cjk.css +1 -1
  6. package/DS1/1-root/controls.css +1 -0
  7. package/DS1/1-root/fonts.css +1 -1
  8. package/DS1/1-root/one.css +1 -1
  9. package/DS1/1-root/one.semantic.css +1 -1
  10. package/DS1/3-unit/calendar-base.ts +31 -22
  11. package/DS1/3-unit/ds-bento.ts +67 -0
  12. package/DS1/3-unit/ds-calendar.ts +477 -22
  13. package/DS1/3-unit/styles/ds-bento.css +66 -0
  14. package/DS1/3-unit/styles/ds-calendar.css +119 -1
  15. package/DS1/index.ts +1 -1
  16. package/dist/0-face/bento.d.ts +41 -0
  17. package/dist/0-face/bento.d.ts.map +1 -0
  18. package/dist/0-face/bento.js +77 -0
  19. package/dist/0-face/calendar.js +2 -2
  20. package/dist/3-unit/calendar-base.d.ts +17 -12
  21. package/dist/3-unit/calendar-base.d.ts.map +1 -1
  22. package/dist/3-unit/calendar-base.js +21 -16
  23. package/dist/3-unit/ds-bento.d.ts +31 -0
  24. package/dist/3-unit/ds-bento.d.ts.map +1 -0
  25. package/dist/3-unit/ds-bento.js +47 -0
  26. package/dist/3-unit/ds-calendar-large.d.ts +3 -4
  27. package/dist/3-unit/ds-calendar-large.d.ts.map +1 -1
  28. package/dist/3-unit/ds-calendar.d.ts +139 -12
  29. package/dist/3-unit/ds-calendar.d.ts.map +1 -1
  30. package/dist/3-unit/ds-calendar.js +464 -23
  31. package/dist/design-system-1.bundle.js +1124 -1094
  32. package/dist/design-system-1.bundle.js.map +1 -1
  33. package/dist/design-system-1.bundle.min.js +2483 -2456
  34. package/dist/design-system-1.bundle.min.js.map +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -1
  38. package/package.json +1 -1
  39. package/DS1/3-unit/ds-date-picker.ts +0 -540
  40. package/DS1/3-unit/styles/ds-date-picker.css +0 -124
  41. package/dist/3-unit/ds-date-picker.d.ts +0 -203
  42. package/dist/3-unit/ds-date-picker.d.ts.map +0 -1
  43. package/dist/3-unit/ds-date-picker.js +0 -499
@@ -2,15 +2,26 @@
2
2
  // A month on whole squares: seven days across, a week to a row. By default a
3
3
  // day is four squares wide and four high (its date on the first square, two
4
4
  // lines of events, one square to spare), drawn in hairline boxes. `compact`
5
- // makes a day one square: the small month a date picker opens and a year is
6
- // drawn from.
5
+ // makes a day one square: the small month a year is drawn from. `picker`
6
+ // makes the calendar a field that opens that small month below it, with the
7
+ // first and last day written out where they can be typed, a switch for a
8
+ // time, and Clear.
9
+ //
10
+ // There is one way to pick, in a month and in a picker: the first day
11
+ // pressed is the value, the next ends a range from it, and leaving after the
12
+ // first keeps the one day. A form takes the value as ISO 8601: a day, a day
13
+ // and a time, or two of them joined by a slash.
7
14
 
8
15
  import { html, nothing, unsafeCSS, type PropertyValues } from "lit";
9
16
  import { define } from "../0-face/define";
17
+ import { getText } from "../0-face/i18n";
18
+ import { dateFormatter, type DateLength } from "../0-face/format";
10
19
  import {
11
20
  addMonths,
12
21
  dateNumber,
22
+ dayOf,
13
23
  eventsByDay,
24
+ isDay,
14
25
  isoWeek,
15
26
  monthTitle,
16
27
  monthWeeks,
@@ -20,7 +31,7 @@ import {
20
31
  type CalendarEvent,
21
32
  type Day,
22
33
  } from "../0-face/calendar";
23
- import { CalendarBase, readMonth, readRange } from "./calendar-base";
34
+ import { CalendarBase, readMonth, readRange, type CalendarChangeDetail } from "./calendar-base";
24
35
  import "../2-core/ds-icon";
25
36
  import metrics from "../1-root/component-metrics.css?inline";
26
37
  import controls from "../1-root/controls.css?inline";
@@ -31,12 +42,59 @@ export * from "./calendar-base";
31
42
  const whole = (value: number | undefined, least: number, most: number, fallback: number) =>
32
43
  typeof value === "number" && Number.isFinite(value) ? Math.max(least, Math.min(most, Math.floor(value))) : fallback;
33
44
 
45
+ /** One end of a picker's value: its day and, when it has one, its time (`14:30`). */
46
+ export interface CalendarEnd {
47
+ day: Day;
48
+ time: string;
49
+ }
50
+
51
+ const TIME = /^([01]\d|2[0-3]):([0-5]\d)$/;
52
+
53
+ /** Reads a value: `2026-10-21`, `2026-10-21T14:30`, or two of them joined by `/`. */
54
+ export function readDateValue(value: string | null | undefined): { start?: CalendarEnd; end?: CalendarEnd } {
55
+ const read = (part: string | undefined): CalendarEnd | undefined => {
56
+ const [day = "", time = ""] = (part ?? "").trim().split("T");
57
+ if (!isDay(day)) return undefined;
58
+ const clock = time.slice(0, 5);
59
+ return { day, time: TIME.test(clock) ? clock : "" };
60
+ };
61
+ const [first, second] = (value ?? "").split("/");
62
+ let start = read(first), end = read(second);
63
+ if (start && end && `${end.day}T${end.time}` < `${start.day}T${start.time}`) [start, end] = [end, start];
64
+ return { start, end };
65
+ }
66
+
67
+ /**
68
+ * Writes a value from its ends, the times only with `time`. An end that is
69
+ * the same moment as the start leaves the value one day.
70
+ */
71
+ export function writeDateValue(start: CalendarEnd | undefined, end: CalendarEnd | undefined, options: { time: boolean }): string {
72
+ if (!start) return "";
73
+ const part = (side: CalendarEnd) => (options.time && side.time ? `${side.day}T${side.time}` : side.day);
74
+ return end && part(end) !== part(start) ? `${part(start)}/${part(end)}` : part(start);
75
+ }
76
+
77
+ /** Reads what a person typed: a day written `YYYY-MM-DD`, or a date the browser can read ("Oct 21, 2026"). */
78
+ export function parseTypedDay(text: string): Day | null {
79
+ const trimmed = text.trim();
80
+ if (isDay(trimmed)) return trimmed;
81
+ const moment = Date.parse(trimmed);
82
+ return Number.isFinite(moment) ? dayOf(new Date(moment)) : null;
83
+ }
84
+
85
+ const openPickers = new Set<Calendar>();
86
+
34
87
  /**
35
88
  * @element ds-calendar
36
89
  * @prop {string} month - The month shown, `YYYY-MM` (or a day in it). The
37
90
  * selected day's month, or today's, when left out; the arrows change it.
38
- * @prop {string} value - The selected day, `YYYY-MM-DD`.
39
- * @prop {boolean} selectable - Pressing a day selects it and fires `change`.
91
+ * @prop {string} value - The selected day, `YYYY-MM-DD`, or a range,
92
+ * `YYYY-MM-DD/YYYY-MM-DD`; in a picker either end may carry a time
93
+ * (`2026-10-21T09:00`).
94
+ * @prop {boolean} selectable - Pressing a day selects it and fires `change`:
95
+ * the first press one day, the next the end of a range from it.
96
+ * @prop {boolean} picker - A field one control high that opens the small
97
+ * month below it, with the days to type, Include time and Clear.
40
98
  * @prop {boolean} compact - One square per day, no events written out: the
41
99
  * small month.
42
100
  * @prop {number} dayWidth - Squares per day (`day-width`): 4 by default, 1
@@ -47,8 +105,6 @@ const whole = (value: number | undefined, least: number, most: number, fallback:
47
105
  * @prop {boolean} fixed - Always draw six weeks, so the month keeps its height.
48
106
  * @prop {boolean} outsideDays - Draw the days of the months before and after
49
107
  * in the title grey (`outside-days`); pressing one moves there.
50
- * @prop {boolean} range - A selectable calendar chooses a first and a last
51
- * day; `value` is then `YYYY-MM-DD/YYYY-MM-DD`.
52
108
  * @prop {boolean} noHeader - Leave out the title and arrows (`no-header`).
53
109
  * @prop {boolean} noToday - Leave out the Today button (`no-today`).
54
110
  * @prop {string} min - The first day a person may choose.
@@ -56,20 +112,36 @@ const whole = (value: number | undefined, least: number, most: number, fallback:
56
112
  * @prop {string} weekStart - The week's first day (`week-start`), 0–6 from
57
113
  * Sunday or its English name; the language's own by default.
58
114
  * @prop {string} today - The day drawn as today; the reader's own by default.
115
+ * @prop {string} label - The grid's name, or a picker's: a translation key,
116
+ * or literal words with `literal`.
117
+ * @prop {string} name - The form field's name.
118
+ * @prop {string} placeholder - A picker's words before a day is chosen; the
119
+ * shared "Choose a date" by default.
120
+ * @prop {string} length - How a picker writes a day: `short`, `medium` (the
121
+ * default) or `long`.
122
+ * @prop {boolean} time - A picker chooses a time with each day (Include time).
123
+ * @prop {boolean} required - A form needs a value; a picker leaves Clear out.
124
+ * @prop {boolean} disabled - A picker cannot be opened.
125
+ * @prop {boolean} open - A picker's month is open.
59
126
  * @prop {CalendarEvent[]} events - What happens on the days (property only).
60
127
  *
61
128
  * @fires day-press - A day was pressed or chosen with Enter or Space;
62
129
  * `{ day, events }`. Cancelable: cancelled, a selectable calendar keeps its value.
63
- * @fires change - A person selected a day; `{ value, previous }`.
130
+ * @fires change - A person selected a day or ended a range;
131
+ * `{ value, previous, start, end }`, `end` empty while it is still to come.
132
+ * @fires input - A picker's value changed, the same moment as `change`.
64
133
  * @fires event-press - An event was pressed; `{ event, day }`.
65
134
  * @fires navigate - A person moved to another month; `{ month }`.
135
+ * @fires ds-calendar-toggle - A picker's month opened or closed; `{ open }`.
66
136
  *
67
137
  * @example
68
138
  * <ds-calendar month="2026-10" week-numbers></ds-calendar>
69
- * <ds-calendar compact selectable value="2026-10-18"></ds-calendar>
70
- * <ds-calendar compact selectable range value="2026-10-18/2026-10-24"></ds-calendar>
139
+ * <ds-calendar compact selectable value="2026-10-18/2026-10-24"></ds-calendar>
140
+ * <ds-calendar picker label="When" literal name="when" value="2026-10-21/2026-10-24"></ds-calendar>
71
141
  */
72
142
  export class Calendar extends CalendarBase {
143
+ static formAssociated = true;
144
+
73
145
  static properties = {
74
146
  ...CalendarBase.properties,
75
147
  month: { type: String, reflect: true },
@@ -81,7 +153,15 @@ export class Calendar extends CalendarBase {
81
153
  outsideDays: { type: Boolean, attribute: "outside-days", reflect: true },
82
154
  noHeader: { type: Boolean, attribute: "no-header", reflect: true },
83
155
  noToday: { type: Boolean, attribute: "no-today", reflect: true },
84
- open: { state: true },
156
+ picker: { type: Boolean, reflect: true },
157
+ name: { type: String, reflect: true },
158
+ placeholder: { type: String },
159
+ length: { type: String },
160
+ time: { type: Boolean, reflect: true },
161
+ required: { type: Boolean, reflect: true },
162
+ disabled: { type: Boolean, reflect: true },
163
+ open: { type: Boolean, reflect: true },
164
+ unfolded: { state: true },
85
165
  };
86
166
 
87
167
  static styles = [unsafeCSS(metrics), unsafeCSS(controls), unsafeCSS(styles)];
@@ -95,12 +175,26 @@ export class Calendar extends CalendarBase {
95
175
  declare outsideDays: boolean;
96
176
  declare noHeader: boolean;
97
177
  declare noToday: boolean;
178
+ declare picker: boolean;
179
+ declare name: string | undefined;
180
+ declare placeholder: string | undefined;
181
+ declare length: DateLength | undefined;
182
+ declare time: boolean;
183
+ declare required: boolean;
184
+ declare disabled: boolean;
185
+ declare open: boolean;
98
186
  /** The day whose hidden events are unfolded. */
99
- declare open: Day | undefined;
187
+ declare unfolded: Day | undefined;
100
188
 
101
189
  protected readonly weeksRun = "rows" as const;
102
190
 
103
191
  private byDay = new Map<Day, CalendarEvent[]>();
192
+ private readonly internals = typeof this.attachInternals === "function" ? this.attachInternals() : null;
193
+ /** The value the field started with, for a form's reset. */
194
+ private initial = "";
195
+ private shown = false;
196
+ /** Whether opening a picker should move focus into the month (a key opened it). */
197
+ private focusMonth = false;
104
198
 
105
199
  constructor() {
106
200
  super();
@@ -110,6 +204,29 @@ export class Calendar extends CalendarBase {
110
204
  this.outsideDays = false;
111
205
  this.noHeader = false;
112
206
  this.noToday = false;
207
+ this.picker = false;
208
+ this.time = false;
209
+ this.required = false;
210
+ this.disabled = false;
211
+ this.open = false;
212
+ }
213
+
214
+ disconnectedCallback() {
215
+ this.hide(false);
216
+ super.disconnectedCallback();
217
+ }
218
+
219
+ protected firstUpdated() {
220
+ this.initial = this.value;
221
+ }
222
+
223
+ /** The form was reset: back to the value it started with. */
224
+ formResetCallback() {
225
+ this.value = this.initial;
226
+ }
227
+
228
+ formDisabledCallback(disabled: boolean) {
229
+ this.disabled = disabled;
113
230
  }
114
231
 
115
232
  /** The first of the month shown. */
@@ -136,6 +253,26 @@ export class Calendar extends CalendarBase {
136
253
  // A compact month with events writes the days without any fainter.
137
254
  this.toggleAttribute("data-events", (this.events ?? []).length > 0);
138
255
  this.toggleAttribute("data-roomy", this.squaresPerDay >= 2);
256
+ if (this.picker && changed.has("value") && this.value) {
257
+ const { start, end } = this.ends;
258
+ // A value with a time turns the switch on.
259
+ if (!start) this.value = "";
260
+ else if (start.time || end?.time) this.time = true;
261
+ }
262
+ if (changed.has("disabled") && this.disabled) this.open = false;
263
+ }
264
+
265
+ protected updated(changed: PropertyValues<this>) {
266
+ super.updated(changed);
267
+ if (changed.has("value") || changed.has("required")) {
268
+ this.internals?.setFormValue(this.value || null);
269
+ if (this.required && !this.value) this.internals?.setValidity({ valueMissing: true }, this.pickerWords(undefined, "Choose a date"), this.trigger ?? undefined);
270
+ else this.internals?.setValidity({});
271
+ }
272
+ if (changed.has("open") || changed.has("picker")) {
273
+ if (this.open && this.picker) this.show();
274
+ else this.hide(changed.get("open") === true);
275
+ }
139
276
  }
140
277
 
141
278
  /** Shows another month and says so with `navigate`. */
@@ -143,7 +280,7 @@ export class Calendar extends CalendarBase {
143
280
  const first = readMonth(month);
144
281
  if (!first || first === this.shownMonth) return;
145
282
  this.month = first.slice(0, 7);
146
- this.open = undefined;
283
+ this.unfolded = undefined;
147
284
  if (announce) this.announceMonth(first);
148
285
  }
149
286
 
@@ -179,9 +316,9 @@ export class Calendar extends CalendarBase {
179
316
  this.press(day);
180
317
  return;
181
318
  }
182
- if (event.key === "Escape" && this.open) {
319
+ if (event.key === "Escape" && this.unfolded) {
183
320
  event.stopPropagation();
184
- this.open = undefined;
321
+ this.unfolded = undefined;
185
322
  return;
186
323
  }
187
324
  const target = this.keyTarget(event, day);
@@ -199,7 +336,7 @@ export class Calendar extends CalendarBase {
199
336
  const day = cell.dataset.day as Day;
200
337
  const more = find("[data-more]");
201
338
  if (more) {
202
- this.open = this.open === day ? undefined : day;
339
+ this.unfolded = this.unfolded === day ? undefined : day;
203
340
  return;
204
341
  }
205
342
  const chip = find("[data-event]");
@@ -216,16 +353,14 @@ export class Calendar extends CalendarBase {
216
353
 
217
354
  /** While a range waits for its end, the day under the pointer previews it. */
218
355
  private onPointerOver = (event: PointerEvent) => {
219
- if (!this.range || !this.selectable) return;
220
- const { start, end } = readRange(this.value);
221
- if (!start || end) return;
356
+ if (!this.endPending || !this.selectable) return;
222
357
  const cell = (event.composedPath() as HTMLElement[]).find(node => node instanceof HTMLElement && node.dataset?.day);
223
358
  const day = cell?.dataset.day as Day | undefined;
224
359
  if (day !== this.hoverDay && (!day || this.allowed(day))) this.hoverDay = day;
225
360
  };
226
361
 
227
362
  private onFocusOut = (event: FocusEvent) => {
228
- if (this.open && !(event.relatedTarget instanceof Node && this.renderRoot.contains(event.relatedTarget))) this.open = undefined;
363
+ if (this.unfolded && !(event.relatedTarget instanceof Node && this.renderRoot.contains(event.relatedTarget))) this.unfolded = undefined;
229
364
  };
230
365
 
231
366
  private renderHeader() {
@@ -262,7 +397,7 @@ export class Calendar extends CalendarBase {
262
397
  const isToday = day === this.todayDay;
263
398
  const { selected, inside, first, last } = this.selection(day);
264
399
  const allowed = this.allowed(day);
265
- const unfolded = this.open === day;
400
+ const unfolded = this.unfolded === day;
266
401
  const shown = unfolded || events.length <= lines ? events : events.slice(0, Math.max(0, lines - 1));
267
402
  const hidden = events.length - shown.length;
268
403
  const parts = ["day", isToday ? "today" : "", selected ? "selected" : ""].filter(Boolean).join(" ");
@@ -304,6 +439,7 @@ export class Calendar extends CalendarBase {
304
439
  }
305
440
 
306
441
  render() {
442
+ if (this.picker) return this.renderPicker();
307
443
  const month = this.shownMonth;
308
444
  const first = this.firstDay;
309
445
  const weeks = monthWeeks(month, first, this.fixed);
@@ -322,7 +458,7 @@ export class Calendar extends CalendarBase {
322
458
  part="grid"
323
459
  role="grid"
324
460
  aria-label=${this.nameOr(monthTitle(month))}
325
- aria-multiselectable=${this.selectable ? String(this.range) : nothing}
461
+ aria-multiselectable=${this.selectable ? "true" : nothing}
326
462
  @keydown=${this.onKeyDown}
327
463
  @click=${this.onClick}
328
464
  >
@@ -333,6 +469,325 @@ export class Calendar extends CalendarBase {
333
469
  </div>
334
470
  </div>`;
335
471
  }
472
+
473
+ // The picker: a field that opens the small month.
474
+
475
+ private pickerWords(value: string | undefined, fallback: string): string {
476
+ if (value && this.literal) return value;
477
+ return getText(value || fallback) || value || fallback;
478
+ }
479
+
480
+ private get trigger() {
481
+ return this.renderRoot.querySelector<HTMLButtonElement>(".trigger");
482
+ }
483
+
484
+ private get panel() {
485
+ return this.renderRoot.querySelector<HTMLElement>(".popover");
486
+ }
487
+
488
+ /** The small month inside the picker. */
489
+ private get smallMonth() {
490
+ return this.renderRoot.querySelector<Calendar>("ds-calendar");
491
+ }
492
+
493
+ private get ends() {
494
+ return readDateValue(this.value);
495
+ }
496
+
497
+ /** Opens a picker's month. */
498
+ show() {
499
+ if (this.shown || !this.picker || this.disabled || !this.isConnected) return;
500
+ const panel = this.panel;
501
+ if (!panel) return;
502
+ for (const other of openPickers) if (other !== this) other.open = false;
503
+ openPickers.add(this);
504
+ this.shown = true;
505
+ this.open = true;
506
+ const month = this.smallMonth;
507
+ if (month) {
508
+ month.month = (this.ends.start?.day || this.today || this.todayDay).slice(0, 7);
509
+ // Each opening starts a new pick: the first press is a first day.
510
+ month.endPending = false;
511
+ }
512
+ panel.showPopover?.();
513
+ this.place();
514
+ if (month) void month.updateComplete.then(this.place);
515
+ this.ownerDocument.addEventListener("pointerdown", this.onOutside, true);
516
+ this.ownerDocument.addEventListener("scroll", this.place, true);
517
+ window.addEventListener("resize", this.place);
518
+ this.dispatchEvent(new CustomEvent("ds-calendar-toggle", { bubbles: true, composed: true, detail: { open: true } }));
519
+ if (this.focusMonth) void this.focusPickerDay();
520
+ }
521
+
522
+ /** Closes a picker's month, giving focus back to the field when it was inside. */
523
+ hide(restore = true) {
524
+ if (!this.shown) return;
525
+ this.shown = false;
526
+ openPickers.delete(this);
527
+ const panel = this.panel;
528
+ const within = panel?.matches(":focus-within");
529
+ if (panel?.matches(":popover-open")) panel.hidePopover?.();
530
+ this.ownerDocument.removeEventListener("pointerdown", this.onOutside, true);
531
+ this.ownerDocument.removeEventListener("scroll", this.place, true);
532
+ window.removeEventListener("resize", this.place);
533
+ this.open = false;
534
+ if (restore && within) this.trigger?.focus({ preventScroll: true });
535
+ this.dispatchEvent(new CustomEvent("ds-calendar-toggle", { bubbles: true, composed: true, detail: { open: false } }));
536
+ }
537
+
538
+ private async focusPickerDay() {
539
+ const month = this.smallMonth;
540
+ if (!month) return;
541
+ await month.updateComplete;
542
+ month.renderRoot.querySelector<HTMLElement>('[data-day][tabindex="0"]')?.focus({ preventScroll: true });
543
+ }
544
+
545
+ /** Below the field, half a square from it, or above when there is more room there; held inside the screen. */
546
+ private place = () => {
547
+ const panel = this.panel, trigger = this.trigger;
548
+ if (!this.shown || !panel || !trigger) return;
549
+ const rect = trigger.getBoundingClientRect();
550
+ const view = this.ownerDocument.defaultView!;
551
+ const viewport = view.visualViewport;
552
+ const left = viewport?.offsetLeft ?? 0, top = viewport?.offsetTop ?? 0;
553
+ const width = viewport?.width ?? view.innerWidth, height = viewport?.height ?? view.innerHeight;
554
+ const box = panel.getBoundingClientRect();
555
+ const gap = (parseFloat(getComputedStyle(panel).paddingTop) || 9) + 1;
556
+ // The face is one control high at the foot of its square: measure from that square.
557
+ const square = Math.max(rect.height, gap * 2);
558
+ const foot = rect.bottom, head = rect.bottom - square;
559
+ const below = top + height - foot - 2 * gap, above = head - top - 2 * gap;
560
+ const up = below < box.height && above > below;
561
+ const x = getComputedStyle(this).direction === "rtl" ? rect.right - box.width : rect.left;
562
+ panel.style.left = `${Math.max(left + gap, Math.min(x, left + width - gap - box.width))}px`;
563
+ panel.style.top = `${Math.max(top + gap, up ? head - gap - box.height : foot + gap)}px`;
564
+ };
565
+
566
+ private onOutside = (event: Event) => {
567
+ if (!event.composedPath().includes(this)) this.open = false;
568
+ };
569
+
570
+ private toggle = () => {
571
+ if (this.disabled) return;
572
+ this.focusMonth = false;
573
+ this.open = !this.open;
574
+ };
575
+
576
+ private onTriggerKey = (event: KeyboardEvent) => {
577
+ if (this.disabled) return;
578
+ if (event.key === "ArrowDown" || event.key === "ArrowUp" || ((event.key === "Enter" || event.key === " ") && !this.open)) {
579
+ event.preventDefault();
580
+ this.focusMonth = true;
581
+ if (this.open) void this.focusPickerDay();
582
+ else this.open = true;
583
+ }
584
+ };
585
+
586
+ private onPanelKey = (event: KeyboardEvent) => {
587
+ if (event.key === "Escape" && !event.defaultPrevented) {
588
+ event.preventDefault();
589
+ event.stopPropagation();
590
+ this.open = false;
591
+ }
592
+ };
593
+
594
+ private onPanelFocusOut = (event: FocusEvent) => {
595
+ const next = event.relatedTarget;
596
+ if (this.open && next instanceof Node && !this.contains(next) && !this.renderRoot.contains(next)) this.open = false;
597
+ };
598
+
599
+ /** Takes a new value and says so. */
600
+ private commit(value: string) {
601
+ const previous = this.value || "";
602
+ if (value === previous) return;
603
+ this.value = value;
604
+ const { start, end } = readDateValue(value);
605
+ const detail: CalendarChangeDetail = { value, previous, start: start?.day ?? "", end: end?.day ?? start?.day ?? "" };
606
+ this.dispatchEvent(new CustomEvent("input", { bubbles: true, composed: true, detail }));
607
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true, composed: true, detail }));
608
+ }
609
+
610
+ private write(start: CalendarEnd | undefined, end: CalendarEnd | undefined) {
611
+ this.commit(writeDateValue(start, end, { time: this.time }));
612
+ }
613
+
614
+ /**
615
+ * A day pressed in the month. The first press of an opening is the value;
616
+ * the second ends the range and, without times to set, closes the month.
617
+ */
618
+ private picked = (event: CustomEvent<CalendarChangeDetail>) => {
619
+ event.stopPropagation();
620
+ const { start, end } = this.ends;
621
+ const first: CalendarEnd = { day: event.detail.start, time: start?.time || (this.time ? "09:00" : "") };
622
+ if (!event.detail.end) {
623
+ this.write(first, undefined);
624
+ return;
625
+ }
626
+ const last: CalendarEnd = { day: event.detail.end, time: end?.time || (this.time ? "10:00" : "") };
627
+ this.write(first, last);
628
+ if (!this.time) this.open = false;
629
+ };
630
+
631
+ /** A day typed in a field, read when the person leaves it or presses Enter. */
632
+ private typed(side: "start" | "end", input: HTMLInputElement) {
633
+ const { start, end } = this.ends;
634
+ const text = input.value.trim();
635
+ if (!text) {
636
+ if (side === "start") this.commit("");
637
+ else this.write(start, undefined);
638
+ return;
639
+ }
640
+ const day = parseTypedDay(text);
641
+ if (!day) {
642
+ input.value = this.fieldText(side === "start" ? start : end);
643
+ return;
644
+ }
645
+ const current = side === "start" ? start : end;
646
+ const next = { day, time: current?.time || (this.time ? (side === "start" ? "09:00" : "10:00") : "") };
647
+ if (side === "start") this.write(next, end);
648
+ else this.write(start ?? next, next);
649
+ const month = this.smallMonth;
650
+ if (month) month.month = day.slice(0, 7);
651
+ }
652
+
653
+ private timed(side: "start" | "end", input: HTMLInputElement) {
654
+ const { start, end } = this.ends;
655
+ const clock = TIME.test(input.value) ? input.value : "";
656
+ if (side === "start" && start) this.write({ ...start, time: clock }, end);
657
+ if (side === "end" && start) this.write(start, { day: (end ?? start).day, time: clock });
658
+ }
659
+
660
+ private setTime = () => {
661
+ this.time = !this.time;
662
+ const { start, end } = this.ends;
663
+ const timed = (side: CalendarEnd | undefined, clock: string) => (side ? { ...side, time: this.time ? side.time || clock : "" } : undefined);
664
+ this.write(timed(start, "09:00"), timed(end ?? (this.time ? start : undefined), "10:00"));
665
+ };
666
+
667
+ /** Pressing the switch's row presses the switch, as a label would. */
668
+ private passPress = (event: MouseEvent) => {
669
+ const row = event.currentTarget as HTMLElement;
670
+ const control = row.querySelector<HTMLButtonElement>(".ds-switch");
671
+ if (control && !event.composedPath().includes(control)) control.click();
672
+ };
673
+
674
+ private clear = () => {
675
+ this.commit("");
676
+ this.open = false;
677
+ };
678
+
679
+ private moment(side: CalendarEnd) {
680
+ const [year, month, date] = side.day.split("-").map(Number);
681
+ const [hours, minutes] = (side.time || "12:00").split(":").map(Number);
682
+ return Date.UTC(year, month - 1, date, hours, minutes);
683
+ }
684
+
685
+ private formatter(withTime: boolean) {
686
+ return dateFormatter({ dateStyle: this.length ?? "medium", ...(withTime ? { timeStyle: "short" } : {}), timeZone: "UTC" }) as Intl.DateTimeFormat & {
687
+ formatRange?: (from: number, to: number) => string;
688
+ };
689
+ }
690
+
691
+ private fieldText(side: CalendarEnd | undefined) {
692
+ return side ? this.formatter(false).format(this.moment(side)) : "";
693
+ }
694
+
695
+ /** The face's words: one day, or a range written as the language writes one ("Oct 21 – 24, 2026"). */
696
+ private faceText(): string {
697
+ const { start, end } = this.ends;
698
+ if (!start) return this.pickerWords(this.placeholder, "Choose a date");
699
+ const formatter = this.formatter(Boolean(start.time || end?.time));
700
+ if (!end) return formatter.format(this.moment(start));
701
+ return formatter.formatRange ? formatter.formatRange(this.moment(start), this.moment(end)) : `${formatter.format(this.moment(start))} – ${formatter.format(this.moment(end))}`;
702
+ }
703
+
704
+ private renderEnd(side: "start" | "end", value: CalendarEnd | undefined, name: string) {
705
+ const label = side === "start" ? name : `${name}, ${this.pickerWords(undefined, "End date")}`;
706
+ return html`<div class="field-row" part="field">
707
+ <input
708
+ class="ds-input field"
709
+ type="text"
710
+ autocomplete="off"
711
+ aria-label=${label}
712
+ placeholder=${side === "start" ? this.pickerWords(this.placeholder, "Choose a date") : this.pickerWords(undefined, "End date")}
713
+ .value=${this.fieldText(value)}
714
+ @change=${(event: Event) => this.typed(side, event.target as HTMLInputElement)}
715
+ @keydown=${(event: KeyboardEvent) => { if (event.key === "Enter") { event.preventDefault(); this.typed(side, event.target as HTMLInputElement); } }}
716
+ />
717
+ </div>
718
+ ${this.time
719
+ ? html`<div class="field-row" part="field">
720
+ <input
721
+ class="ds-input clock"
722
+ type="time"
723
+ aria-label=${`${label}, ${this.pickerWords(undefined, "Time")}`}
724
+ .value=${value?.time ?? ""}
725
+ ?disabled=${!this.ends.start}
726
+ @change=${(event: Event) => this.timed(side, event.target as HTMLInputElement)}
727
+ />
728
+ </div>`
729
+ : nothing}`;
730
+ }
731
+
732
+ private renderPicker() {
733
+ const { start, end } = this.ends;
734
+ const text = this.faceText();
735
+ const name = this.label ? this.pickerWords(this.label, this.label) : this.pickerWords(undefined, "Date");
736
+ const days = start ? (end ? `${start.day}/${end.day}` : start.day) : "";
737
+ return html`<button
738
+ class="ds-select trigger"
739
+ part="trigger"
740
+ type="button"
741
+ aria-haspopup="dialog"
742
+ aria-expanded=${String(this.open)}
743
+ aria-label=${start ? `${name}, ${text}` : name}
744
+ ?disabled=${this.disabled}
745
+ ?data-empty=${!start}
746
+ @click=${this.toggle}
747
+ @keydown=${this.onTriggerKey}
748
+ >
749
+ <ds-icon class="icon" type="calendar" aria-hidden="true"></ds-icon>
750
+ <span class="text">${text}</span>
751
+ <ds-icon class="arrow" type="down" aria-hidden="true"></ds-icon>
752
+ </button>
753
+ <div
754
+ class="popover"
755
+ part="popover"
756
+ popover="manual"
757
+ role="dialog"
758
+ aria-label=${name}
759
+ @keydown=${this.onPanelKey}
760
+ @focusout=${this.onPanelFocusOut}
761
+ >
762
+ <div class="fields">
763
+ ${this.renderEnd("start", start, name)}
764
+ ${this.renderEnd("end", start ? end ?? start : undefined, name)}
765
+ </div>
766
+ <ds-calendar
767
+ compact
768
+ selectable
769
+ fixed
770
+ outside-days
771
+ .value=${days}
772
+ .min=${this.min}
773
+ .max=${this.max}
774
+ .weekStart=${this.weekStart}
775
+ .today=${this.today}
776
+ @change=${this.picked}
777
+ ></ds-calendar>
778
+ <div class="options" part="options">
779
+ <div class="separator" aria-hidden="true"></div>
780
+ <div class="option" @click=${this.passPress}>
781
+ <span>${this.pickerWords(undefined, "Include time")}</span>
782
+ <button class="ds-switch" type="button" role="switch" aria-label=${this.pickerWords(undefined, "Include time")} aria-checked=${String(this.time)} @click=${this.setTime}></button>
783
+ </div>
784
+ ${this.required
785
+ ? nothing
786
+ : html`<div class="separator" aria-hidden="true"></div>
787
+ <button class="option clear" part="clear" type="button" ?disabled=${!start} @click=${this.clear}>${this.pickerWords(undefined, "Clear")}</button>`}
788
+ </div>
789
+ </div>`;
790
+ }
336
791
  }
337
792
 
338
793
  define("ds-calendar", Calendar);