@lekoala/slot-picker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/custom-elements.json +53 -0
- package/dist/slot-picker.css +597 -0
- package/dist/slot-picker.js +1382 -0
- package/dist/slot-picker.min.css +1 -0
- package/dist/slot-picker.min.js +36 -0
- package/dist/types/date.d.ts +23 -0
- package/dist/types/date.d.ts.map +1 -0
- package/dist/types/define.d.ts +2 -0
- package/dist/types/define.d.ts.map +1 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/locales/ar.d.ts +20 -0
- package/dist/types/locales/ar.d.ts.map +1 -0
- package/dist/types/locales/de.d.ts +20 -0
- package/dist/types/locales/de.d.ts.map +1 -0
- package/dist/types/locales/en.d.ts +20 -0
- package/dist/types/locales/en.d.ts.map +1 -0
- package/dist/types/locales/es.d.ts +20 -0
- package/dist/types/locales/es.d.ts.map +1 -0
- package/dist/types/locales/fr.d.ts +20 -0
- package/dist/types/locales/fr.d.ts.map +1 -0
- package/dist/types/locales/hi.d.ts +20 -0
- package/dist/types/locales/hi.d.ts.map +1 -0
- package/dist/types/locales/id.d.ts +20 -0
- package/dist/types/locales/id.d.ts.map +1 -0
- package/dist/types/locales/it.d.ts +20 -0
- package/dist/types/locales/it.d.ts.map +1 -0
- package/dist/types/locales/ja.d.ts +20 -0
- package/dist/types/locales/ja.d.ts.map +1 -0
- package/dist/types/locales/ko.d.ts +20 -0
- package/dist/types/locales/ko.d.ts.map +1 -0
- package/dist/types/locales/nl.d.ts +20 -0
- package/dist/types/locales/nl.d.ts.map +1 -0
- package/dist/types/locales/pl.d.ts +20 -0
- package/dist/types/locales/pl.d.ts.map +1 -0
- package/dist/types/locales/pt-BR.d.ts +20 -0
- package/dist/types/locales/pt-BR.d.ts.map +1 -0
- package/dist/types/locales/pt-PT.d.ts +20 -0
- package/dist/types/locales/pt-PT.d.ts.map +1 -0
- package/dist/types/locales/ru.d.ts +20 -0
- package/dist/types/locales/ru.d.ts.map +1 -0
- package/dist/types/locales/tr.d.ts +20 -0
- package/dist/types/locales/tr.d.ts.map +1 -0
- package/dist/types/locales/zh-CN.d.ts +20 -0
- package/dist/types/locales/zh-CN.d.ts.map +1 -0
- package/dist/types/messages.d.ts +67 -0
- package/dist/types/messages.d.ts.map +1 -0
- package/dist/types/model.d.ts +213 -0
- package/dist/types/model.d.ts.map +1 -0
- package/dist/types/slot-picker.d.ts +182 -0
- package/dist/types/slot-picker.d.ts.map +1 -0
- package/dist/types/source.d.ts +45 -0
- package/dist/types/source.d.ts.map +1 -0
- package/dist/types/views/columns.d.ts +28 -0
- package/dist/types/views/columns.d.ts.map +1 -0
- package/dist/types/views/day.d.ts +27 -0
- package/dist/types/views/day.d.ts.map +1 -0
- package/dist/types/views/shared.d.ts +99 -0
- package/dist/types/views/shared.d.ts.map +1 -0
- package/docs/USE_CASES.md +197 -0
- package/package.json +148 -0
- package/src/date.js +66 -0
- package/src/define.js +5 -0
- package/src/index.js +25 -0
- package/src/locales/ar.js +18 -0
- package/src/locales/de.js +18 -0
- package/src/locales/en.js +18 -0
- package/src/locales/es.js +18 -0
- package/src/locales/fr.js +18 -0
- package/src/locales/hi.js +18 -0
- package/src/locales/id.js +18 -0
- package/src/locales/it.js +18 -0
- package/src/locales/ja.js +18 -0
- package/src/locales/ko.js +18 -0
- package/src/locales/nl.js +18 -0
- package/src/locales/pl.js +18 -0
- package/src/locales/pt-BR.js +18 -0
- package/src/locales/pt-PT.js +18 -0
- package/src/locales/ru.js +18 -0
- package/src/locales/tr.js +18 -0
- package/src/locales/zh-CN.js +18 -0
- package/src/messages.js +44 -0
- package/src/model.js +357 -0
- package/src/slot-picker.css +597 -0
- package/src/slot-picker.js +1094 -0
- package/src/source.js +87 -0
- package/src/views/columns.js +108 -0
- package/src/views/day.js +122 -0
- package/src/views/shared.js +157 -0
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
import { addDays, compareDates, isDateValue, rangeEnd, todayValue } from "./date.js";
|
|
2
|
+
import { resolveMessages } from "./messages.js";
|
|
3
|
+
import {
|
|
4
|
+
boundedDayCount,
|
|
5
|
+
clampStart,
|
|
6
|
+
collapsedDays,
|
|
7
|
+
ensureVisible,
|
|
8
|
+
hasDayContent,
|
|
9
|
+
isSlotValue,
|
|
10
|
+
isValidRange,
|
|
11
|
+
moveFocus,
|
|
12
|
+
normalizeBreakpoints,
|
|
13
|
+
normalizeDays,
|
|
14
|
+
RESPONSIVE_BREAKPOINTS,
|
|
15
|
+
rangeDetail,
|
|
16
|
+
resolveActiveDate,
|
|
17
|
+
resolveVisibleDayCount,
|
|
18
|
+
slotValue,
|
|
19
|
+
visibleDays,
|
|
20
|
+
} from "./model.js";
|
|
21
|
+
import { SlotSourceController } from "./source.js";
|
|
22
|
+
import { renderColumns } from "./views/columns.js";
|
|
23
|
+
import { renderDay } from "./views/day.js";
|
|
24
|
+
import { escapeAttr, escapeHtml, rangeEmpty } from "./views/shared.js";
|
|
25
|
+
|
|
26
|
+
const PREV_ICON =
|
|
27
|
+
'<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M12.5 4 6.5 10l6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
28
|
+
const NEXT_ICON =
|
|
29
|
+
'<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4 6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
30
|
+
|
|
31
|
+
/** Attributes that change the resolved visible range. */
|
|
32
|
+
const RANGE_ATTRIBUTES = new Set(["start", "min", "max", "day-count", "responsive"]);
|
|
33
|
+
|
|
34
|
+
/** @typedef {import("./model.js").SlotDay} SlotDay */
|
|
35
|
+
/** @typedef {"up"|"down"|"left"|"right"|"home"|"end"} FocusDirection */
|
|
36
|
+
/** @typedef {{minWidth:number,dayCount:number}} ResponsiveBreakpoint */
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {Partial<{
|
|
39
|
+
* min: string,
|
|
40
|
+
* max: string,
|
|
41
|
+
* start: string,
|
|
42
|
+
* dayCount: number,
|
|
43
|
+
* layout: "columns"|"day",
|
|
44
|
+
* value: string,
|
|
45
|
+
* maxVisibleRows: number,
|
|
46
|
+
* responsive: boolean,
|
|
47
|
+
* homeDate: string|null,
|
|
48
|
+
* responsiveBreakpoints: ResponsiveBreakpoint[]|null,
|
|
49
|
+
* closedDays: "show"|"hide",
|
|
50
|
+
* }>} ConfigureOptions
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Inline appointment slot chooser.
|
|
55
|
+
*
|
|
56
|
+
* Canonical public values:
|
|
57
|
+
* - dates: YYYY-MM-DD
|
|
58
|
+
* - times: HH:mm
|
|
59
|
+
* - value: YYYY-MM-DDTHH:mm
|
|
60
|
+
*
|
|
61
|
+
* State:
|
|
62
|
+
* - dayCount: consumer's maximum intention
|
|
63
|
+
* - visibleDayCount: capacity actually resolved from bounds + width
|
|
64
|
+
* - start/range: visible civil range
|
|
65
|
+
* - activeDate: consulted day
|
|
66
|
+
* - focusedValue: keyboard target, never a selection
|
|
67
|
+
* - value: selected local slot value
|
|
68
|
+
* - homeDate: reference date for `goHome()`, unrelated to `min`
|
|
69
|
+
*
|
|
70
|
+
* Invariants:
|
|
71
|
+
* - resize changes the range, never `value`;
|
|
72
|
+
* - navigation changes the range, never `value`;
|
|
73
|
+
* - day activation changes `activeDate`, never `value`;
|
|
74
|
+
* - slot activation changes `activeDate` and `value`;
|
|
75
|
+
* - event order is stable: `rangechange` then `daychange`, then any reload.
|
|
76
|
+
*/
|
|
77
|
+
export class SlotPickerElement extends HTMLElement {
|
|
78
|
+
static observedAttributes = [
|
|
79
|
+
"start",
|
|
80
|
+
"day-count",
|
|
81
|
+
"min",
|
|
82
|
+
"max",
|
|
83
|
+
"value",
|
|
84
|
+
"active-date",
|
|
85
|
+
"layout",
|
|
86
|
+
"max-visible-rows",
|
|
87
|
+
"expanded",
|
|
88
|
+
"responsive",
|
|
89
|
+
"home-date",
|
|
90
|
+
"next-availability",
|
|
91
|
+
"notice-display",
|
|
92
|
+
"closed-days",
|
|
93
|
+
"locale",
|
|
94
|
+
"lang",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/** @type {SlotDay[]} */
|
|
98
|
+
#days = [];
|
|
99
|
+
#sourceController = new SlotSourceController();
|
|
100
|
+
/** @type {Partial<typeof import("./messages.js").DEFAULT_MESSAGES>|null} */
|
|
101
|
+
#messages = null;
|
|
102
|
+
#loading = false;
|
|
103
|
+
/** @type {unknown} */
|
|
104
|
+
#error = null;
|
|
105
|
+
#focusedValue = "";
|
|
106
|
+
#renderQueued = false;
|
|
107
|
+
#initializing = false;
|
|
108
|
+
#batching = false;
|
|
109
|
+
#pendingFocus = "";
|
|
110
|
+
/** @type {ResizeObserver|null} */
|
|
111
|
+
#resizeObserver = null;
|
|
112
|
+
/** @type {number|null} */
|
|
113
|
+
#measuredWidth = null;
|
|
114
|
+
/** @type {ResponsiveBreakpoint[]|null} */
|
|
115
|
+
#breakpoints = null;
|
|
116
|
+
/** @type {{start:string,end:string,dayCount:number}|null} */
|
|
117
|
+
#lastRange = null;
|
|
118
|
+
/** @type {string|null} */
|
|
119
|
+
#lastActiveDate = null;
|
|
120
|
+
/**
|
|
121
|
+
* Range the latest load was requested for. Kept separate from #lastRange so
|
|
122
|
+
* initialization and reconnection can load without faking a range change.
|
|
123
|
+
* @type {string|null}
|
|
124
|
+
*/
|
|
125
|
+
#loadedKey = null;
|
|
126
|
+
#loadRequest = 0;
|
|
127
|
+
|
|
128
|
+
constructor() {
|
|
129
|
+
super();
|
|
130
|
+
this.addEventListener("click", /** @param {MouseEvent} event */ (event) => this.#onClick(event));
|
|
131
|
+
this.addEventListener("keydown", /** @param {KeyboardEvent} event */ (event) => this.#onKeyDown(event));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
connectedCallback() {
|
|
135
|
+
this.#initializing = true;
|
|
136
|
+
if (!this.hasAttribute("start")) this.setAttribute("start", todayValue());
|
|
137
|
+
if (!this.hasAttribute("day-count")) this.setAttribute("day-count", "5");
|
|
138
|
+
if (!this.hasAttribute("layout")) this.setAttribute("layout", "columns");
|
|
139
|
+
this.#initializing = false;
|
|
140
|
+
|
|
141
|
+
// Without a ResizeObserver the projection width can never be measured
|
|
142
|
+
// accurately; fall back to the host width so `responsive` still resolves.
|
|
143
|
+
this.#measuredWidth = typeof ResizeObserver === "undefined" ? this.getBoundingClientRect().width : null;
|
|
144
|
+
if (!this.#resizeObserver && typeof ResizeObserver !== "undefined") {
|
|
145
|
+
this.#resizeObserver = new ResizeObserver((entries) => this.#onResize(entries));
|
|
146
|
+
}
|
|
147
|
+
this.#resizeObserver?.observe(this);
|
|
148
|
+
|
|
149
|
+
this.#reconcile({ pinActive: false });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
disconnectedCallback() {
|
|
153
|
+
this.#sourceController.abort();
|
|
154
|
+
this.#resizeObserver?.disconnect();
|
|
155
|
+
// Reconnecting is a fresh load, even when the range is unchanged.
|
|
156
|
+
this.#loadedKey = null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} name
|
|
161
|
+
* @param {string|null} oldValue
|
|
162
|
+
* @param {string|null} newValue
|
|
163
|
+
*/
|
|
164
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
165
|
+
if (oldValue === newValue) return;
|
|
166
|
+
if (this.#initializing || this.#batching) return;
|
|
167
|
+
if (RANGE_ATTRIBUTES.has(name)) this.#reconcile({ pinActive: name !== "start" });
|
|
168
|
+
else this.#queueRender();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
get start() {
|
|
172
|
+
const value = this.getAttribute("start") || todayValue();
|
|
173
|
+
return isDateValue(value) ? value : todayValue();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
set start(value) {
|
|
177
|
+
if (!isDateValue(value)) throw new TypeError("start must be YYYY-MM-DD");
|
|
178
|
+
const count = this.visibleDayCount;
|
|
179
|
+
this.setAttribute("start", count > 0 ? clampStart(value, this.min, this.max, count) : value);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
get dayCount() {
|
|
183
|
+
const value = Number.parseInt(this.getAttribute("day-count") || "5", 10);
|
|
184
|
+
return Number.isFinite(value) ? Math.max(1, Math.min(14, value)) : 5;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** @param {number} value */
|
|
188
|
+
set dayCount(value) {
|
|
189
|
+
const next = Math.max(1, Math.min(14, Number(value) || 5));
|
|
190
|
+
this.setAttribute("day-count", String(next));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Consumer's maximum intention.
|
|
195
|
+
* @see visibleDayCount for the resolved value.
|
|
196
|
+
*/
|
|
197
|
+
get visibleDayCount() {
|
|
198
|
+
if (!isValidRange(this.min, this.max)) return 0;
|
|
199
|
+
const bounded = boundedDayCount(this.dayCount, this.min, this.max);
|
|
200
|
+
if (!this.responsive || this.#measuredWidth === null) return bounded;
|
|
201
|
+
return resolveVisibleDayCount(bounded, this.#measuredWidth, this.#breakpoints ?? RESPONSIVE_BREAKPOINTS);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
get responsive() {
|
|
205
|
+
return this.hasAttribute("responsive");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
set responsive(value) {
|
|
209
|
+
this.toggleAttribute("responsive", Boolean(value));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** @returns {readonly ResponsiveBreakpoint[]} */
|
|
213
|
+
get responsiveBreakpoints() {
|
|
214
|
+
return this.#breakpoints ?? RESPONSIVE_BREAKPOINTS;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @param {ResponsiveBreakpoint[]|null} value */
|
|
218
|
+
set responsiveBreakpoints(value) {
|
|
219
|
+
this.#breakpoints = value?.length ? normalizeBreakpoints(value) : null;
|
|
220
|
+
if (!this.isConnected || this.#batching) return;
|
|
221
|
+
this.#reconcile({ pinActive: true });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
get min() {
|
|
225
|
+
const value = this.getAttribute("min");
|
|
226
|
+
return value && isDateValue(value) ? value : "";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
set min(value) {
|
|
230
|
+
if (!value) this.removeAttribute("min");
|
|
231
|
+
else if (isDateValue(value)) this.setAttribute("min", value);
|
|
232
|
+
else throw new TypeError("min must be YYYY-MM-DD");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
get max() {
|
|
236
|
+
const value = this.getAttribute("max");
|
|
237
|
+
return value && isDateValue(value) ? value : "";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
set max(value) {
|
|
241
|
+
if (!value) this.removeAttribute("max");
|
|
242
|
+
else if (isDateValue(value)) this.setAttribute("max", value);
|
|
243
|
+
else throw new TypeError("max must be YYYY-MM-DD");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Canonical selection. A malformed attribute (markup is not guaranteed to be
|
|
248
|
+
* valid) reads as empty instead of breaking the upgrade; the property setter
|
|
249
|
+
* still rejects it explicitly.
|
|
250
|
+
*/
|
|
251
|
+
get value() {
|
|
252
|
+
const value = this.getAttribute("value") || "";
|
|
253
|
+
return isSlotValue(value) ? value : "";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
set value(value) {
|
|
257
|
+
if (!value) {
|
|
258
|
+
this.removeAttribute("value");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (isSlotValue(value)) this.setAttribute("value", value);
|
|
262
|
+
else throw new TypeError("value must be a valid YYYY-MM-DDTHH:mm or empty");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
get activeDate() {
|
|
266
|
+
const count = this.visibleDayCount;
|
|
267
|
+
if (!count) return "";
|
|
268
|
+
const raw = this.getAttribute("active-date") || "";
|
|
269
|
+
const base = isDateValue(raw) ? raw : this.start;
|
|
270
|
+
return resolveActiveDate(this.start, count, base);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
set activeDate(value) {
|
|
274
|
+
if (!value) this.removeAttribute("active-date");
|
|
275
|
+
else if (isDateValue(value)) this.setAttribute("active-date", value);
|
|
276
|
+
else throw new TypeError("active-date must be YYYY-MM-DD or empty");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Reference date used by `goHome()`. Never confused with `min`. */
|
|
280
|
+
get homeDate() {
|
|
281
|
+
const raw = this.getAttribute("home-date") || "";
|
|
282
|
+
if (isDateValue(raw)) return raw;
|
|
283
|
+
return this.min || todayValue();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
set homeDate(value) {
|
|
287
|
+
if (!value) this.removeAttribute("home-date");
|
|
288
|
+
else if (isDateValue(value)) this.setAttribute("home-date", value);
|
|
289
|
+
else throw new TypeError("home-date must be YYYY-MM-DD or empty");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
get layout() {
|
|
293
|
+
const value = this.getAttribute("layout") || "columns";
|
|
294
|
+
return value === "day" ? "day" : "columns";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
set layout(value) {
|
|
298
|
+
if (value !== "columns" && value !== "day") throw new TypeError('layout must be "columns" or "day"');
|
|
299
|
+
this.setAttribute("layout", value);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Notice projection strategy:
|
|
304
|
+
* - `action` (default) renders only an accessible control that emits
|
|
305
|
+
* `noticeactivate`, so the application owns the detailed presentation;
|
|
306
|
+
* - `inline` additionally renders the readable notice content, which stays
|
|
307
|
+
* non-interactive;
|
|
308
|
+
* - `none` renders nothing.
|
|
309
|
+
*/
|
|
310
|
+
get noticeDisplay() {
|
|
311
|
+
const value = this.getAttribute("notice-display");
|
|
312
|
+
return value === "inline" || value === "none" ? value : "action";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
set noticeDisplay(value) {
|
|
316
|
+
if (value !== "inline" && value !== "action" && value !== "none") {
|
|
317
|
+
throw new TypeError('notice-display must be "inline", "action" or "none"');
|
|
318
|
+
}
|
|
319
|
+
this.setAttribute("notice-display", value);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Projection policy for `closed` days:
|
|
324
|
+
* - `show` (default) renders every civil day;
|
|
325
|
+
* - `hide` omits closed days from the projection without changing the civil
|
|
326
|
+
* range, `previous()`/`next()` or `source.load()`.
|
|
327
|
+
*/
|
|
328
|
+
get closedDays() {
|
|
329
|
+
return this.getAttribute("closed-days") === "hide" ? "hide" : "show";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
set closedDays(value) {
|
|
333
|
+
if (value !== "show" && value !== "hide") {
|
|
334
|
+
throw new TypeError('closed-days must be "show" or "hide"');
|
|
335
|
+
}
|
|
336
|
+
this.setAttribute("closed-days", value);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
get expanded() {
|
|
340
|
+
return this.hasAttribute("expanded");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
set expanded(value) {
|
|
344
|
+
this.toggleAttribute("expanded", Boolean(value));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
get maxVisibleRows() {
|
|
348
|
+
const value = Number.parseInt(this.getAttribute("max-visible-rows") || "5", 10);
|
|
349
|
+
return Number.isFinite(value) ? Math.max(1, value) : 5;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
set maxVisibleRows(value) {
|
|
353
|
+
this.setAttribute("max-visible-rows", String(Math.max(1, Number(value) || 5)));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** @returns {SlotDay[]} */
|
|
357
|
+
get days() {
|
|
358
|
+
return this.#days.map((day) => ({ ...day, slots: day.slots.map((slot) => ({ ...slot })) }));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** @param {SlotDay[]} value */
|
|
362
|
+
set days(value) {
|
|
363
|
+
this.#days = normalizeDays(value);
|
|
364
|
+
this.#error = null;
|
|
365
|
+
this.#queueRender();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** @param {import("./source.js").SlotSource|null} source */
|
|
369
|
+
set source(source) {
|
|
370
|
+
this.#sourceController.source = source;
|
|
371
|
+
if (this.isConnected) this.#load();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
get source() {
|
|
375
|
+
return this.#sourceController.source;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Instance overrides only. Global defaults from `setDefaultMessages()` are
|
|
380
|
+
* resolved at render time, so they also reach already-created instances.
|
|
381
|
+
* @param {Partial<typeof import("./messages.js").DEFAULT_MESSAGES>|null} value
|
|
382
|
+
*/
|
|
383
|
+
set messages(value) {
|
|
384
|
+
this.#messages = value ? { ...value } : null;
|
|
385
|
+
this.#queueRender();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Locale used for `Intl` formatting. `locale` wins over the native `lang`,
|
|
390
|
+
* then the document language, then the browser language.
|
|
391
|
+
*/
|
|
392
|
+
get locale() {
|
|
393
|
+
const value = this.getAttribute("locale") || this.lang;
|
|
394
|
+
return value || document.documentElement.lang || navigator.language || "en";
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
set locale(value) {
|
|
398
|
+
if (!value) this.removeAttribute("locale");
|
|
399
|
+
else this.setAttribute("locale", value);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Resolved visible civil range. Empty and invalid when count is 0. */
|
|
403
|
+
get range() {
|
|
404
|
+
const count = this.visibleDayCount;
|
|
405
|
+
if (!count) return { start: "", end: "", dayCount: 0 };
|
|
406
|
+
return rangeDetail(this.start, count);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
previous() {
|
|
410
|
+
this.#navigate(-this.visibleDayCount);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
next() {
|
|
414
|
+
this.#navigate(this.visibleDayCount);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Bring `date` into the visible range and consult it.
|
|
419
|
+
* Event order: `rangechange` then `daychange`.
|
|
420
|
+
* @param {string} date
|
|
421
|
+
* @returns {string} the resolved active date, or "" when the range is invalid
|
|
422
|
+
*/
|
|
423
|
+
goTo(date) {
|
|
424
|
+
const count = this.visibleDayCount;
|
|
425
|
+
if (!count) return "";
|
|
426
|
+
const target = isDateValue(date) ? this.#clampToBounds(date) : this.start;
|
|
427
|
+
this.#mutate(() => this.#moveTo(target, count));
|
|
428
|
+
this.#commit();
|
|
429
|
+
return this.activeDate;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Return to the reference window. Distinct from `min` and from `previous()`. */
|
|
433
|
+
goHome() {
|
|
434
|
+
return this.goTo(this.homeDate);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Ask the source (or, failing that, the `nextrequest` event) for the next
|
|
439
|
+
* known availability beyond the visible range. Never merged with `next()`.
|
|
440
|
+
* @param {{after?:string}} [options]
|
|
441
|
+
* @returns {Promise<string|null>}
|
|
442
|
+
*/
|
|
443
|
+
async goToNextAvailability(options = {}) {
|
|
444
|
+
if (!this.visibleDayCount) return null;
|
|
445
|
+
const after = options.after ?? this.range.end;
|
|
446
|
+
if (this.#sourceController.hasNext) {
|
|
447
|
+
const date = await this.#sourceController.next({ after });
|
|
448
|
+
if (date && isDateValue(date)) {
|
|
449
|
+
// Return the destination actually reached: `goTo` clamps to bounds, so
|
|
450
|
+
// the source proposal may differ from the resolved active date.
|
|
451
|
+
return this.goTo(date) || null;
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
this.dispatchEvent(new CustomEvent("nextrequest", { detail: { after } }));
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Apply several settings as one transaction: no intermediate load or event.
|
|
461
|
+
* @param {ConfigureOptions} [options]
|
|
462
|
+
*/
|
|
463
|
+
configure(options = {}) {
|
|
464
|
+
// Validate every option before mutating anything: a rejected transaction
|
|
465
|
+
// must leave the component exactly as it was, not partially applied.
|
|
466
|
+
this.#validateConfigure(options);
|
|
467
|
+
this.#mutate(() => {
|
|
468
|
+
if (options.min !== undefined) this.min = options.min;
|
|
469
|
+
if (options.max !== undefined) this.max = options.max;
|
|
470
|
+
if (options.start !== undefined) this.start = options.start;
|
|
471
|
+
if (options.dayCount !== undefined) this.dayCount = options.dayCount;
|
|
472
|
+
if (options.layout !== undefined) this.layout = options.layout;
|
|
473
|
+
if (options.value !== undefined) this.value = options.value;
|
|
474
|
+
if (options.maxVisibleRows !== undefined) this.maxVisibleRows = options.maxVisibleRows;
|
|
475
|
+
if (options.responsive !== undefined) this.responsive = options.responsive;
|
|
476
|
+
if (options.homeDate !== undefined) this.homeDate = options.homeDate ?? "";
|
|
477
|
+
if (options.responsiveBreakpoints !== undefined)
|
|
478
|
+
this.responsiveBreakpoints = options.responsiveBreakpoints;
|
|
479
|
+
if (options.closedDays !== undefined) this.closedDays = options.closedDays;
|
|
480
|
+
});
|
|
481
|
+
// An explicit start wins over the previous active-day anchor; only the
|
|
482
|
+
// other options preserve the consulted day.
|
|
483
|
+
this.#reconcile({ pinActive: options.start === undefined });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Mirror the option setters without touching state, so `configure()` can
|
|
488
|
+
* fail closed. Options that only coerce (dayCount, maxVisibleRows,
|
|
489
|
+
* responsive) need no check.
|
|
490
|
+
* @param {ConfigureOptions} options
|
|
491
|
+
*/
|
|
492
|
+
#validateConfigure(options) {
|
|
493
|
+
if (options.min && !isDateValue(options.min)) throw new TypeError("min must be YYYY-MM-DD");
|
|
494
|
+
if (options.max && !isDateValue(options.max)) throw new TypeError("max must be YYYY-MM-DD");
|
|
495
|
+
if (options.start !== undefined && !isDateValue(options.start)) {
|
|
496
|
+
throw new TypeError("start must be YYYY-MM-DD");
|
|
497
|
+
}
|
|
498
|
+
if (options.layout !== undefined && options.layout !== "columns" && options.layout !== "day") {
|
|
499
|
+
throw new TypeError('layout must be "columns" or "day"');
|
|
500
|
+
}
|
|
501
|
+
if (options.value && !isSlotValue(options.value)) {
|
|
502
|
+
throw new TypeError("value must be a valid YYYY-MM-DDTHH:mm or empty");
|
|
503
|
+
}
|
|
504
|
+
if (options.homeDate && !isDateValue(options.homeDate)) {
|
|
505
|
+
throw new TypeError("homeDate must be YYYY-MM-DD or empty");
|
|
506
|
+
}
|
|
507
|
+
if (options.responsiveBreakpoints != null && !Array.isArray(options.responsiveBreakpoints)) {
|
|
508
|
+
throw new TypeError("responsiveBreakpoints must be an array or null");
|
|
509
|
+
}
|
|
510
|
+
if (options.closedDays !== undefined && options.closedDays !== "show" && options.closedDays !== "hide") {
|
|
511
|
+
throw new TypeError('closedDays must be "show" or "hide"');
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async reload() {
|
|
516
|
+
await this.#load();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** @param {number} delta */
|
|
520
|
+
#navigate(delta) {
|
|
521
|
+
const count = this.visibleDayCount;
|
|
522
|
+
if (!count) return;
|
|
523
|
+
const next = clampStart(addDays(this.start, delta), this.min, this.max, count);
|
|
524
|
+
if (next === this.start) return;
|
|
525
|
+
this.#mutate(() => this.setAttribute("start", next));
|
|
526
|
+
this.#commit();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Move the window just enough to make `target` visible.
|
|
531
|
+
* @param {string} target
|
|
532
|
+
* @param {number} count
|
|
533
|
+
*/
|
|
534
|
+
#moveTo(target, count) {
|
|
535
|
+
const nextStart = ensureVisible(this.start, count, target, this.min, this.max);
|
|
536
|
+
if (nextStart !== this.getAttribute("start")) this.setAttribute("start", nextStart);
|
|
537
|
+
const nextActive = resolveActiveDate(nextStart, count, target);
|
|
538
|
+
if (this.getAttribute("active-date") !== nextActive) this.setAttribute("active-date", nextActive);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** @param {string} date */
|
|
542
|
+
#clampToBounds(date) {
|
|
543
|
+
let next = date;
|
|
544
|
+
if (this.min && compareDates(next, this.min) < 0) next = this.min;
|
|
545
|
+
if (this.max && compareDates(next, this.max) > 0) next = this.max;
|
|
546
|
+
return next;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* @param {() => void} fn
|
|
551
|
+
*/
|
|
552
|
+
#mutate(fn) {
|
|
553
|
+
const wasBatching = this.#batching;
|
|
554
|
+
this.#batching = true;
|
|
555
|
+
try {
|
|
556
|
+
fn();
|
|
557
|
+
} finally {
|
|
558
|
+
this.#batching = wasBatching;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Single reconciliation pass: normalize -> resolve count -> clamp/ensure
|
|
564
|
+
* visible -> commit (render + load + events).
|
|
565
|
+
* @param {{pinActive?:boolean,reload?:boolean}} [options]
|
|
566
|
+
*/
|
|
567
|
+
#reconcile(options = {}) {
|
|
568
|
+
const pinActive = options.pinActive ?? true;
|
|
569
|
+
const reload = options.reload ?? true;
|
|
570
|
+
const wasBatching = this.#batching;
|
|
571
|
+
this.#batching = true;
|
|
572
|
+
try {
|
|
573
|
+
const count = this.visibleDayCount;
|
|
574
|
+
const invalid = count === 0;
|
|
575
|
+
this.toggleAttribute("data-invalid-range", invalid);
|
|
576
|
+
if (!invalid) {
|
|
577
|
+
const rawActive = this.getAttribute("active-date") || "";
|
|
578
|
+
const previous = this.#lastRange;
|
|
579
|
+
// Resolve the anchor against the *previous* range so a shrink cannot
|
|
580
|
+
// clamp the consulted day to the new, shorter end before we move start.
|
|
581
|
+
let anchor = this.start;
|
|
582
|
+
if (pinActive) {
|
|
583
|
+
if (previous?.dayCount && isDateValue(rawActive)) {
|
|
584
|
+
anchor = resolveActiveDate(previous.start, previous.dayCount, rawActive);
|
|
585
|
+
} else if (isDateValue(rawActive)) {
|
|
586
|
+
anchor = rawActive;
|
|
587
|
+
} else if (previous?.start) {
|
|
588
|
+
anchor = previous.start;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const nextStart = ensureVisible(this.start, count, anchor, this.min, this.max);
|
|
592
|
+
if (nextStart !== this.getAttribute("start")) this.setAttribute("start", nextStart);
|
|
593
|
+
if (pinActive && isDateValue(anchor)) {
|
|
594
|
+
const nextActive = resolveActiveDate(nextStart, count, anchor);
|
|
595
|
+
if (this.getAttribute("active-date") !== nextActive) this.setAttribute("active-date", nextActive);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
} finally {
|
|
599
|
+
this.#batching = wasBatching;
|
|
600
|
+
}
|
|
601
|
+
if (wasBatching) return;
|
|
602
|
+
this.#commit({ reload });
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Publish state: render, then `rangechange`, `daychange`, then reload.
|
|
607
|
+
* @param {{reload?:boolean}} [options]
|
|
608
|
+
*/
|
|
609
|
+
#commit(options = {}) {
|
|
610
|
+
const reload = options.reload ?? true;
|
|
611
|
+
const range = this.range;
|
|
612
|
+
const count = this.visibleDayCount;
|
|
613
|
+
const active = count ? this.activeDate : "";
|
|
614
|
+
const previousRange = this.#lastRange;
|
|
615
|
+
const first = previousRange === null;
|
|
616
|
+
const rangeChanged =
|
|
617
|
+
first ||
|
|
618
|
+
range.start !== previousRange.start ||
|
|
619
|
+
range.end !== previousRange.end ||
|
|
620
|
+
range.dayCount !== previousRange.dayCount;
|
|
621
|
+
const activeChanged = !first && this.#lastActiveDate !== null && active !== this.#lastActiveDate;
|
|
622
|
+
this.#lastRange = range;
|
|
623
|
+
this.#lastActiveDate = active;
|
|
624
|
+
this.#queueRender();
|
|
625
|
+
if (!first && rangeChanged) this.dispatchEvent(new CustomEvent("rangechange", { detail: range }));
|
|
626
|
+
if (!first && activeChanged) {
|
|
627
|
+
this.dispatchEvent(new CustomEvent("daychange", { detail: { activeDate: active } }));
|
|
628
|
+
}
|
|
629
|
+
if (!count) {
|
|
630
|
+
// An invalid range must invalidate in-flight work: its response would
|
|
631
|
+
// otherwise repaint a range the consumer can no longer see.
|
|
632
|
+
this.#sourceController.abort();
|
|
633
|
+
this.#loadRequest += 1;
|
|
634
|
+
this.#loadedKey = null;
|
|
635
|
+
this.#loading = false;
|
|
636
|
+
this.#error = null;
|
|
637
|
+
} else if (reload && this.#loadedKey !== this.#rangeKey(range)) {
|
|
638
|
+
this.#load();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** @param {{start:string,end:string,dayCount:number}} range */
|
|
643
|
+
#rangeKey(range) {
|
|
644
|
+
return `${range.start}|${range.end}|${range.dayCount}`;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** @param {ResizeObserverEntry[]} entries */
|
|
648
|
+
#onResize(entries) {
|
|
649
|
+
const entry = entries[entries.length - 1];
|
|
650
|
+
const box = entry?.contentBoxSize?.[0];
|
|
651
|
+
const hostWidth = box ? box.inlineSize : (entry?.contentRect.width ?? 0);
|
|
652
|
+
// Capacity follows the projection's own width, not the host: the nav rail
|
|
653
|
+
// is chrome and must not inflate the resolved day count.
|
|
654
|
+
const width = this.#contentWidth() ?? hostWidth;
|
|
655
|
+
const previous = this.visibleDayCount;
|
|
656
|
+
this.#measuredWidth = width;
|
|
657
|
+
if (this.visibleDayCount !== previous) this.#reconcile({ pinActive: true });
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Width actually available to the projection, inside the nav rail. */
|
|
661
|
+
#contentWidth() {
|
|
662
|
+
const content = this.querySelector(".sp-content");
|
|
663
|
+
return content ? content.getBoundingClientRect().width : null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** @param {string} date @param {boolean} emit */
|
|
667
|
+
#setActiveDate(date, emit = true) {
|
|
668
|
+
const count = this.visibleDayCount;
|
|
669
|
+
if (!count) return;
|
|
670
|
+
const resolved = resolveActiveDate(this.start, count, date);
|
|
671
|
+
const before = this.activeDate;
|
|
672
|
+
if (resolved === before && this.getAttribute("active-date") === resolved) return;
|
|
673
|
+
this.setAttribute("active-date", resolved);
|
|
674
|
+
this.#lastActiveDate = resolved;
|
|
675
|
+
if (emit && resolved !== before) {
|
|
676
|
+
this.dispatchEvent(new CustomEvent("daychange", { detail: { activeDate: resolved } }));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async #load() {
|
|
681
|
+
const request = ++this.#loadRequest;
|
|
682
|
+
if (!this.source || !this.visibleDayCount) {
|
|
683
|
+
// No source or an unusable range: return to a neutral state instead of
|
|
684
|
+
// leaving a stale loading flag or a stale error behind.
|
|
685
|
+
this.#loading = false;
|
|
686
|
+
this.#error = null;
|
|
687
|
+
this.#queueRender();
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const range = this.range;
|
|
691
|
+
this.#loadedKey = this.#rangeKey(range);
|
|
692
|
+
this.#loading = true;
|
|
693
|
+
this.#error = null;
|
|
694
|
+
this.#queueRender();
|
|
695
|
+
this.dispatchEvent(new CustomEvent("loadstart", { detail: range }));
|
|
696
|
+
|
|
697
|
+
try {
|
|
698
|
+
const result = await this.#sourceController.load(range);
|
|
699
|
+
// A superseded response must not repaint the current range.
|
|
700
|
+
if (request !== this.#loadRequest || result === null) return;
|
|
701
|
+
const days = Array.isArray(result)
|
|
702
|
+
? result
|
|
703
|
+
: result && typeof result === "object" && "days" in result
|
|
704
|
+
? result.days
|
|
705
|
+
: [];
|
|
706
|
+
this.#days = normalizeDays(/** @type {SlotDay[]} */ (days));
|
|
707
|
+
if (request !== this.#loadRequest) return;
|
|
708
|
+
this.dispatchEvent(new CustomEvent("loadend", { detail: { ...range, days: this.days } }));
|
|
709
|
+
} catch (error) {
|
|
710
|
+
// Stale errors (and stale loading resets) belong to a range nobody asked
|
|
711
|
+
// for anymore: only the latest request may publish.
|
|
712
|
+
if (request !== this.#loadRequest) return;
|
|
713
|
+
if (error instanceof DOMException && error.name === "AbortError") return;
|
|
714
|
+
this.#error = error;
|
|
715
|
+
this.dispatchEvent(new CustomEvent("loaderror", { detail: { ...range, error } }));
|
|
716
|
+
} finally {
|
|
717
|
+
if (request === this.#loadRequest) {
|
|
718
|
+
this.#loading = false;
|
|
719
|
+
this.#queueRender();
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
#queueRender() {
|
|
725
|
+
if (this.#renderQueued) return;
|
|
726
|
+
this.#renderQueued = true;
|
|
727
|
+
queueMicrotask(() => {
|
|
728
|
+
this.#renderQueued = false;
|
|
729
|
+
if (this.isConnected) this.#render();
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** @param {Element} element */
|
|
734
|
+
#focusSelector(element) {
|
|
735
|
+
if (element.classList.contains("sp-slot")) {
|
|
736
|
+
const value = element.getAttribute("data-value") || "";
|
|
737
|
+
return value ? `.sp-slot[data-value="${CSS.escape(value)}"]` : "";
|
|
738
|
+
}
|
|
739
|
+
if (element.classList.contains("sp-strip-day")) {
|
|
740
|
+
const date = element.getAttribute("data-date") || "";
|
|
741
|
+
return date ? `.sp-strip-day[data-date="${CSS.escape(date)}"]` : "";
|
|
742
|
+
}
|
|
743
|
+
if (element.classList.contains("sp-nav-prev")) return ".sp-nav-prev";
|
|
744
|
+
if (element.classList.contains("sp-nav-next")) return ".sp-nav-next";
|
|
745
|
+
if (element.classList.contains("sp-home")) return ".sp-home";
|
|
746
|
+
if (element.classList.contains("sp-range-empty-availability")) return ".sp-range-empty-availability";
|
|
747
|
+
if (element.classList.contains("sp-range-empty-next")) return ".sp-range-empty-next";
|
|
748
|
+
if (element.classList.contains("sp-more-button")) return ".sp-more-button";
|
|
749
|
+
if (element.classList.contains("sp-notice")) {
|
|
750
|
+
const index = element.getAttribute("data-day-index") || "";
|
|
751
|
+
return index ? `.sp-notice[data-day-index="${CSS.escape(index)}"]` : "";
|
|
752
|
+
}
|
|
753
|
+
return "";
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* The previously focused control vanished (data or range change): fall back
|
|
758
|
+
* to the single roving slot, then to the consulted day control. No heuristic
|
|
759
|
+
* position, and focus is only restored when it was already inside.
|
|
760
|
+
*/
|
|
761
|
+
#focusFallback() {
|
|
762
|
+
return this.querySelector('.sp-slot[tabindex="0"]') ?? this.querySelector('.sp-strip-day[tabindex="0"]');
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Days actually handed to a projection. The civil range never changes; only
|
|
767
|
+
* `closed-days="hide"` removes closed days, and in `layout="day"` the
|
|
768
|
+
* consulted day is kept even when closed so data arrival can never move
|
|
769
|
+
* `activeDate` on its own.
|
|
770
|
+
* @returns {SlotDay[]}
|
|
771
|
+
*/
|
|
772
|
+
#projectedDays() {
|
|
773
|
+
const count = this.visibleDayCount;
|
|
774
|
+
if (!count) return [];
|
|
775
|
+
const days = visibleDays(this.#days, this.start, count);
|
|
776
|
+
if (this.closedDays !== "hide") return days;
|
|
777
|
+
const active = this.activeDate;
|
|
778
|
+
return days.filter((day) => !day.closed || (this.layout === "day" && day.date === active));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
#render() {
|
|
782
|
+
const focused = document.activeElement;
|
|
783
|
+
const fallbackSelector =
|
|
784
|
+
focused instanceof Element && this.contains(focused) ? this.#focusSelector(focused) : "";
|
|
785
|
+
const pendingSelector = this.#pendingFocus;
|
|
786
|
+
this.#pendingFocus = "";
|
|
787
|
+
|
|
788
|
+
const count = this.visibleDayCount;
|
|
789
|
+
const invalid = count === 0;
|
|
790
|
+
// The civil range stays the source of truth; the projection may hide closed
|
|
791
|
+
// days without changing `range`, `previous()/next()` or `source.load()`.
|
|
792
|
+
const civilDays = count ? visibleDays(this.#days, this.start, count) : [];
|
|
793
|
+
const days = this.#projectedDays();
|
|
794
|
+
const allClosed = civilDays.length > 0 && civilDays.every((day) => day.closed);
|
|
795
|
+
// Resolve at render time so `setDefaultMessages()` reaches live instances.
|
|
796
|
+
const messages = resolveMessages(this.#messages);
|
|
797
|
+
const locale = this.locale;
|
|
798
|
+
const canPrevious = !invalid && (!this.min || compareDates(this.start, this.min) > 0);
|
|
799
|
+
const canNext = !invalid && (!this.max || compareDates(rangeEnd(this.start, count), this.max) < 0);
|
|
800
|
+
|
|
801
|
+
// A range is "empty" only when the projected days have no meaningful
|
|
802
|
+
// content. A notice or a closed day without slots keeps the projection.
|
|
803
|
+
const empty = !this.#loading && !this.#error && !hasDayContent(days);
|
|
804
|
+
|
|
805
|
+
let content;
|
|
806
|
+
let hasOverflow = false;
|
|
807
|
+
if (empty) {
|
|
808
|
+
content = rangeEmpty({
|
|
809
|
+
start: this.range.start,
|
|
810
|
+
end: this.range.end,
|
|
811
|
+
invalid,
|
|
812
|
+
canNext,
|
|
813
|
+
nextAvailability: this.hasAttribute("next-availability"),
|
|
814
|
+
closed: allClosed,
|
|
815
|
+
locale,
|
|
816
|
+
messages,
|
|
817
|
+
});
|
|
818
|
+
} else if (this.layout === "day") {
|
|
819
|
+
content = renderDay({
|
|
820
|
+
visible: days,
|
|
821
|
+
activeDate: this.activeDate,
|
|
822
|
+
today: todayValue(),
|
|
823
|
+
value: this.value,
|
|
824
|
+
focusedValue: this.#focusedValue,
|
|
825
|
+
noticeDisplay: this.noticeDisplay,
|
|
826
|
+
messages,
|
|
827
|
+
locale,
|
|
828
|
+
}).html;
|
|
829
|
+
} else {
|
|
830
|
+
const rendered = renderColumns({
|
|
831
|
+
visible: days,
|
|
832
|
+
value: this.value,
|
|
833
|
+
focusedValue: this.#focusedValue,
|
|
834
|
+
maxVisibleRows: this.maxVisibleRows,
|
|
835
|
+
expanded: this.expanded,
|
|
836
|
+
noticeDisplay: this.noticeDisplay,
|
|
837
|
+
messages,
|
|
838
|
+
locale,
|
|
839
|
+
});
|
|
840
|
+
content = rendered.html;
|
|
841
|
+
hasOverflow = rendered.hasOverflow;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Loading is signalled by `aria-busy` plus a delayed CSS fade, never by a
|
|
845
|
+
// visible status line: the collapsed footprint must not shift. The live
|
|
846
|
+
// text stays available to assistive tech but is visually hidden.
|
|
847
|
+
const status = this.#loading
|
|
848
|
+
? `<div class="sp-status sp-visually-hidden" role="status">${escapeHtml(messages.loading)}</div>`
|
|
849
|
+
: this.#error
|
|
850
|
+
? `<div class="sp-status sp-status-error" role="status">${escapeHtml(String(this.#error))}</div>`
|
|
851
|
+
: "";
|
|
852
|
+
|
|
853
|
+
// `home` is a shortcut to a reference date, not the mirror of `next`. When
|
|
854
|
+
// `home-date` is configured the wrapper stays in the DOM so its height is
|
|
855
|
+
// reserved, while the button only appears when the reference date is
|
|
856
|
+
// actually outside the visible range.
|
|
857
|
+
const homeAvailable = this.hasAttribute("home-date") && !invalid;
|
|
858
|
+
const homeInRange =
|
|
859
|
+
homeAvailable &&
|
|
860
|
+
compareDates(this.homeDate, this.range.start) >= 0 &&
|
|
861
|
+
compareDates(this.homeDate, this.range.end) <= 0;
|
|
862
|
+
|
|
863
|
+
this.style.setProperty("--_sp-day-count", String(Math.max(1, days.length)));
|
|
864
|
+
this.style.setProperty("--_sp-max-visible-rows", String(this.maxVisibleRows));
|
|
865
|
+
// `aria-busy` is the single source of loading state; the fade is pure CSS.
|
|
866
|
+
if (this.#loading) this.setAttribute("aria-busy", "true");
|
|
867
|
+
else this.removeAttribute("aria-busy");
|
|
868
|
+
this.innerHTML = `<div class="sp-shell" data-layout="${this.layout}">
|
|
869
|
+
<div class="sp-projection">
|
|
870
|
+
<button type="button" class="sp-nav sp-nav-prev" aria-label="${escapeAttr(messages.previous)}"${canPrevious ? "" : " disabled"}>${PREV_ICON}</button>
|
|
871
|
+
<div class="sp-content">
|
|
872
|
+
${status}
|
|
873
|
+
${content}
|
|
874
|
+
${
|
|
875
|
+
hasOverflow
|
|
876
|
+
? `<div class="sp-more"><button type="button" class="sp-more-button">${escapeHtml(this.expanded ? messages.showLess : messages.showMore)}</button></div>`
|
|
877
|
+
: ""
|
|
878
|
+
}
|
|
879
|
+
</div>
|
|
880
|
+
<button type="button" class="sp-nav sp-nav-next" aria-label="${escapeAttr(messages.next)}"${canNext ? "" : " disabled"}>${NEXT_ICON}</button>
|
|
881
|
+
</div>
|
|
882
|
+
${
|
|
883
|
+
homeAvailable
|
|
884
|
+
? `<div class="sp-shortcuts">${
|
|
885
|
+
homeInRange ? "" : `<button type="button" class="sp-home">${escapeHtml(messages.home)}</button>`
|
|
886
|
+
}</div>`
|
|
887
|
+
: ""
|
|
888
|
+
}
|
|
889
|
+
</div>`;
|
|
890
|
+
|
|
891
|
+
const selector = pendingSelector || fallbackSelector;
|
|
892
|
+
if (selector) {
|
|
893
|
+
const target = this.querySelector(selector) ?? this.#focusFallback();
|
|
894
|
+
if (target instanceof HTMLElement) target.focus({ preventScroll: true });
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** @param {MouseEvent} event */
|
|
899
|
+
#onClick(event) {
|
|
900
|
+
const target = /** @type {Element|null} */ (event.target instanceof Element ? event.target : null);
|
|
901
|
+
if (!target) return;
|
|
902
|
+
|
|
903
|
+
if (target.closest(".sp-nav-prev")) return this.previous();
|
|
904
|
+
if (target.closest(".sp-nav-next")) return this.next();
|
|
905
|
+
if (target.closest(".sp-home")) {
|
|
906
|
+
// The shortcut disappears once the reference date is back in range, so
|
|
907
|
+
// hand focus to the roving slot instead of losing it with the button.
|
|
908
|
+
this.#pendingFocus = '.sp-slot[tabindex="0"]';
|
|
909
|
+
return this.goHome();
|
|
910
|
+
}
|
|
911
|
+
if (target.closest(".sp-nav-availability")) {
|
|
912
|
+
void this.goToNextAvailability();
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (target.closest(".sp-range-empty-availability")) {
|
|
916
|
+
void this.goToNextAvailability();
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (target.closest(".sp-range-empty-next")) return this.next();
|
|
920
|
+
|
|
921
|
+
const more = target.closest(".sp-more-button");
|
|
922
|
+
if (more) {
|
|
923
|
+
this.expanded = !this.expanded;
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const stripDay = target.closest(".sp-strip-day");
|
|
928
|
+
if (stripDay instanceof HTMLButtonElement) {
|
|
929
|
+
const date = stripDay.getAttribute("data-date") || "";
|
|
930
|
+
if (isDateValue(date)) {
|
|
931
|
+
this.#pendingFocus = `.sp-strip-day[data-date="${CSS.escape(date)}"]`;
|
|
932
|
+
this.#setActiveDate(date);
|
|
933
|
+
}
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// `noticeactivate` belongs to the explicit control, never to displayed
|
|
938
|
+
// text, so there is no mouse-only activation.
|
|
939
|
+
const notice = target.closest(".sp-notice[data-day-index]");
|
|
940
|
+
if (notice instanceof HTMLElement) {
|
|
941
|
+
const dayIndex = Number(notice.getAttribute("data-day-index"));
|
|
942
|
+
const day = this.#projectedDays()[dayIndex];
|
|
943
|
+
if (day?.notice) {
|
|
944
|
+
this.dispatchEvent(
|
|
945
|
+
new CustomEvent("noticeactivate", {
|
|
946
|
+
detail: { day, notice: day.notice, anchor: notice },
|
|
947
|
+
bubbles: true,
|
|
948
|
+
}),
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const slotButton = target.closest(".sp-slot");
|
|
955
|
+
if (slotButton instanceof HTMLButtonElement) this.#activateSlot(slotButton);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** @param {KeyboardEvent} event */
|
|
959
|
+
#onKeyDown(event) {
|
|
960
|
+
if (!(event.target instanceof HTMLButtonElement)) return;
|
|
961
|
+
const button = event.target;
|
|
962
|
+
if (button.classList.contains("sp-strip-day")) return this.#onStripKeyDown(event);
|
|
963
|
+
if (!button.classList.contains("sp-slot")) return;
|
|
964
|
+
|
|
965
|
+
/** @type {Record<string,FocusDirection>} */
|
|
966
|
+
const map = {
|
|
967
|
+
ArrowUp: "up",
|
|
968
|
+
ArrowDown: "down",
|
|
969
|
+
ArrowLeft: "left",
|
|
970
|
+
ArrowRight: "right",
|
|
971
|
+
Home: "home",
|
|
972
|
+
End: "end",
|
|
973
|
+
};
|
|
974
|
+
const direction = map[event.key];
|
|
975
|
+
if (!direction) return;
|
|
976
|
+
event.preventDefault();
|
|
977
|
+
|
|
978
|
+
const allDays = this.#projectedDays();
|
|
979
|
+
if (this.layout === "day") {
|
|
980
|
+
this.#onPanelKeyDown(event, allDays, direction);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Move focus within the rendered rows only, otherwise the target button
|
|
985
|
+
// does not exist in the DOM and focus would be lost.
|
|
986
|
+
const { days } = collapsedDays(allDays, this.maxVisibleRows, this.expanded);
|
|
987
|
+
const current = {
|
|
988
|
+
dayIndex: Number(button.dataset.dayIndex),
|
|
989
|
+
slotIndex: Number(button.dataset.slotIndex),
|
|
990
|
+
};
|
|
991
|
+
const next = moveFocus(days, current, direction);
|
|
992
|
+
const nextDay = days[next.dayIndex];
|
|
993
|
+
const nextSlot = nextDay?.slots[next.slotIndex];
|
|
994
|
+
if (!nextSlot || nextSlot.disabled) return;
|
|
995
|
+
|
|
996
|
+
const value = slotValue(nextDay.date, nextSlot.start);
|
|
997
|
+
this.#focusedValue = value;
|
|
998
|
+
this.#pendingFocus = `.sp-slot[data-value="${CSS.escape(value)}"]`;
|
|
999
|
+
this.#render();
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** @param {KeyboardEvent} event */
|
|
1003
|
+
#onStripKeyDown(event) {
|
|
1004
|
+
/** @type {Record<string,number|string>} */
|
|
1005
|
+
const map = { ArrowLeft: -1, ArrowRight: 1, Home: "home", End: "end" };
|
|
1006
|
+
const action = map[event.key];
|
|
1007
|
+
if (action === undefined) return;
|
|
1008
|
+
event.preventDefault();
|
|
1009
|
+
|
|
1010
|
+
const days = this.#projectedDays();
|
|
1011
|
+
const currentIndex = Math.max(
|
|
1012
|
+
0,
|
|
1013
|
+
days.findIndex((day) => day.date === this.activeDate),
|
|
1014
|
+
);
|
|
1015
|
+
let nextIndex = currentIndex;
|
|
1016
|
+
if (action === "home") nextIndex = 0;
|
|
1017
|
+
else if (action === "end") nextIndex = days.length - 1;
|
|
1018
|
+
else if (typeof action === "number")
|
|
1019
|
+
nextIndex = Math.max(0, Math.min(days.length - 1, currentIndex + action));
|
|
1020
|
+
|
|
1021
|
+
const next = days[nextIndex];
|
|
1022
|
+
if (!next || next.date === this.activeDate) {
|
|
1023
|
+
const same = this.querySelector(`.sp-strip-day[data-date="${CSS.escape(this.activeDate)}"]`);
|
|
1024
|
+
if (same instanceof HTMLButtonElement) same.focus();
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
this.#pendingFocus = `.sp-strip-day[data-date="${CSS.escape(next.date)}"]`;
|
|
1028
|
+
this.#setActiveDate(next.date);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* @param {KeyboardEvent} event
|
|
1033
|
+
* @param {SlotDay[]} days
|
|
1034
|
+
* @param {string} direction
|
|
1035
|
+
*/
|
|
1036
|
+
#onPanelKeyDown(event, days, direction) {
|
|
1037
|
+
if (!(event.target instanceof HTMLButtonElement)) return;
|
|
1038
|
+
const button = event.target;
|
|
1039
|
+
const activeIndex = Math.max(
|
|
1040
|
+
0,
|
|
1041
|
+
days.findIndex((day) => day.date === this.activeDate),
|
|
1042
|
+
);
|
|
1043
|
+
const active = days[activeIndex];
|
|
1044
|
+
if (!active) return;
|
|
1045
|
+
const enabled = active.slots
|
|
1046
|
+
.map((slot, slotIndex) => ({ slot, slotIndex }))
|
|
1047
|
+
.filter(({ slot }) => !slot.disabled);
|
|
1048
|
+
if (!enabled.length) return;
|
|
1049
|
+
|
|
1050
|
+
const currentSlotIndex = Number(button.dataset.slotIndex);
|
|
1051
|
+
let position = enabled.findIndex(({ slotIndex }) => slotIndex === currentSlotIndex);
|
|
1052
|
+
if (position < 0) {
|
|
1053
|
+
const currentValue = button.getAttribute("data-value") || "";
|
|
1054
|
+
position = Math.max(
|
|
1055
|
+
0,
|
|
1056
|
+
enabled.findIndex(
|
|
1057
|
+
({ slot }) =>
|
|
1058
|
+
slotValue(active.date, slot.start) === (this.#focusedValue || this.value || currentValue),
|
|
1059
|
+
),
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
let nextPosition = position;
|
|
1063
|
+
if (direction === "home") nextPosition = 0;
|
|
1064
|
+
else if (direction === "end") nextPosition = enabled.length - 1;
|
|
1065
|
+
else if (direction === "left" || direction === "up") nextPosition = Math.max(0, position - 1);
|
|
1066
|
+
else nextPosition = Math.min(enabled.length - 1, position + 1);
|
|
1067
|
+
|
|
1068
|
+
const next = enabled[nextPosition];
|
|
1069
|
+
if (!next) return;
|
|
1070
|
+
const value = slotValue(active.date, next.slot.start);
|
|
1071
|
+
this.#focusedValue = value;
|
|
1072
|
+
this.#pendingFocus = `.sp-slot[data-value="${CSS.escape(value)}"]`;
|
|
1073
|
+
this.#render();
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** @param {HTMLButtonElement} button */
|
|
1077
|
+
#activateSlot(button) {
|
|
1078
|
+
if (button.disabled) return;
|
|
1079
|
+
const dayIndex = Number(button.dataset.dayIndex);
|
|
1080
|
+
const slotIndex = Number(button.dataset.slotIndex);
|
|
1081
|
+
const day = this.#projectedDays()[dayIndex];
|
|
1082
|
+
const slot = day?.slots[slotIndex];
|
|
1083
|
+
if (!day || !slot || slot.disabled) return;
|
|
1084
|
+
|
|
1085
|
+
const value = slotValue(day.date, slot.start);
|
|
1086
|
+
this.#pendingFocus = `.sp-slot[data-value="${CSS.escape(value)}"]`;
|
|
1087
|
+
// The consulted day follows the last explicitly targeted slot,
|
|
1088
|
+
// so switching projections keeps showing the selected day.
|
|
1089
|
+
this.#setActiveDate(day.date);
|
|
1090
|
+
this.value = value;
|
|
1091
|
+
this.#focusedValue = value;
|
|
1092
|
+
this.dispatchEvent(new CustomEvent("slotactivate", { detail: { value, day, slot }, bubbles: true }));
|
|
1093
|
+
}
|
|
1094
|
+
}
|