@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,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
|
|
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
|
import { html, nothing, unsafeCSS } from "lit";
|
|
8
15
|
import { define } from "../0-face/define";
|
|
9
|
-
import {
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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.
|
|
137
|
+
if (event.key === "Escape" && this.unfolded) {
|
|
71
138
|
event.stopPropagation();
|
|
72
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
122
|
-
this.
|
|
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.
|
|
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.
|
|
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 ?
|
|
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
|
-
|
|
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);
|