@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,1382 @@
|
|
|
1
|
+
/*** @lekoala/slot-picker v0.1.0 - https://github.com/lekoala/slot-picker ***/
|
|
2
|
+
(() => {
|
|
3
|
+
// src/date.js
|
|
4
|
+
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
5
|
+
function isDateValue(value) {
|
|
6
|
+
if (!DATE_RE.test(value))
|
|
7
|
+
return false;
|
|
8
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
9
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
10
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
11
|
+
}
|
|
12
|
+
function parts(value) {
|
|
13
|
+
if (!isDateValue(value))
|
|
14
|
+
throw new TypeError(`Invalid civil date: ${value}`);
|
|
15
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
16
|
+
return { year, month, day };
|
|
17
|
+
}
|
|
18
|
+
function addDays(value, amount) {
|
|
19
|
+
const { year, month, day } = parts(value);
|
|
20
|
+
const date = new Date(Date.UTC(year, month - 1, day + amount));
|
|
21
|
+
return [
|
|
22
|
+
String(date.getUTCFullYear()).padStart(4, "0"),
|
|
23
|
+
String(date.getUTCMonth() + 1).padStart(2, "0"),
|
|
24
|
+
String(date.getUTCDate()).padStart(2, "0")
|
|
25
|
+
].join("-");
|
|
26
|
+
}
|
|
27
|
+
function compareDates(a, b) {
|
|
28
|
+
return a.localeCompare(b);
|
|
29
|
+
}
|
|
30
|
+
function daysBetween(a, b) {
|
|
31
|
+
return Math.round((toUtcDate(b).getTime() - toUtcDate(a).getTime()) / 86400000);
|
|
32
|
+
}
|
|
33
|
+
function rangeEnd(start, dayCount) {
|
|
34
|
+
return addDays(start, Math.max(1, dayCount) - 1);
|
|
35
|
+
}
|
|
36
|
+
function toUtcDate(value) {
|
|
37
|
+
const { year, month, day } = parts(value);
|
|
38
|
+
return new Date(Date.UTC(year, month - 1, day));
|
|
39
|
+
}
|
|
40
|
+
function todayValue() {
|
|
41
|
+
const now = new Date;
|
|
42
|
+
return [
|
|
43
|
+
String(now.getFullYear()).padStart(4, "0"),
|
|
44
|
+
String(now.getMonth() + 1).padStart(2, "0"),
|
|
45
|
+
String(now.getDate()).padStart(2, "0")
|
|
46
|
+
].join("-");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/messages.js
|
|
50
|
+
var DEFAULT_MESSAGES = {
|
|
51
|
+
previous: "Previous days",
|
|
52
|
+
next: "Next days",
|
|
53
|
+
home: "Back to start",
|
|
54
|
+
nextAvailability: "Next availability",
|
|
55
|
+
showMore: "Show more availability",
|
|
56
|
+
showLess: "Show less",
|
|
57
|
+
loading: "Loading availability",
|
|
58
|
+
empty: "No availability",
|
|
59
|
+
closed: "Closed",
|
|
60
|
+
closedRange: "Closed during this period",
|
|
61
|
+
rangeEmptyTitle: "No availability in this period",
|
|
62
|
+
rangeEmptyDescription: "From {start} to {end}",
|
|
63
|
+
rangeEmptyNext: "See the next period",
|
|
64
|
+
oneSlot: "1 slot",
|
|
65
|
+
manySlots: "{n} slots",
|
|
66
|
+
days: "Days"
|
|
67
|
+
};
|
|
68
|
+
var defaults = { ...DEFAULT_MESSAGES };
|
|
69
|
+
function resolveMessages(messages) {
|
|
70
|
+
return { ...defaults, ...messages ?? {} };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/model.js
|
|
74
|
+
var TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
75
|
+
var RESPONSIVE_BREAKPOINTS = Object.freeze([
|
|
76
|
+
Object.freeze({ minWidth: 600, dayCount: 5 }),
|
|
77
|
+
Object.freeze({ minWidth: 480, dayCount: 4 }),
|
|
78
|
+
Object.freeze({ minWidth: 360, dayCount: 3 }),
|
|
79
|
+
Object.freeze({ minWidth: 260, dayCount: 2 }),
|
|
80
|
+
Object.freeze({ minWidth: 0, dayCount: 1 })
|
|
81
|
+
]);
|
|
82
|
+
var SLOT_RE = /^(\d{4}-\d{2}-\d{2})T((?:[01]\d|2[0-3]):[0-5]\d)$/;
|
|
83
|
+
function isTimeValue(value) {
|
|
84
|
+
return TIME_RE.test(value);
|
|
85
|
+
}
|
|
86
|
+
function isSlotValue(value) {
|
|
87
|
+
const match = SLOT_RE.exec(value);
|
|
88
|
+
return match !== null && isDateValue(match[1]) && isTimeValue(match[2]);
|
|
89
|
+
}
|
|
90
|
+
function slotValue(date, time) {
|
|
91
|
+
if (!isDateValue(date) || !isTimeValue(time))
|
|
92
|
+
throw new TypeError("Invalid slot date/time");
|
|
93
|
+
return `${date}T${time}`;
|
|
94
|
+
}
|
|
95
|
+
function normalizeDays(input) {
|
|
96
|
+
if (!Array.isArray(input))
|
|
97
|
+
throw new TypeError("days must be an array");
|
|
98
|
+
const seen = new Set;
|
|
99
|
+
return input.map((day) => {
|
|
100
|
+
if (!day || !isDateValue(day.date))
|
|
101
|
+
throw new TypeError("Each day needs a YYYY-MM-DD date");
|
|
102
|
+
if (seen.has(day.date))
|
|
103
|
+
throw new TypeError(`Duplicate day: ${day.date}`);
|
|
104
|
+
seen.add(day.date);
|
|
105
|
+
const slots = Array.isArray(day.slots) ? day.slots : [];
|
|
106
|
+
const seenStarts = new Set;
|
|
107
|
+
const normalizedSlots = slots.map((slot) => {
|
|
108
|
+
if (!slot || !isTimeValue(slot.start)) {
|
|
109
|
+
throw new TypeError(`Invalid slot start on ${day.date}`);
|
|
110
|
+
}
|
|
111
|
+
if (slot.end && !isTimeValue(slot.end)) {
|
|
112
|
+
throw new TypeError(`Invalid slot end on ${day.date}`);
|
|
113
|
+
}
|
|
114
|
+
if (slot.tone !== undefined && (typeof slot.tone !== "string" || slot.tone.trim() === "")) {
|
|
115
|
+
throw new TypeError(`Invalid slot tone on ${day.date}`);
|
|
116
|
+
}
|
|
117
|
+
if (seenStarts.has(slot.start)) {
|
|
118
|
+
throw new TypeError(`Duplicate slot: ${day.date}T${slot.start}`);
|
|
119
|
+
}
|
|
120
|
+
seenStarts.add(slot.start);
|
|
121
|
+
return { ...slot };
|
|
122
|
+
}).sort((a, b) => a.start.localeCompare(b.start));
|
|
123
|
+
const notice = day.notice && typeof day.notice.label === "string" ? { ...day.notice } : undefined;
|
|
124
|
+
if (day.closed !== undefined && typeof day.closed !== "boolean") {
|
|
125
|
+
throw new TypeError(`Invalid day closed on ${day.date}`);
|
|
126
|
+
}
|
|
127
|
+
if (day.closed === true && normalizedSlots.length > 0) {
|
|
128
|
+
throw new TypeError(`Closed day cannot have slots: ${day.date}`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
date: day.date,
|
|
132
|
+
slots: normalizedSlots,
|
|
133
|
+
...day.closed ? { closed: true } : {},
|
|
134
|
+
...notice ? { notice } : {}
|
|
135
|
+
};
|
|
136
|
+
}).sort((a, b) => a.date.localeCompare(b.date));
|
|
137
|
+
}
|
|
138
|
+
function visibleDays(days, start, dayCount) {
|
|
139
|
+
const normalized = normalizeDays(days);
|
|
140
|
+
const byDate = new Map(normalized.map((day) => [day.date, day]));
|
|
141
|
+
return Array.from({ length: dayCount }, (_, index) => {
|
|
142
|
+
const date = addDays(start, index);
|
|
143
|
+
return byDate.get(date) ?? { date, slots: [] };
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function collapsedDays(days, maxVisibleRows, expanded) {
|
|
147
|
+
const maxRows = Math.max(0, ...days.map((day) => day.slots.length));
|
|
148
|
+
const rows = expanded ? maxRows : Math.min(maxRows, maxVisibleRows);
|
|
149
|
+
return {
|
|
150
|
+
rows,
|
|
151
|
+
hasOverflow: maxRows > maxVisibleRows,
|
|
152
|
+
days: days.map((day) => ({ ...day, slots: day.slots.slice(0, rows) }))
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function isValidRange(min, max) {
|
|
156
|
+
return !min || !max || compareDates(min, max) <= 0;
|
|
157
|
+
}
|
|
158
|
+
function boundedDayCount(dayCount, min, max) {
|
|
159
|
+
const requested = Math.max(1, Number(dayCount) || 1);
|
|
160
|
+
if (!min || !max)
|
|
161
|
+
return requested;
|
|
162
|
+
if (compareDates(min, max) > 0)
|
|
163
|
+
return 0;
|
|
164
|
+
return Math.min(requested, daysBetween(min, max) + 1);
|
|
165
|
+
}
|
|
166
|
+
function normalizeBreakpoints(breakpoints) {
|
|
167
|
+
return breakpoints.map((breakpoint) => ({
|
|
168
|
+
minWidth: Math.max(0, Number(breakpoint?.minWidth) || 0),
|
|
169
|
+
dayCount: Math.max(1, Math.min(14, Number(breakpoint?.dayCount) || 1))
|
|
170
|
+
})).sort((a, b) => b.minWidth - a.minWidth);
|
|
171
|
+
}
|
|
172
|
+
function resolveVisibleDayCount(dayCount, width, breakpoints = RESPONSIVE_BREAKPOINTS) {
|
|
173
|
+
const requested = Math.max(0, Number(dayCount) || 0);
|
|
174
|
+
if (requested === 0)
|
|
175
|
+
return 0;
|
|
176
|
+
const ladder = breakpoints.length ? breakpoints : RESPONSIVE_BREAKPOINTS;
|
|
177
|
+
const size = Number.isFinite(width) ? width : 0;
|
|
178
|
+
const match = ladder.find((breakpoint) => size >= breakpoint.minWidth) ?? ladder[ladder.length - 1];
|
|
179
|
+
return Math.min(requested, match.dayCount);
|
|
180
|
+
}
|
|
181
|
+
function clampStart(start, min, max, dayCount) {
|
|
182
|
+
let next = start;
|
|
183
|
+
if (min && compareDates(next, min) < 0)
|
|
184
|
+
next = min;
|
|
185
|
+
const count = Math.max(1, dayCount);
|
|
186
|
+
if (max) {
|
|
187
|
+
const latest = addDays(max, -(count - 1));
|
|
188
|
+
if (compareDates(latest, min || next) < 0)
|
|
189
|
+
next = min || latest;
|
|
190
|
+
else if (compareDates(next, latest) > 0)
|
|
191
|
+
next = latest;
|
|
192
|
+
}
|
|
193
|
+
return next;
|
|
194
|
+
}
|
|
195
|
+
function ensureVisible(start, dayCount, date, min, max) {
|
|
196
|
+
const count = Math.max(1, dayCount);
|
|
197
|
+
const bounded = clampStart(start, min, max, count);
|
|
198
|
+
if (!isDateValue(date))
|
|
199
|
+
return bounded;
|
|
200
|
+
const end = rangeEnd(bounded, count);
|
|
201
|
+
if (compareDates(date, bounded) >= 0 && compareDates(date, end) <= 0)
|
|
202
|
+
return bounded;
|
|
203
|
+
const candidate = compareDates(date, bounded) < 0 ? date : addDays(date, -(count - 1));
|
|
204
|
+
return clampStart(candidate, min, max, count);
|
|
205
|
+
}
|
|
206
|
+
function hasDayContent(days) {
|
|
207
|
+
return days.some((day) => Array.isArray(day.slots) && day.slots.length > 0 || Boolean(day.notice) || Boolean(day.closed));
|
|
208
|
+
}
|
|
209
|
+
function rangeDetail(start, dayCount) {
|
|
210
|
+
return { start, end: rangeEnd(start, dayCount), dayCount };
|
|
211
|
+
}
|
|
212
|
+
function resolveActiveDate(start, dayCount, activeDate) {
|
|
213
|
+
if (!isDateValue(activeDate))
|
|
214
|
+
return start;
|
|
215
|
+
if (compareDates(activeDate, start) < 0)
|
|
216
|
+
return start;
|
|
217
|
+
const end = rangeEnd(start, dayCount);
|
|
218
|
+
if (compareDates(activeDate, end) > 0)
|
|
219
|
+
return end;
|
|
220
|
+
return activeDate;
|
|
221
|
+
}
|
|
222
|
+
function firstEnabledIndex(day) {
|
|
223
|
+
return day.slots.findIndex((slot) => !slot.disabled);
|
|
224
|
+
}
|
|
225
|
+
function lastEnabledIndex(day) {
|
|
226
|
+
for (let index = day.slots.length - 1;index >= 0; index--) {
|
|
227
|
+
if (!day.slots[index].disabled)
|
|
228
|
+
return index;
|
|
229
|
+
}
|
|
230
|
+
return -1;
|
|
231
|
+
}
|
|
232
|
+
function moveFocus(days, current, direction) {
|
|
233
|
+
const currentDay = days[current.dayIndex];
|
|
234
|
+
if (!currentDay)
|
|
235
|
+
return current;
|
|
236
|
+
if (direction === "home") {
|
|
237
|
+
const index = firstEnabledIndex(currentDay);
|
|
238
|
+
return index < 0 ? current : { dayIndex: current.dayIndex, slotIndex: index };
|
|
239
|
+
}
|
|
240
|
+
if (direction === "end") {
|
|
241
|
+
const index = lastEnabledIndex(currentDay);
|
|
242
|
+
return index < 0 ? current : { dayIndex: current.dayIndex, slotIndex: index };
|
|
243
|
+
}
|
|
244
|
+
if (direction === "up" || direction === "down") {
|
|
245
|
+
const delta = direction === "up" ? -1 : 1;
|
|
246
|
+
let index = current.slotIndex + delta;
|
|
247
|
+
while (index >= 0 && index < currentDay.slots.length) {
|
|
248
|
+
if (!currentDay.slots[index].disabled)
|
|
249
|
+
return { dayIndex: current.dayIndex, slotIndex: index };
|
|
250
|
+
index += delta;
|
|
251
|
+
}
|
|
252
|
+
return current;
|
|
253
|
+
}
|
|
254
|
+
const delta = direction === "left" ? -1 : 1;
|
|
255
|
+
for (let dayIndex = current.dayIndex + delta;dayIndex >= 0 && dayIndex < days.length; dayIndex += delta) {
|
|
256
|
+
const day = days[dayIndex];
|
|
257
|
+
if (!day.slots.length || firstEnabledIndex(day) < 0)
|
|
258
|
+
continue;
|
|
259
|
+
const clamped = Math.min(current.slotIndex, day.slots.length - 1);
|
|
260
|
+
if (!day.slots[clamped].disabled)
|
|
261
|
+
return { dayIndex, slotIndex: clamped };
|
|
262
|
+
for (let distance = 1;distance < day.slots.length; distance++) {
|
|
263
|
+
const before = clamped - distance;
|
|
264
|
+
const after = clamped + distance;
|
|
265
|
+
if (before >= 0 && !day.slots[before].disabled)
|
|
266
|
+
return { dayIndex, slotIndex: before };
|
|
267
|
+
if (after < day.slots.length && !day.slots[after].disabled)
|
|
268
|
+
return { dayIndex, slotIndex: after };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return current;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/source.js
|
|
275
|
+
class SlotSourceController {
|
|
276
|
+
#source = null;
|
|
277
|
+
#controller = null;
|
|
278
|
+
#nextController = null;
|
|
279
|
+
#request = 0;
|
|
280
|
+
#nextRequest = 0;
|
|
281
|
+
set source(source) {
|
|
282
|
+
const isFunction = typeof source === "function";
|
|
283
|
+
const isObject = source !== null && typeof source === "object" && typeof source.load === "function";
|
|
284
|
+
if (source !== null && !isFunction && !isObject) {
|
|
285
|
+
throw new TypeError("source must be a function or an object with a load() method");
|
|
286
|
+
}
|
|
287
|
+
this.abort();
|
|
288
|
+
this.#source = source;
|
|
289
|
+
}
|
|
290
|
+
get source() {
|
|
291
|
+
return this.#source;
|
|
292
|
+
}
|
|
293
|
+
get hasNext() {
|
|
294
|
+
const source = this.#source;
|
|
295
|
+
return Boolean(source && typeof source === "object" && typeof source.next === "function");
|
|
296
|
+
}
|
|
297
|
+
abort() {
|
|
298
|
+
this.#controller?.abort();
|
|
299
|
+
this.#controller = null;
|
|
300
|
+
this.#nextController?.abort();
|
|
301
|
+
this.#nextController = null;
|
|
302
|
+
}
|
|
303
|
+
async load(range) {
|
|
304
|
+
const source = this.#source;
|
|
305
|
+
if (!source)
|
|
306
|
+
return null;
|
|
307
|
+
const loader = typeof source === "function" ? source : source.load;
|
|
308
|
+
this.abort();
|
|
309
|
+
const controller = new AbortController;
|
|
310
|
+
this.#controller = controller;
|
|
311
|
+
const request = ++this.#request;
|
|
312
|
+
try {
|
|
313
|
+
const result = await loader({ ...range, signal: controller.signal });
|
|
314
|
+
if (request !== this.#request || controller.signal.aborted)
|
|
315
|
+
return null;
|
|
316
|
+
return result;
|
|
317
|
+
} finally {
|
|
318
|
+
if (this.#controller === controller)
|
|
319
|
+
this.#controller = null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async next({ after }) {
|
|
323
|
+
const source = this.#source;
|
|
324
|
+
if (!source || typeof source !== "object" || typeof source.next !== "function")
|
|
325
|
+
return null;
|
|
326
|
+
this.#nextController?.abort();
|
|
327
|
+
const controller = new AbortController;
|
|
328
|
+
this.#nextController = controller;
|
|
329
|
+
const request = ++this.#nextRequest;
|
|
330
|
+
try {
|
|
331
|
+
const result = await source.next({ after, signal: controller.signal });
|
|
332
|
+
if (request !== this.#nextRequest || controller.signal.aborted)
|
|
333
|
+
return null;
|
|
334
|
+
return typeof result === "string" ? result : null;
|
|
335
|
+
} finally {
|
|
336
|
+
if (this.#nextController === controller)
|
|
337
|
+
this.#nextController = null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/views/shared.js
|
|
343
|
+
var ENTITIES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
344
|
+
function escapeHtml(value) {
|
|
345
|
+
return String(value).replace(/[&<>"']/g, (char) => ENTITIES[char] || char);
|
|
346
|
+
}
|
|
347
|
+
function escapeAttr(value) {
|
|
348
|
+
return escapeHtml(value);
|
|
349
|
+
}
|
|
350
|
+
function enabledValues(days) {
|
|
351
|
+
const values = [];
|
|
352
|
+
for (const day of days) {
|
|
353
|
+
for (const slot of day.slots) {
|
|
354
|
+
if (!slot.disabled)
|
|
355
|
+
values.push(slotValue(day.date, slot.start));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return values;
|
|
359
|
+
}
|
|
360
|
+
function rovingValue(enabled, ...preferred) {
|
|
361
|
+
for (const value of preferred) {
|
|
362
|
+
if (value && enabled.includes(value))
|
|
363
|
+
return value;
|
|
364
|
+
}
|
|
365
|
+
return enabled[0] || "";
|
|
366
|
+
}
|
|
367
|
+
function slotButton({ date, slot, dayIndex, slotIndex, selected, tabbed }) {
|
|
368
|
+
const value = slotValue(date, slot.start);
|
|
369
|
+
const description = slot.description ? ` aria-description="${escapeAttr(slot.description)}"` : "";
|
|
370
|
+
const disabled = slot.disabled ? ' disabled aria-disabled="true"' : "";
|
|
371
|
+
const tone = slot.tone ? ` data-tone="${escapeAttr(slot.tone)}"` : "";
|
|
372
|
+
return `<button type="button" role="option" class="sp-slot" data-day-index="${dayIndex}" data-slot-index="${slotIndex}" data-value="${escapeAttr(value)}"${tone} aria-selected="${selected ? "true" : "false"}" tabindex="${tabbed ? 0 : -1}"${disabled}${description}>${escapeHtml(slot.start)}</button>`;
|
|
373
|
+
}
|
|
374
|
+
function noticeIndicator(options = {}) {
|
|
375
|
+
if (options.interactive) {
|
|
376
|
+
const accessible = options.description || options.label || "";
|
|
377
|
+
const title = options.label ? ` title="${escapeAttr(options.label)}"` : "";
|
|
378
|
+
return `<button type="button" class="sp-notice" data-day-index="${options.dayIndex ?? 0}" aria-label="${escapeAttr(accessible)}"${title}><span class="sp-notice-dot" aria-hidden="true"></span></button>`;
|
|
379
|
+
}
|
|
380
|
+
return '<span class="sp-notice" aria-hidden="true"><span class="sp-notice-dot"></span></span>';
|
|
381
|
+
}
|
|
382
|
+
function noticeBlock({ label, description }) {
|
|
383
|
+
const body = `<strong class="sp-day-notice-label">${escapeHtml(label)}</strong>${description ? `<span class="sp-day-notice-description">${escapeHtml(description)}</span>` : ""}`;
|
|
384
|
+
return `<div class="sp-day-notice">${body}</div>`;
|
|
385
|
+
}
|
|
386
|
+
function dayEmpty(message) {
|
|
387
|
+
return `<div class="sp-day-empty">${escapeHtml(message)}</div>`;
|
|
388
|
+
}
|
|
389
|
+
function slotCountText(count, messages) {
|
|
390
|
+
if (count <= 0)
|
|
391
|
+
return "–";
|
|
392
|
+
if (count === 1)
|
|
393
|
+
return messages.oneSlot;
|
|
394
|
+
return messages.manySlots.replace("{n}", String(count));
|
|
395
|
+
}
|
|
396
|
+
function rangeEmpty({ start, end, invalid, canNext, nextAvailability, closed, locale, messages }) {
|
|
397
|
+
const showDescription = !invalid && Boolean(start);
|
|
398
|
+
let description = "";
|
|
399
|
+
if (showDescription) {
|
|
400
|
+
const formatter = new Intl.DateTimeFormat(locale, {
|
|
401
|
+
day: "numeric",
|
|
402
|
+
month: "short",
|
|
403
|
+
timeZone: "UTC"
|
|
404
|
+
});
|
|
405
|
+
const text = messages.rangeEmptyDescription.replace("{start}", formatter.format(toUtcDate(start))).replace("{end}", formatter.format(toUtcDate(end)));
|
|
406
|
+
description = `<p class="sp-range-empty-description">${escapeHtml(text)}</p>`;
|
|
407
|
+
}
|
|
408
|
+
const actions = [];
|
|
409
|
+
if (canNext) {
|
|
410
|
+
actions.push(`<button type="button" class="sp-range-empty-next">${escapeHtml(messages.rangeEmptyNext)}</button>`);
|
|
411
|
+
}
|
|
412
|
+
if (nextAvailability && !invalid) {
|
|
413
|
+
actions.push(`<button type="button" class="sp-range-empty-availability">${escapeHtml(messages.nextAvailability)}</button>`);
|
|
414
|
+
}
|
|
415
|
+
const action = actions.length ? `<div class="sp-range-empty-actions">${actions.join("")}</div>` : "";
|
|
416
|
+
const title = closed ? messages.closedRange : messages.rangeEmptyTitle;
|
|
417
|
+
return `<div class="sp-range-empty">
|
|
418
|
+
<strong class="sp-range-empty-title">${escapeHtml(title)}</strong>
|
|
419
|
+
${description}
|
|
420
|
+
${action}
|
|
421
|
+
</div>`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// src/views/columns.js
|
|
425
|
+
var UTC_FORMAT = { timeZone: "UTC" };
|
|
426
|
+
function renderColumns({
|
|
427
|
+
visible,
|
|
428
|
+
value,
|
|
429
|
+
focusedValue,
|
|
430
|
+
maxVisibleRows,
|
|
431
|
+
expanded,
|
|
432
|
+
noticeDisplay,
|
|
433
|
+
messages,
|
|
434
|
+
locale
|
|
435
|
+
}) {
|
|
436
|
+
const formatterDay = new Intl.DateTimeFormat(locale, { weekday: "short", ...UTC_FORMAT });
|
|
437
|
+
const formatterDate = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", ...UTC_FORMAT });
|
|
438
|
+
const { days: collapsed, rows, hasOverflow } = collapsedDays(visible, maxVisibleRows, expanded);
|
|
439
|
+
const enabled = enabledValues(collapsed);
|
|
440
|
+
const firstEnabled = enabled[0] || "";
|
|
441
|
+
const activeFocus = rovingValue(enabled, focusedValue, value, firstEnabled);
|
|
442
|
+
const html = collapsed.map((day, dayIndex) => {
|
|
443
|
+
const date = toUtcDate(day.date);
|
|
444
|
+
const weekday = formatterDay.format(date);
|
|
445
|
+
const displayDate = formatterDate.format(date);
|
|
446
|
+
const inline = noticeDisplay === "inline";
|
|
447
|
+
const action = noticeDisplay === "action";
|
|
448
|
+
const headerNotice = day.notice && (inline || action) ? noticeIndicator({ interactive: true, dayIndex, ...day.notice }) : "";
|
|
449
|
+
const noticeContent = day.notice && inline ? noticeBlock(day.notice) : "";
|
|
450
|
+
const closed = Boolean(day.closed);
|
|
451
|
+
let body;
|
|
452
|
+
if (day.slots.length === 0) {
|
|
453
|
+
body = `${noticeContent}${dayEmpty(closed ? messages.closed : messages.empty)}`;
|
|
454
|
+
} else {
|
|
455
|
+
const slots = day.slots;
|
|
456
|
+
const slotMarkup = slots.map((slot, slotIndex) => {
|
|
457
|
+
const slotVal = slotValue(day.date, slot.start);
|
|
458
|
+
return slotButton({
|
|
459
|
+
date: day.date,
|
|
460
|
+
slot,
|
|
461
|
+
dayIndex,
|
|
462
|
+
slotIndex,
|
|
463
|
+
selected: slotVal === value,
|
|
464
|
+
tabbed: slotVal === activeFocus && !slot.disabled
|
|
465
|
+
});
|
|
466
|
+
}).join("");
|
|
467
|
+
const empties = Array.from({ length: Math.max(0, rows - slots.length) }, () => '<span class="sp-empty" aria-hidden="true">–</span>').join("");
|
|
468
|
+
body = `${noticeContent}<div class="sp-slots" role="listbox" aria-label="${escapeAttr(`${weekday} ${displayDate}`)}">${slotMarkup}${empties}</div>`;
|
|
469
|
+
}
|
|
470
|
+
return `<section class="sp-day" data-date="${escapeAttr(day.date)}"${closed ? " data-closed" : ""}>
|
|
471
|
+
<header class="sp-day-header">
|
|
472
|
+
<span class="sp-weekday">${escapeHtml(weekday)}</span>
|
|
473
|
+
<strong class="sp-date">${escapeHtml(displayDate)}</strong>
|
|
474
|
+
${headerNotice}
|
|
475
|
+
</header>
|
|
476
|
+
${body}
|
|
477
|
+
</section>`;
|
|
478
|
+
}).join("");
|
|
479
|
+
return {
|
|
480
|
+
html: `<div class="sp-grid" role="presentation">${html}</div>`,
|
|
481
|
+
hasOverflow
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/views/day.js
|
|
486
|
+
var UTC_FORMAT2 = { timeZone: "UTC" };
|
|
487
|
+
function renderDay({
|
|
488
|
+
visible,
|
|
489
|
+
activeDate,
|
|
490
|
+
today,
|
|
491
|
+
value,
|
|
492
|
+
focusedValue,
|
|
493
|
+
noticeDisplay,
|
|
494
|
+
messages,
|
|
495
|
+
locale
|
|
496
|
+
}) {
|
|
497
|
+
const formatterStripDay = new Intl.DateTimeFormat(locale, { weekday: "short", ...UTC_FORMAT2 });
|
|
498
|
+
const formatterStripDate = new Intl.DateTimeFormat(locale, {
|
|
499
|
+
day: "numeric",
|
|
500
|
+
month: "short",
|
|
501
|
+
...UTC_FORMAT2
|
|
502
|
+
});
|
|
503
|
+
const formatterPanelDay = new Intl.DateTimeFormat(locale, {
|
|
504
|
+
weekday: "long",
|
|
505
|
+
day: "numeric",
|
|
506
|
+
month: "long",
|
|
507
|
+
...UTC_FORMAT2
|
|
508
|
+
});
|
|
509
|
+
const activeIndex = Math.max(0, visible.findIndex((day) => day.date === activeDate));
|
|
510
|
+
const active = visible[activeIndex];
|
|
511
|
+
const strip = visible.map((day, dayIndex) => {
|
|
512
|
+
const date = toUtcDate(day.date);
|
|
513
|
+
const pressed = day.date === active.date;
|
|
514
|
+
const isToday = day.date === today;
|
|
515
|
+
const closed = Boolean(day.closed);
|
|
516
|
+
const count = closed ? messages.closed : slotCountText(day.slots.length, messages);
|
|
517
|
+
return `<button type="button" class="sp-strip-day" data-date="${escapeAttr(day.date)}" data-day-index="${dayIndex}" aria-pressed="${pressed ? "true" : "false"}"${isToday ? " data-today" : ""}${closed ? " data-closed" : ""} tabindex="${pressed ? 0 : -1}">
|
|
518
|
+
<span class="sp-strip-weekday">${escapeHtml(formatterStripDay.format(date))}</span>
|
|
519
|
+
<strong class="sp-strip-date">${escapeHtml(formatterStripDate.format(date))}</strong>
|
|
520
|
+
<span class="sp-strip-count" aria-hidden="true">${escapeHtml(count)}</span>
|
|
521
|
+
<span class="sp-visually-hidden">${escapeHtml(`${formatterPanelDay.format(date)}: ${count}`)}</span>
|
|
522
|
+
</button>`;
|
|
523
|
+
}).join("");
|
|
524
|
+
const enabled = active.slots.filter((slot) => !slot.disabled).map((slot) => slotValue(active.date, slot.start));
|
|
525
|
+
const firstEnabled = enabled[0] || "";
|
|
526
|
+
const activeFocus = rovingValue(enabled, focusedValue, value, firstEnabled);
|
|
527
|
+
const inline = noticeDisplay === "inline";
|
|
528
|
+
const notice = active.notice && inline ? noticeBlock(active.notice) : "";
|
|
529
|
+
const headerNotice = active.notice && noticeDisplay === "action" ? noticeIndicator({ interactive: true, dayIndex: activeIndex, ...active.notice }) : "";
|
|
530
|
+
const panelLabel = formatterPanelDay.format(toUtcDate(active.date));
|
|
531
|
+
const activeClosed = Boolean(active.closed);
|
|
532
|
+
let panelBody;
|
|
533
|
+
if (active.slots.length === 0) {
|
|
534
|
+
panelBody = `${notice}${dayEmpty(activeClosed ? messages.closed : messages.empty)}`;
|
|
535
|
+
} else {
|
|
536
|
+
const slots = active.slots.map((slot, slotIndex) => {
|
|
537
|
+
const slotVal = slotValue(active.date, slot.start);
|
|
538
|
+
return slotButton({
|
|
539
|
+
date: active.date,
|
|
540
|
+
slot,
|
|
541
|
+
dayIndex: activeIndex,
|
|
542
|
+
slotIndex,
|
|
543
|
+
selected: slotVal === value,
|
|
544
|
+
tabbed: slotVal === activeFocus && !slot.disabled
|
|
545
|
+
});
|
|
546
|
+
}).join("");
|
|
547
|
+
panelBody = `${notice}<div class="sp-panel-slots" role="listbox" aria-label="${escapeAttr(panelLabel)}">${slots}</div>`;
|
|
548
|
+
}
|
|
549
|
+
const html = `<div class="sp-daystrip" role="group" aria-label="${escapeAttr(messages.days)}">${strip}</div>
|
|
550
|
+
<section class="sp-panel" data-date="${escapeAttr(active.date)}"${activeClosed ? " data-closed" : ""}>
|
|
551
|
+
<header class="sp-panel-header">
|
|
552
|
+
<strong class="sp-panel-title">${escapeHtml(panelLabel)}</strong>
|
|
553
|
+
${headerNotice}
|
|
554
|
+
</header>
|
|
555
|
+
${panelBody}
|
|
556
|
+
</section>`;
|
|
557
|
+
return { html };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/slot-picker.js
|
|
561
|
+
var PREV_ICON = '<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>';
|
|
562
|
+
var NEXT_ICON = '<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>';
|
|
563
|
+
var RANGE_ATTRIBUTES = new Set(["start", "min", "max", "day-count", "responsive"]);
|
|
564
|
+
|
|
565
|
+
class SlotPickerElement extends HTMLElement {
|
|
566
|
+
static observedAttributes = [
|
|
567
|
+
"start",
|
|
568
|
+
"day-count",
|
|
569
|
+
"min",
|
|
570
|
+
"max",
|
|
571
|
+
"value",
|
|
572
|
+
"active-date",
|
|
573
|
+
"layout",
|
|
574
|
+
"max-visible-rows",
|
|
575
|
+
"expanded",
|
|
576
|
+
"responsive",
|
|
577
|
+
"home-date",
|
|
578
|
+
"next-availability",
|
|
579
|
+
"notice-display",
|
|
580
|
+
"closed-days",
|
|
581
|
+
"locale",
|
|
582
|
+
"lang"
|
|
583
|
+
];
|
|
584
|
+
#days = [];
|
|
585
|
+
#sourceController = new SlotSourceController;
|
|
586
|
+
#messages = null;
|
|
587
|
+
#loading = false;
|
|
588
|
+
#error = null;
|
|
589
|
+
#focusedValue = "";
|
|
590
|
+
#renderQueued = false;
|
|
591
|
+
#initializing = false;
|
|
592
|
+
#batching = false;
|
|
593
|
+
#pendingFocus = "";
|
|
594
|
+
#resizeObserver = null;
|
|
595
|
+
#measuredWidth = null;
|
|
596
|
+
#breakpoints = null;
|
|
597
|
+
#lastRange = null;
|
|
598
|
+
#lastActiveDate = null;
|
|
599
|
+
#loadedKey = null;
|
|
600
|
+
#loadRequest = 0;
|
|
601
|
+
constructor() {
|
|
602
|
+
super();
|
|
603
|
+
this.addEventListener("click", (event) => this.#onClick(event));
|
|
604
|
+
this.addEventListener("keydown", (event) => this.#onKeyDown(event));
|
|
605
|
+
}
|
|
606
|
+
connectedCallback() {
|
|
607
|
+
this.#initializing = true;
|
|
608
|
+
if (!this.hasAttribute("start"))
|
|
609
|
+
this.setAttribute("start", todayValue());
|
|
610
|
+
if (!this.hasAttribute("day-count"))
|
|
611
|
+
this.setAttribute("day-count", "5");
|
|
612
|
+
if (!this.hasAttribute("layout"))
|
|
613
|
+
this.setAttribute("layout", "columns");
|
|
614
|
+
this.#initializing = false;
|
|
615
|
+
this.#measuredWidth = typeof ResizeObserver === "undefined" ? this.getBoundingClientRect().width : null;
|
|
616
|
+
if (!this.#resizeObserver && typeof ResizeObserver !== "undefined") {
|
|
617
|
+
this.#resizeObserver = new ResizeObserver((entries) => this.#onResize(entries));
|
|
618
|
+
}
|
|
619
|
+
this.#resizeObserver?.observe(this);
|
|
620
|
+
this.#reconcile({ pinActive: false });
|
|
621
|
+
}
|
|
622
|
+
disconnectedCallback() {
|
|
623
|
+
this.#sourceController.abort();
|
|
624
|
+
this.#resizeObserver?.disconnect();
|
|
625
|
+
this.#loadedKey = null;
|
|
626
|
+
}
|
|
627
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
628
|
+
if (oldValue === newValue)
|
|
629
|
+
return;
|
|
630
|
+
if (this.#initializing || this.#batching)
|
|
631
|
+
return;
|
|
632
|
+
if (RANGE_ATTRIBUTES.has(name))
|
|
633
|
+
this.#reconcile({ pinActive: name !== "start" });
|
|
634
|
+
else
|
|
635
|
+
this.#queueRender();
|
|
636
|
+
}
|
|
637
|
+
get start() {
|
|
638
|
+
const value = this.getAttribute("start") || todayValue();
|
|
639
|
+
return isDateValue(value) ? value : todayValue();
|
|
640
|
+
}
|
|
641
|
+
set start(value) {
|
|
642
|
+
if (!isDateValue(value))
|
|
643
|
+
throw new TypeError("start must be YYYY-MM-DD");
|
|
644
|
+
const count = this.visibleDayCount;
|
|
645
|
+
this.setAttribute("start", count > 0 ? clampStart(value, this.min, this.max, count) : value);
|
|
646
|
+
}
|
|
647
|
+
get dayCount() {
|
|
648
|
+
const value = Number.parseInt(this.getAttribute("day-count") || "5", 10);
|
|
649
|
+
return Number.isFinite(value) ? Math.max(1, Math.min(14, value)) : 5;
|
|
650
|
+
}
|
|
651
|
+
set dayCount(value) {
|
|
652
|
+
const next = Math.max(1, Math.min(14, Number(value) || 5));
|
|
653
|
+
this.setAttribute("day-count", String(next));
|
|
654
|
+
}
|
|
655
|
+
get visibleDayCount() {
|
|
656
|
+
if (!isValidRange(this.min, this.max))
|
|
657
|
+
return 0;
|
|
658
|
+
const bounded = boundedDayCount(this.dayCount, this.min, this.max);
|
|
659
|
+
if (!this.responsive || this.#measuredWidth === null)
|
|
660
|
+
return bounded;
|
|
661
|
+
return resolveVisibleDayCount(bounded, this.#measuredWidth, this.#breakpoints ?? RESPONSIVE_BREAKPOINTS);
|
|
662
|
+
}
|
|
663
|
+
get responsive() {
|
|
664
|
+
return this.hasAttribute("responsive");
|
|
665
|
+
}
|
|
666
|
+
set responsive(value) {
|
|
667
|
+
this.toggleAttribute("responsive", Boolean(value));
|
|
668
|
+
}
|
|
669
|
+
get responsiveBreakpoints() {
|
|
670
|
+
return this.#breakpoints ?? RESPONSIVE_BREAKPOINTS;
|
|
671
|
+
}
|
|
672
|
+
set responsiveBreakpoints(value) {
|
|
673
|
+
this.#breakpoints = value?.length ? normalizeBreakpoints(value) : null;
|
|
674
|
+
if (!this.isConnected || this.#batching)
|
|
675
|
+
return;
|
|
676
|
+
this.#reconcile({ pinActive: true });
|
|
677
|
+
}
|
|
678
|
+
get min() {
|
|
679
|
+
const value = this.getAttribute("min");
|
|
680
|
+
return value && isDateValue(value) ? value : "";
|
|
681
|
+
}
|
|
682
|
+
set min(value) {
|
|
683
|
+
if (!value)
|
|
684
|
+
this.removeAttribute("min");
|
|
685
|
+
else if (isDateValue(value))
|
|
686
|
+
this.setAttribute("min", value);
|
|
687
|
+
else
|
|
688
|
+
throw new TypeError("min must be YYYY-MM-DD");
|
|
689
|
+
}
|
|
690
|
+
get max() {
|
|
691
|
+
const value = this.getAttribute("max");
|
|
692
|
+
return value && isDateValue(value) ? value : "";
|
|
693
|
+
}
|
|
694
|
+
set max(value) {
|
|
695
|
+
if (!value)
|
|
696
|
+
this.removeAttribute("max");
|
|
697
|
+
else if (isDateValue(value))
|
|
698
|
+
this.setAttribute("max", value);
|
|
699
|
+
else
|
|
700
|
+
throw new TypeError("max must be YYYY-MM-DD");
|
|
701
|
+
}
|
|
702
|
+
get value() {
|
|
703
|
+
const value = this.getAttribute("value") || "";
|
|
704
|
+
return isSlotValue(value) ? value : "";
|
|
705
|
+
}
|
|
706
|
+
set value(value) {
|
|
707
|
+
if (!value) {
|
|
708
|
+
this.removeAttribute("value");
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (isSlotValue(value))
|
|
712
|
+
this.setAttribute("value", value);
|
|
713
|
+
else
|
|
714
|
+
throw new TypeError("value must be a valid YYYY-MM-DDTHH:mm or empty");
|
|
715
|
+
}
|
|
716
|
+
get activeDate() {
|
|
717
|
+
const count = this.visibleDayCount;
|
|
718
|
+
if (!count)
|
|
719
|
+
return "";
|
|
720
|
+
const raw = this.getAttribute("active-date") || "";
|
|
721
|
+
const base = isDateValue(raw) ? raw : this.start;
|
|
722
|
+
return resolveActiveDate(this.start, count, base);
|
|
723
|
+
}
|
|
724
|
+
set activeDate(value) {
|
|
725
|
+
if (!value)
|
|
726
|
+
this.removeAttribute("active-date");
|
|
727
|
+
else if (isDateValue(value))
|
|
728
|
+
this.setAttribute("active-date", value);
|
|
729
|
+
else
|
|
730
|
+
throw new TypeError("active-date must be YYYY-MM-DD or empty");
|
|
731
|
+
}
|
|
732
|
+
get homeDate() {
|
|
733
|
+
const raw = this.getAttribute("home-date") || "";
|
|
734
|
+
if (isDateValue(raw))
|
|
735
|
+
return raw;
|
|
736
|
+
return this.min || todayValue();
|
|
737
|
+
}
|
|
738
|
+
set homeDate(value) {
|
|
739
|
+
if (!value)
|
|
740
|
+
this.removeAttribute("home-date");
|
|
741
|
+
else if (isDateValue(value))
|
|
742
|
+
this.setAttribute("home-date", value);
|
|
743
|
+
else
|
|
744
|
+
throw new TypeError("home-date must be YYYY-MM-DD or empty");
|
|
745
|
+
}
|
|
746
|
+
get layout() {
|
|
747
|
+
const value = this.getAttribute("layout") || "columns";
|
|
748
|
+
return value === "day" ? "day" : "columns";
|
|
749
|
+
}
|
|
750
|
+
set layout(value) {
|
|
751
|
+
if (value !== "columns" && value !== "day")
|
|
752
|
+
throw new TypeError('layout must be "columns" or "day"');
|
|
753
|
+
this.setAttribute("layout", value);
|
|
754
|
+
}
|
|
755
|
+
get noticeDisplay() {
|
|
756
|
+
const value = this.getAttribute("notice-display");
|
|
757
|
+
return value === "inline" || value === "none" ? value : "action";
|
|
758
|
+
}
|
|
759
|
+
set noticeDisplay(value) {
|
|
760
|
+
if (value !== "inline" && value !== "action" && value !== "none") {
|
|
761
|
+
throw new TypeError('notice-display must be "inline", "action" or "none"');
|
|
762
|
+
}
|
|
763
|
+
this.setAttribute("notice-display", value);
|
|
764
|
+
}
|
|
765
|
+
get closedDays() {
|
|
766
|
+
return this.getAttribute("closed-days") === "hide" ? "hide" : "show";
|
|
767
|
+
}
|
|
768
|
+
set closedDays(value) {
|
|
769
|
+
if (value !== "show" && value !== "hide") {
|
|
770
|
+
throw new TypeError('closed-days must be "show" or "hide"');
|
|
771
|
+
}
|
|
772
|
+
this.setAttribute("closed-days", value);
|
|
773
|
+
}
|
|
774
|
+
get expanded() {
|
|
775
|
+
return this.hasAttribute("expanded");
|
|
776
|
+
}
|
|
777
|
+
set expanded(value) {
|
|
778
|
+
this.toggleAttribute("expanded", Boolean(value));
|
|
779
|
+
}
|
|
780
|
+
get maxVisibleRows() {
|
|
781
|
+
const value = Number.parseInt(this.getAttribute("max-visible-rows") || "5", 10);
|
|
782
|
+
return Number.isFinite(value) ? Math.max(1, value) : 5;
|
|
783
|
+
}
|
|
784
|
+
set maxVisibleRows(value) {
|
|
785
|
+
this.setAttribute("max-visible-rows", String(Math.max(1, Number(value) || 5)));
|
|
786
|
+
}
|
|
787
|
+
get days() {
|
|
788
|
+
return this.#days.map((day) => ({ ...day, slots: day.slots.map((slot) => ({ ...slot })) }));
|
|
789
|
+
}
|
|
790
|
+
set days(value) {
|
|
791
|
+
this.#days = normalizeDays(value);
|
|
792
|
+
this.#error = null;
|
|
793
|
+
this.#queueRender();
|
|
794
|
+
}
|
|
795
|
+
set source(source) {
|
|
796
|
+
this.#sourceController.source = source;
|
|
797
|
+
if (this.isConnected)
|
|
798
|
+
this.#load();
|
|
799
|
+
}
|
|
800
|
+
get source() {
|
|
801
|
+
return this.#sourceController.source;
|
|
802
|
+
}
|
|
803
|
+
set messages(value) {
|
|
804
|
+
this.#messages = value ? { ...value } : null;
|
|
805
|
+
this.#queueRender();
|
|
806
|
+
}
|
|
807
|
+
get locale() {
|
|
808
|
+
const value = this.getAttribute("locale") || this.lang;
|
|
809
|
+
return value || document.documentElement.lang || navigator.language || "en";
|
|
810
|
+
}
|
|
811
|
+
set locale(value) {
|
|
812
|
+
if (!value)
|
|
813
|
+
this.removeAttribute("locale");
|
|
814
|
+
else
|
|
815
|
+
this.setAttribute("locale", value);
|
|
816
|
+
}
|
|
817
|
+
get range() {
|
|
818
|
+
const count = this.visibleDayCount;
|
|
819
|
+
if (!count)
|
|
820
|
+
return { start: "", end: "", dayCount: 0 };
|
|
821
|
+
return rangeDetail(this.start, count);
|
|
822
|
+
}
|
|
823
|
+
previous() {
|
|
824
|
+
this.#navigate(-this.visibleDayCount);
|
|
825
|
+
}
|
|
826
|
+
next() {
|
|
827
|
+
this.#navigate(this.visibleDayCount);
|
|
828
|
+
}
|
|
829
|
+
goTo(date) {
|
|
830
|
+
const count = this.visibleDayCount;
|
|
831
|
+
if (!count)
|
|
832
|
+
return "";
|
|
833
|
+
const target = isDateValue(date) ? this.#clampToBounds(date) : this.start;
|
|
834
|
+
this.#mutate(() => this.#moveTo(target, count));
|
|
835
|
+
this.#commit();
|
|
836
|
+
return this.activeDate;
|
|
837
|
+
}
|
|
838
|
+
goHome() {
|
|
839
|
+
return this.goTo(this.homeDate);
|
|
840
|
+
}
|
|
841
|
+
async goToNextAvailability(options = {}) {
|
|
842
|
+
if (!this.visibleDayCount)
|
|
843
|
+
return null;
|
|
844
|
+
const after = options.after ?? this.range.end;
|
|
845
|
+
if (this.#sourceController.hasNext) {
|
|
846
|
+
const date = await this.#sourceController.next({ after });
|
|
847
|
+
if (date && isDateValue(date)) {
|
|
848
|
+
return this.goTo(date) || null;
|
|
849
|
+
}
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
this.dispatchEvent(new CustomEvent("nextrequest", { detail: { after } }));
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
configure(options = {}) {
|
|
856
|
+
this.#validateConfigure(options);
|
|
857
|
+
this.#mutate(() => {
|
|
858
|
+
if (options.min !== undefined)
|
|
859
|
+
this.min = options.min;
|
|
860
|
+
if (options.max !== undefined)
|
|
861
|
+
this.max = options.max;
|
|
862
|
+
if (options.start !== undefined)
|
|
863
|
+
this.start = options.start;
|
|
864
|
+
if (options.dayCount !== undefined)
|
|
865
|
+
this.dayCount = options.dayCount;
|
|
866
|
+
if (options.layout !== undefined)
|
|
867
|
+
this.layout = options.layout;
|
|
868
|
+
if (options.value !== undefined)
|
|
869
|
+
this.value = options.value;
|
|
870
|
+
if (options.maxVisibleRows !== undefined)
|
|
871
|
+
this.maxVisibleRows = options.maxVisibleRows;
|
|
872
|
+
if (options.responsive !== undefined)
|
|
873
|
+
this.responsive = options.responsive;
|
|
874
|
+
if (options.homeDate !== undefined)
|
|
875
|
+
this.homeDate = options.homeDate ?? "";
|
|
876
|
+
if (options.responsiveBreakpoints !== undefined)
|
|
877
|
+
this.responsiveBreakpoints = options.responsiveBreakpoints;
|
|
878
|
+
if (options.closedDays !== undefined)
|
|
879
|
+
this.closedDays = options.closedDays;
|
|
880
|
+
});
|
|
881
|
+
this.#reconcile({ pinActive: options.start === undefined });
|
|
882
|
+
}
|
|
883
|
+
#validateConfigure(options) {
|
|
884
|
+
if (options.min && !isDateValue(options.min))
|
|
885
|
+
throw new TypeError("min must be YYYY-MM-DD");
|
|
886
|
+
if (options.max && !isDateValue(options.max))
|
|
887
|
+
throw new TypeError("max must be YYYY-MM-DD");
|
|
888
|
+
if (options.start !== undefined && !isDateValue(options.start)) {
|
|
889
|
+
throw new TypeError("start must be YYYY-MM-DD");
|
|
890
|
+
}
|
|
891
|
+
if (options.layout !== undefined && options.layout !== "columns" && options.layout !== "day") {
|
|
892
|
+
throw new TypeError('layout must be "columns" or "day"');
|
|
893
|
+
}
|
|
894
|
+
if (options.value && !isSlotValue(options.value)) {
|
|
895
|
+
throw new TypeError("value must be a valid YYYY-MM-DDTHH:mm or empty");
|
|
896
|
+
}
|
|
897
|
+
if (options.homeDate && !isDateValue(options.homeDate)) {
|
|
898
|
+
throw new TypeError("homeDate must be YYYY-MM-DD or empty");
|
|
899
|
+
}
|
|
900
|
+
if (options.responsiveBreakpoints != null && !Array.isArray(options.responsiveBreakpoints)) {
|
|
901
|
+
throw new TypeError("responsiveBreakpoints must be an array or null");
|
|
902
|
+
}
|
|
903
|
+
if (options.closedDays !== undefined && options.closedDays !== "show" && options.closedDays !== "hide") {
|
|
904
|
+
throw new TypeError('closedDays must be "show" or "hide"');
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
async reload() {
|
|
908
|
+
await this.#load();
|
|
909
|
+
}
|
|
910
|
+
#navigate(delta) {
|
|
911
|
+
const count = this.visibleDayCount;
|
|
912
|
+
if (!count)
|
|
913
|
+
return;
|
|
914
|
+
const next = clampStart(addDays(this.start, delta), this.min, this.max, count);
|
|
915
|
+
if (next === this.start)
|
|
916
|
+
return;
|
|
917
|
+
this.#mutate(() => this.setAttribute("start", next));
|
|
918
|
+
this.#commit();
|
|
919
|
+
}
|
|
920
|
+
#moveTo(target, count) {
|
|
921
|
+
const nextStart = ensureVisible(this.start, count, target, this.min, this.max);
|
|
922
|
+
if (nextStart !== this.getAttribute("start"))
|
|
923
|
+
this.setAttribute("start", nextStart);
|
|
924
|
+
const nextActive = resolveActiveDate(nextStart, count, target);
|
|
925
|
+
if (this.getAttribute("active-date") !== nextActive)
|
|
926
|
+
this.setAttribute("active-date", nextActive);
|
|
927
|
+
}
|
|
928
|
+
#clampToBounds(date) {
|
|
929
|
+
let next = date;
|
|
930
|
+
if (this.min && compareDates(next, this.min) < 0)
|
|
931
|
+
next = this.min;
|
|
932
|
+
if (this.max && compareDates(next, this.max) > 0)
|
|
933
|
+
next = this.max;
|
|
934
|
+
return next;
|
|
935
|
+
}
|
|
936
|
+
#mutate(fn) {
|
|
937
|
+
const wasBatching = this.#batching;
|
|
938
|
+
this.#batching = true;
|
|
939
|
+
try {
|
|
940
|
+
fn();
|
|
941
|
+
} finally {
|
|
942
|
+
this.#batching = wasBatching;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
#reconcile(options = {}) {
|
|
946
|
+
const pinActive = options.pinActive ?? true;
|
|
947
|
+
const reload = options.reload ?? true;
|
|
948
|
+
const wasBatching = this.#batching;
|
|
949
|
+
this.#batching = true;
|
|
950
|
+
try {
|
|
951
|
+
const count = this.visibleDayCount;
|
|
952
|
+
const invalid = count === 0;
|
|
953
|
+
this.toggleAttribute("data-invalid-range", invalid);
|
|
954
|
+
if (!invalid) {
|
|
955
|
+
const rawActive = this.getAttribute("active-date") || "";
|
|
956
|
+
const previous = this.#lastRange;
|
|
957
|
+
let anchor = this.start;
|
|
958
|
+
if (pinActive) {
|
|
959
|
+
if (previous?.dayCount && isDateValue(rawActive)) {
|
|
960
|
+
anchor = resolveActiveDate(previous.start, previous.dayCount, rawActive);
|
|
961
|
+
} else if (isDateValue(rawActive)) {
|
|
962
|
+
anchor = rawActive;
|
|
963
|
+
} else if (previous?.start) {
|
|
964
|
+
anchor = previous.start;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
const nextStart = ensureVisible(this.start, count, anchor, this.min, this.max);
|
|
968
|
+
if (nextStart !== this.getAttribute("start"))
|
|
969
|
+
this.setAttribute("start", nextStart);
|
|
970
|
+
if (pinActive && isDateValue(anchor)) {
|
|
971
|
+
const nextActive = resolveActiveDate(nextStart, count, anchor);
|
|
972
|
+
if (this.getAttribute("active-date") !== nextActive)
|
|
973
|
+
this.setAttribute("active-date", nextActive);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
} finally {
|
|
977
|
+
this.#batching = wasBatching;
|
|
978
|
+
}
|
|
979
|
+
if (wasBatching)
|
|
980
|
+
return;
|
|
981
|
+
this.#commit({ reload });
|
|
982
|
+
}
|
|
983
|
+
#commit(options = {}) {
|
|
984
|
+
const reload = options.reload ?? true;
|
|
985
|
+
const range = this.range;
|
|
986
|
+
const count = this.visibleDayCount;
|
|
987
|
+
const active = count ? this.activeDate : "";
|
|
988
|
+
const previousRange = this.#lastRange;
|
|
989
|
+
const first = previousRange === null;
|
|
990
|
+
const rangeChanged = first || range.start !== previousRange.start || range.end !== previousRange.end || range.dayCount !== previousRange.dayCount;
|
|
991
|
+
const activeChanged = !first && this.#lastActiveDate !== null && active !== this.#lastActiveDate;
|
|
992
|
+
this.#lastRange = range;
|
|
993
|
+
this.#lastActiveDate = active;
|
|
994
|
+
this.#queueRender();
|
|
995
|
+
if (!first && rangeChanged)
|
|
996
|
+
this.dispatchEvent(new CustomEvent("rangechange", { detail: range }));
|
|
997
|
+
if (!first && activeChanged) {
|
|
998
|
+
this.dispatchEvent(new CustomEvent("daychange", { detail: { activeDate: active } }));
|
|
999
|
+
}
|
|
1000
|
+
if (!count) {
|
|
1001
|
+
this.#sourceController.abort();
|
|
1002
|
+
this.#loadRequest += 1;
|
|
1003
|
+
this.#loadedKey = null;
|
|
1004
|
+
this.#loading = false;
|
|
1005
|
+
this.#error = null;
|
|
1006
|
+
} else if (reload && this.#loadedKey !== this.#rangeKey(range)) {
|
|
1007
|
+
this.#load();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
#rangeKey(range) {
|
|
1011
|
+
return `${range.start}|${range.end}|${range.dayCount}`;
|
|
1012
|
+
}
|
|
1013
|
+
#onResize(entries) {
|
|
1014
|
+
const entry = entries[entries.length - 1];
|
|
1015
|
+
const box = entry?.contentBoxSize?.[0];
|
|
1016
|
+
const hostWidth = box ? box.inlineSize : entry?.contentRect.width ?? 0;
|
|
1017
|
+
const width = this.#contentWidth() ?? hostWidth;
|
|
1018
|
+
const previous = this.visibleDayCount;
|
|
1019
|
+
this.#measuredWidth = width;
|
|
1020
|
+
if (this.visibleDayCount !== previous)
|
|
1021
|
+
this.#reconcile({ pinActive: true });
|
|
1022
|
+
}
|
|
1023
|
+
#contentWidth() {
|
|
1024
|
+
const content = this.querySelector(".sp-content");
|
|
1025
|
+
return content ? content.getBoundingClientRect().width : null;
|
|
1026
|
+
}
|
|
1027
|
+
#setActiveDate(date, emit = true) {
|
|
1028
|
+
const count = this.visibleDayCount;
|
|
1029
|
+
if (!count)
|
|
1030
|
+
return;
|
|
1031
|
+
const resolved = resolveActiveDate(this.start, count, date);
|
|
1032
|
+
const before = this.activeDate;
|
|
1033
|
+
if (resolved === before && this.getAttribute("active-date") === resolved)
|
|
1034
|
+
return;
|
|
1035
|
+
this.setAttribute("active-date", resolved);
|
|
1036
|
+
this.#lastActiveDate = resolved;
|
|
1037
|
+
if (emit && resolved !== before) {
|
|
1038
|
+
this.dispatchEvent(new CustomEvent("daychange", { detail: { activeDate: resolved } }));
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
async#load() {
|
|
1042
|
+
const request = ++this.#loadRequest;
|
|
1043
|
+
if (!this.source || !this.visibleDayCount) {
|
|
1044
|
+
this.#loading = false;
|
|
1045
|
+
this.#error = null;
|
|
1046
|
+
this.#queueRender();
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
const range = this.range;
|
|
1050
|
+
this.#loadedKey = this.#rangeKey(range);
|
|
1051
|
+
this.#loading = true;
|
|
1052
|
+
this.#error = null;
|
|
1053
|
+
this.#queueRender();
|
|
1054
|
+
this.dispatchEvent(new CustomEvent("loadstart", { detail: range }));
|
|
1055
|
+
try {
|
|
1056
|
+
const result = await this.#sourceController.load(range);
|
|
1057
|
+
if (request !== this.#loadRequest || result === null)
|
|
1058
|
+
return;
|
|
1059
|
+
const days = Array.isArray(result) ? result : result && typeof result === "object" && ("days" in result) ? result.days : [];
|
|
1060
|
+
this.#days = normalizeDays(days);
|
|
1061
|
+
if (request !== this.#loadRequest)
|
|
1062
|
+
return;
|
|
1063
|
+
this.dispatchEvent(new CustomEvent("loadend", { detail: { ...range, days: this.days } }));
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
if (request !== this.#loadRequest)
|
|
1066
|
+
return;
|
|
1067
|
+
if (error instanceof DOMException && error.name === "AbortError")
|
|
1068
|
+
return;
|
|
1069
|
+
this.#error = error;
|
|
1070
|
+
this.dispatchEvent(new CustomEvent("loaderror", { detail: { ...range, error } }));
|
|
1071
|
+
} finally {
|
|
1072
|
+
if (request === this.#loadRequest) {
|
|
1073
|
+
this.#loading = false;
|
|
1074
|
+
this.#queueRender();
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
#queueRender() {
|
|
1079
|
+
if (this.#renderQueued)
|
|
1080
|
+
return;
|
|
1081
|
+
this.#renderQueued = true;
|
|
1082
|
+
queueMicrotask(() => {
|
|
1083
|
+
this.#renderQueued = false;
|
|
1084
|
+
if (this.isConnected)
|
|
1085
|
+
this.#render();
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
#focusSelector(element) {
|
|
1089
|
+
if (element.classList.contains("sp-slot")) {
|
|
1090
|
+
const value = element.getAttribute("data-value") || "";
|
|
1091
|
+
return value ? `.sp-slot[data-value="${CSS.escape(value)}"]` : "";
|
|
1092
|
+
}
|
|
1093
|
+
if (element.classList.contains("sp-strip-day")) {
|
|
1094
|
+
const date = element.getAttribute("data-date") || "";
|
|
1095
|
+
return date ? `.sp-strip-day[data-date="${CSS.escape(date)}"]` : "";
|
|
1096
|
+
}
|
|
1097
|
+
if (element.classList.contains("sp-nav-prev"))
|
|
1098
|
+
return ".sp-nav-prev";
|
|
1099
|
+
if (element.classList.contains("sp-nav-next"))
|
|
1100
|
+
return ".sp-nav-next";
|
|
1101
|
+
if (element.classList.contains("sp-home"))
|
|
1102
|
+
return ".sp-home";
|
|
1103
|
+
if (element.classList.contains("sp-range-empty-availability"))
|
|
1104
|
+
return ".sp-range-empty-availability";
|
|
1105
|
+
if (element.classList.contains("sp-range-empty-next"))
|
|
1106
|
+
return ".sp-range-empty-next";
|
|
1107
|
+
if (element.classList.contains("sp-more-button"))
|
|
1108
|
+
return ".sp-more-button";
|
|
1109
|
+
if (element.classList.contains("sp-notice")) {
|
|
1110
|
+
const index = element.getAttribute("data-day-index") || "";
|
|
1111
|
+
return index ? `.sp-notice[data-day-index="${CSS.escape(index)}"]` : "";
|
|
1112
|
+
}
|
|
1113
|
+
return "";
|
|
1114
|
+
}
|
|
1115
|
+
#focusFallback() {
|
|
1116
|
+
return this.querySelector('.sp-slot[tabindex="0"]') ?? this.querySelector('.sp-strip-day[tabindex="0"]');
|
|
1117
|
+
}
|
|
1118
|
+
#projectedDays() {
|
|
1119
|
+
const count = this.visibleDayCount;
|
|
1120
|
+
if (!count)
|
|
1121
|
+
return [];
|
|
1122
|
+
const days = visibleDays(this.#days, this.start, count);
|
|
1123
|
+
if (this.closedDays !== "hide")
|
|
1124
|
+
return days;
|
|
1125
|
+
const active = this.activeDate;
|
|
1126
|
+
return days.filter((day) => !day.closed || this.layout === "day" && day.date === active);
|
|
1127
|
+
}
|
|
1128
|
+
#render() {
|
|
1129
|
+
const focused = document.activeElement;
|
|
1130
|
+
const fallbackSelector = focused instanceof Element && this.contains(focused) ? this.#focusSelector(focused) : "";
|
|
1131
|
+
const pendingSelector = this.#pendingFocus;
|
|
1132
|
+
this.#pendingFocus = "";
|
|
1133
|
+
const count = this.visibleDayCount;
|
|
1134
|
+
const invalid = count === 0;
|
|
1135
|
+
const civilDays = count ? visibleDays(this.#days, this.start, count) : [];
|
|
1136
|
+
const days = this.#projectedDays();
|
|
1137
|
+
const allClosed = civilDays.length > 0 && civilDays.every((day) => day.closed);
|
|
1138
|
+
const messages = resolveMessages(this.#messages);
|
|
1139
|
+
const locale = this.locale;
|
|
1140
|
+
const canPrevious = !invalid && (!this.min || compareDates(this.start, this.min) > 0);
|
|
1141
|
+
const canNext = !invalid && (!this.max || compareDates(rangeEnd(this.start, count), this.max) < 0);
|
|
1142
|
+
const empty = !this.#loading && !this.#error && !hasDayContent(days);
|
|
1143
|
+
let content;
|
|
1144
|
+
let hasOverflow = false;
|
|
1145
|
+
if (empty) {
|
|
1146
|
+
content = rangeEmpty({
|
|
1147
|
+
start: this.range.start,
|
|
1148
|
+
end: this.range.end,
|
|
1149
|
+
invalid,
|
|
1150
|
+
canNext,
|
|
1151
|
+
nextAvailability: this.hasAttribute("next-availability"),
|
|
1152
|
+
closed: allClosed,
|
|
1153
|
+
locale,
|
|
1154
|
+
messages
|
|
1155
|
+
});
|
|
1156
|
+
} else if (this.layout === "day") {
|
|
1157
|
+
content = renderDay({
|
|
1158
|
+
visible: days,
|
|
1159
|
+
activeDate: this.activeDate,
|
|
1160
|
+
today: todayValue(),
|
|
1161
|
+
value: this.value,
|
|
1162
|
+
focusedValue: this.#focusedValue,
|
|
1163
|
+
noticeDisplay: this.noticeDisplay,
|
|
1164
|
+
messages,
|
|
1165
|
+
locale
|
|
1166
|
+
}).html;
|
|
1167
|
+
} else {
|
|
1168
|
+
const rendered = renderColumns({
|
|
1169
|
+
visible: days,
|
|
1170
|
+
value: this.value,
|
|
1171
|
+
focusedValue: this.#focusedValue,
|
|
1172
|
+
maxVisibleRows: this.maxVisibleRows,
|
|
1173
|
+
expanded: this.expanded,
|
|
1174
|
+
noticeDisplay: this.noticeDisplay,
|
|
1175
|
+
messages,
|
|
1176
|
+
locale
|
|
1177
|
+
});
|
|
1178
|
+
content = rendered.html;
|
|
1179
|
+
hasOverflow = rendered.hasOverflow;
|
|
1180
|
+
}
|
|
1181
|
+
const status = this.#loading ? `<div class="sp-status sp-visually-hidden" role="status">${escapeHtml(messages.loading)}</div>` : this.#error ? `<div class="sp-status sp-status-error" role="status">${escapeHtml(String(this.#error))}</div>` : "";
|
|
1182
|
+
const homeAvailable = this.hasAttribute("home-date") && !invalid;
|
|
1183
|
+
const homeInRange = homeAvailable && compareDates(this.homeDate, this.range.start) >= 0 && compareDates(this.homeDate, this.range.end) <= 0;
|
|
1184
|
+
this.style.setProperty("--_sp-day-count", String(Math.max(1, days.length)));
|
|
1185
|
+
this.style.setProperty("--_sp-max-visible-rows", String(this.maxVisibleRows));
|
|
1186
|
+
if (this.#loading)
|
|
1187
|
+
this.setAttribute("aria-busy", "true");
|
|
1188
|
+
else
|
|
1189
|
+
this.removeAttribute("aria-busy");
|
|
1190
|
+
this.innerHTML = `<div class="sp-shell" data-layout="${this.layout}">
|
|
1191
|
+
<div class="sp-projection">
|
|
1192
|
+
<button type="button" class="sp-nav sp-nav-prev" aria-label="${escapeAttr(messages.previous)}"${canPrevious ? "" : " disabled"}>${PREV_ICON}</button>
|
|
1193
|
+
<div class="sp-content">
|
|
1194
|
+
${status}
|
|
1195
|
+
${content}
|
|
1196
|
+
${hasOverflow ? `<div class="sp-more"><button type="button" class="sp-more-button">${escapeHtml(this.expanded ? messages.showLess : messages.showMore)}</button></div>` : ""}
|
|
1197
|
+
</div>
|
|
1198
|
+
<button type="button" class="sp-nav sp-nav-next" aria-label="${escapeAttr(messages.next)}"${canNext ? "" : " disabled"}>${NEXT_ICON}</button>
|
|
1199
|
+
</div>
|
|
1200
|
+
${homeAvailable ? `<div class="sp-shortcuts">${homeInRange ? "" : `<button type="button" class="sp-home">${escapeHtml(messages.home)}</button>`}</div>` : ""}
|
|
1201
|
+
</div>`;
|
|
1202
|
+
const selector = pendingSelector || fallbackSelector;
|
|
1203
|
+
if (selector) {
|
|
1204
|
+
const target = this.querySelector(selector) ?? this.#focusFallback();
|
|
1205
|
+
if (target instanceof HTMLElement)
|
|
1206
|
+
target.focus({ preventScroll: true });
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
#onClick(event) {
|
|
1210
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
1211
|
+
if (!target)
|
|
1212
|
+
return;
|
|
1213
|
+
if (target.closest(".sp-nav-prev"))
|
|
1214
|
+
return this.previous();
|
|
1215
|
+
if (target.closest(".sp-nav-next"))
|
|
1216
|
+
return this.next();
|
|
1217
|
+
if (target.closest(".sp-home")) {
|
|
1218
|
+
this.#pendingFocus = '.sp-slot[tabindex="0"]';
|
|
1219
|
+
return this.goHome();
|
|
1220
|
+
}
|
|
1221
|
+
if (target.closest(".sp-nav-availability")) {
|
|
1222
|
+
this.goToNextAvailability();
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
if (target.closest(".sp-range-empty-availability")) {
|
|
1226
|
+
this.goToNextAvailability();
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (target.closest(".sp-range-empty-next"))
|
|
1230
|
+
return this.next();
|
|
1231
|
+
const more = target.closest(".sp-more-button");
|
|
1232
|
+
if (more) {
|
|
1233
|
+
this.expanded = !this.expanded;
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
const stripDay = target.closest(".sp-strip-day");
|
|
1237
|
+
if (stripDay instanceof HTMLButtonElement) {
|
|
1238
|
+
const date = stripDay.getAttribute("data-date") || "";
|
|
1239
|
+
if (isDateValue(date)) {
|
|
1240
|
+
this.#pendingFocus = `.sp-strip-day[data-date="${CSS.escape(date)}"]`;
|
|
1241
|
+
this.#setActiveDate(date);
|
|
1242
|
+
}
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
const notice = target.closest(".sp-notice[data-day-index]");
|
|
1246
|
+
if (notice instanceof HTMLElement) {
|
|
1247
|
+
const dayIndex = Number(notice.getAttribute("data-day-index"));
|
|
1248
|
+
const day = this.#projectedDays()[dayIndex];
|
|
1249
|
+
if (day?.notice) {
|
|
1250
|
+
this.dispatchEvent(new CustomEvent("noticeactivate", {
|
|
1251
|
+
detail: { day, notice: day.notice, anchor: notice },
|
|
1252
|
+
bubbles: true
|
|
1253
|
+
}));
|
|
1254
|
+
}
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const slotButton = target.closest(".sp-slot");
|
|
1258
|
+
if (slotButton instanceof HTMLButtonElement)
|
|
1259
|
+
this.#activateSlot(slotButton);
|
|
1260
|
+
}
|
|
1261
|
+
#onKeyDown(event) {
|
|
1262
|
+
if (!(event.target instanceof HTMLButtonElement))
|
|
1263
|
+
return;
|
|
1264
|
+
const button = event.target;
|
|
1265
|
+
if (button.classList.contains("sp-strip-day"))
|
|
1266
|
+
return this.#onStripKeyDown(event);
|
|
1267
|
+
if (!button.classList.contains("sp-slot"))
|
|
1268
|
+
return;
|
|
1269
|
+
const map = {
|
|
1270
|
+
ArrowUp: "up",
|
|
1271
|
+
ArrowDown: "down",
|
|
1272
|
+
ArrowLeft: "left",
|
|
1273
|
+
ArrowRight: "right",
|
|
1274
|
+
Home: "home",
|
|
1275
|
+
End: "end"
|
|
1276
|
+
};
|
|
1277
|
+
const direction = map[event.key];
|
|
1278
|
+
if (!direction)
|
|
1279
|
+
return;
|
|
1280
|
+
event.preventDefault();
|
|
1281
|
+
const allDays = this.#projectedDays();
|
|
1282
|
+
if (this.layout === "day") {
|
|
1283
|
+
this.#onPanelKeyDown(event, allDays, direction);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
const { days } = collapsedDays(allDays, this.maxVisibleRows, this.expanded);
|
|
1287
|
+
const current = {
|
|
1288
|
+
dayIndex: Number(button.dataset.dayIndex),
|
|
1289
|
+
slotIndex: Number(button.dataset.slotIndex)
|
|
1290
|
+
};
|
|
1291
|
+
const next = moveFocus(days, current, direction);
|
|
1292
|
+
const nextDay = days[next.dayIndex];
|
|
1293
|
+
const nextSlot = nextDay?.slots[next.slotIndex];
|
|
1294
|
+
if (!nextSlot || nextSlot.disabled)
|
|
1295
|
+
return;
|
|
1296
|
+
const value = slotValue(nextDay.date, nextSlot.start);
|
|
1297
|
+
this.#focusedValue = value;
|
|
1298
|
+
this.#pendingFocus = `.sp-slot[data-value="${CSS.escape(value)}"]`;
|
|
1299
|
+
this.#render();
|
|
1300
|
+
}
|
|
1301
|
+
#onStripKeyDown(event) {
|
|
1302
|
+
const map = { ArrowLeft: -1, ArrowRight: 1, Home: "home", End: "end" };
|
|
1303
|
+
const action = map[event.key];
|
|
1304
|
+
if (action === undefined)
|
|
1305
|
+
return;
|
|
1306
|
+
event.preventDefault();
|
|
1307
|
+
const days = this.#projectedDays();
|
|
1308
|
+
const currentIndex = Math.max(0, days.findIndex((day) => day.date === this.activeDate));
|
|
1309
|
+
let nextIndex = currentIndex;
|
|
1310
|
+
if (action === "home")
|
|
1311
|
+
nextIndex = 0;
|
|
1312
|
+
else if (action === "end")
|
|
1313
|
+
nextIndex = days.length - 1;
|
|
1314
|
+
else if (typeof action === "number")
|
|
1315
|
+
nextIndex = Math.max(0, Math.min(days.length - 1, currentIndex + action));
|
|
1316
|
+
const next = days[nextIndex];
|
|
1317
|
+
if (!next || next.date === this.activeDate) {
|
|
1318
|
+
const same = this.querySelector(`.sp-strip-day[data-date="${CSS.escape(this.activeDate)}"]`);
|
|
1319
|
+
if (same instanceof HTMLButtonElement)
|
|
1320
|
+
same.focus();
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
this.#pendingFocus = `.sp-strip-day[data-date="${CSS.escape(next.date)}"]`;
|
|
1324
|
+
this.#setActiveDate(next.date);
|
|
1325
|
+
}
|
|
1326
|
+
#onPanelKeyDown(event, days, direction) {
|
|
1327
|
+
if (!(event.target instanceof HTMLButtonElement))
|
|
1328
|
+
return;
|
|
1329
|
+
const button = event.target;
|
|
1330
|
+
const activeIndex = Math.max(0, days.findIndex((day) => day.date === this.activeDate));
|
|
1331
|
+
const active = days[activeIndex];
|
|
1332
|
+
if (!active)
|
|
1333
|
+
return;
|
|
1334
|
+
const enabled = active.slots.map((slot, slotIndex) => ({ slot, slotIndex })).filter(({ slot }) => !slot.disabled);
|
|
1335
|
+
if (!enabled.length)
|
|
1336
|
+
return;
|
|
1337
|
+
const currentSlotIndex = Number(button.dataset.slotIndex);
|
|
1338
|
+
let position = enabled.findIndex(({ slotIndex }) => slotIndex === currentSlotIndex);
|
|
1339
|
+
if (position < 0) {
|
|
1340
|
+
const currentValue = button.getAttribute("data-value") || "";
|
|
1341
|
+
position = Math.max(0, enabled.findIndex(({ slot }) => slotValue(active.date, slot.start) === (this.#focusedValue || this.value || currentValue)));
|
|
1342
|
+
}
|
|
1343
|
+
let nextPosition = position;
|
|
1344
|
+
if (direction === "home")
|
|
1345
|
+
nextPosition = 0;
|
|
1346
|
+
else if (direction === "end")
|
|
1347
|
+
nextPosition = enabled.length - 1;
|
|
1348
|
+
else if (direction === "left" || direction === "up")
|
|
1349
|
+
nextPosition = Math.max(0, position - 1);
|
|
1350
|
+
else
|
|
1351
|
+
nextPosition = Math.min(enabled.length - 1, position + 1);
|
|
1352
|
+
const next = enabled[nextPosition];
|
|
1353
|
+
if (!next)
|
|
1354
|
+
return;
|
|
1355
|
+
const value = slotValue(active.date, next.slot.start);
|
|
1356
|
+
this.#focusedValue = value;
|
|
1357
|
+
this.#pendingFocus = `.sp-slot[data-value="${CSS.escape(value)}"]`;
|
|
1358
|
+
this.#render();
|
|
1359
|
+
}
|
|
1360
|
+
#activateSlot(button) {
|
|
1361
|
+
if (button.disabled)
|
|
1362
|
+
return;
|
|
1363
|
+
const dayIndex = Number(button.dataset.dayIndex);
|
|
1364
|
+
const slotIndex = Number(button.dataset.slotIndex);
|
|
1365
|
+
const day = this.#projectedDays()[dayIndex];
|
|
1366
|
+
const slot = day?.slots[slotIndex];
|
|
1367
|
+
if (!day || !slot || slot.disabled)
|
|
1368
|
+
return;
|
|
1369
|
+
const value = slotValue(day.date, slot.start);
|
|
1370
|
+
this.#pendingFocus = `.sp-slot[data-value="${CSS.escape(value)}"]`;
|
|
1371
|
+
this.#setActiveDate(day.date);
|
|
1372
|
+
this.value = value;
|
|
1373
|
+
this.#focusedValue = value;
|
|
1374
|
+
this.dispatchEvent(new CustomEvent("slotactivate", { detail: { value, day, slot }, bubbles: true }));
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// src/define.js
|
|
1379
|
+
if (!customElements.get("slot-picker")) {
|
|
1380
|
+
customElements.define("slot-picker", SlotPickerElement);
|
|
1381
|
+
}
|
|
1382
|
+
})();
|