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