@0001-labs/design-system-1 0.3.0-alpha.19 → 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.
- package/DS1/0-face/app-banner.ts +1 -1
- package/DS1/0-face/bento.ts +84 -0
- package/DS1/0-face/calendar.ts +2 -2
- package/DS1/1-root/bento.css +97 -0
- package/DS1/1-root/card.css +6 -1
- package/DS1/1-root/cjk.css +1 -1
- package/DS1/1-root/controls.css +1 -0
- package/DS1/1-root/fonts.css +1 -1
- package/DS1/1-root/one.css +1 -1
- package/DS1/1-root/one.semantic.css +1 -1
- package/DS1/3-unit/calendar-base.ts +31 -22
- package/DS1/3-unit/ds-bento.ts +67 -0
- package/DS1/3-unit/ds-calendar.ts +477 -22
- package/DS1/3-unit/styles/ds-bento.css +66 -0
- package/DS1/3-unit/styles/ds-calendar.css +119 -1
- package/DS1/index.ts +1 -1
- package/dist/0-face/app-banner.js +1 -1
- package/dist/0-face/bento.d.ts +41 -0
- package/dist/0-face/bento.d.ts.map +1 -0
- package/dist/0-face/bento.js +77 -0
- package/dist/0-face/calendar.js +2 -2
- package/dist/3-unit/calendar-base.d.ts +17 -12
- package/dist/3-unit/calendar-base.d.ts.map +1 -1
- package/dist/3-unit/calendar-base.js +21 -16
- package/dist/3-unit/ds-bento.d.ts +31 -0
- package/dist/3-unit/ds-bento.d.ts.map +1 -0
- package/dist/3-unit/ds-bento.js +47 -0
- package/dist/3-unit/ds-calendar-large.d.ts +3 -4
- package/dist/3-unit/ds-calendar-large.d.ts.map +1 -1
- package/dist/3-unit/ds-calendar.d.ts +139 -12
- package/dist/3-unit/ds-calendar.d.ts.map +1 -1
- package/dist/3-unit/ds-calendar.js +464 -23
- package/dist/design-system-1.bundle.js +1125 -1095
- package/dist/design-system-1.bundle.js.map +1 -1
- package/dist/design-system-1.bundle.min.js +2484 -2457
- package/dist/design-system-1.bundle.min.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/DS1/3-unit/ds-date-picker.ts +0 -540
- package/DS1/3-unit/styles/ds-date-picker.css +0 -124
- package/dist/3-unit/ds-date-picker.d.ts +0 -203
- package/dist/3-unit/ds-date-picker.d.ts.map +0 -1
- 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
|
|
6
|
-
//
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
319
|
+
if (event.key === "Escape" && this.unfolded) {
|
|
183
320
|
event.stopPropagation();
|
|
184
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 ?
|
|
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);
|