@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
package/src/source.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** @typedef {{start:string,end:string,dayCount:number,signal:AbortSignal}} SourceContext */
|
|
2
|
+
/** @typedef {{after:string,signal:AbortSignal}} NextContext */
|
|
3
|
+
/** @typedef {(context:SourceContext) => Promise<unknown>|unknown} SlotSourceFunction */
|
|
4
|
+
/** @typedef {{load:(context:SourceContext) => Promise<unknown>|unknown, next?:(context:NextContext) => Promise<string|null|undefined>|string|null|undefined}} SlotSourceObject */
|
|
5
|
+
/** @typedef {SlotSourceFunction|SlotSourceObject} SlotSource */
|
|
6
|
+
|
|
7
|
+
export class SlotSourceController {
|
|
8
|
+
/** @type {SlotSource|null} */
|
|
9
|
+
#source = null;
|
|
10
|
+
/** @type {AbortController|null} */
|
|
11
|
+
#controller = null;
|
|
12
|
+
/** @type {AbortController|null} */
|
|
13
|
+
#nextController = null;
|
|
14
|
+
#request = 0;
|
|
15
|
+
#nextRequest = 0;
|
|
16
|
+
|
|
17
|
+
/** @param {SlotSource|null} source */
|
|
18
|
+
set source(source) {
|
|
19
|
+
const isFunction = typeof source === "function";
|
|
20
|
+
const isObject = source !== null && typeof source === "object" && typeof source.load === "function";
|
|
21
|
+
if (source !== null && !isFunction && !isObject) {
|
|
22
|
+
throw new TypeError("source must be a function or an object with a load() method");
|
|
23
|
+
}
|
|
24
|
+
this.abort();
|
|
25
|
+
this.#source = source;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get source() {
|
|
29
|
+
return this.#source;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether the current source can resolve a date beyond the visible range. */
|
|
33
|
+
get hasNext() {
|
|
34
|
+
const source = this.#source;
|
|
35
|
+
return Boolean(source && typeof source === "object" && typeof source.next === "function");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
abort() {
|
|
39
|
+
this.#controller?.abort();
|
|
40
|
+
this.#controller = null;
|
|
41
|
+
this.#nextController?.abort();
|
|
42
|
+
this.#nextController = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {{start:string,end:string,dayCount:number}} range */
|
|
46
|
+
async load(range) {
|
|
47
|
+
const source = this.#source;
|
|
48
|
+
if (!source) return null;
|
|
49
|
+
const loader = typeof source === "function" ? source : source.load;
|
|
50
|
+
|
|
51
|
+
this.abort();
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
this.#controller = controller;
|
|
54
|
+
const request = ++this.#request;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const result = await loader({ ...range, signal: controller.signal });
|
|
58
|
+
if (request !== this.#request || controller.signal.aborted) return null;
|
|
59
|
+
return result;
|
|
60
|
+
} finally {
|
|
61
|
+
if (this.#controller === controller) this.#controller = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Ask the source for the next known availability after `after`.
|
|
67
|
+
* Superseded calls are aborted; a stale response resolves to null.
|
|
68
|
+
* @param {{after:string}} context
|
|
69
|
+
*/
|
|
70
|
+
async next({ after }) {
|
|
71
|
+
const source = this.#source;
|
|
72
|
+
if (!source || typeof source !== "object" || typeof source.next !== "function") return null;
|
|
73
|
+
|
|
74
|
+
this.#nextController?.abort();
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
this.#nextController = controller;
|
|
77
|
+
const request = ++this.#nextRequest;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const result = await source.next({ after, signal: controller.signal });
|
|
81
|
+
if (request !== this.#nextRequest || controller.signal.aborted) return null;
|
|
82
|
+
return typeof result === "string" ? result : null;
|
|
83
|
+
} finally {
|
|
84
|
+
if (this.#nextController === controller) this.#nextController = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { toUtcDate } from "../date.js";
|
|
2
|
+
import { collapsedDays, slotValue } from "../model.js";
|
|
3
|
+
import {
|
|
4
|
+
dayEmpty,
|
|
5
|
+
enabledValues,
|
|
6
|
+
escapeAttr,
|
|
7
|
+
escapeHtml,
|
|
8
|
+
noticeBlock,
|
|
9
|
+
noticeIndicator,
|
|
10
|
+
rovingValue,
|
|
11
|
+
slotButton,
|
|
12
|
+
} from "./shared.js";
|
|
13
|
+
|
|
14
|
+
const UTC_FORMAT = { timeZone: "UTC" };
|
|
15
|
+
|
|
16
|
+
/** @typedef {import("../model.js").SlotDay} SlotDay */
|
|
17
|
+
/** @typedef {import("./shared.js").SlotCountMessages & {empty:string,closed:string}} ColumnsMessages */
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Columns projection: N civil days side by side with their slots.
|
|
21
|
+
* Empty days render a real DOM empty state (messages.empty); alignment
|
|
22
|
+
* placeholders are aria-hidden and only used when the day has slots
|
|
23
|
+
* but fewer rows than the visible grid.
|
|
24
|
+
* @param {{visible:SlotDay[],value:string,focusedValue:string,maxVisibleRows:number,expanded:boolean,noticeDisplay:"inline"|"action"|"none",messages:ColumnsMessages,locale:string}} options
|
|
25
|
+
*/
|
|
26
|
+
export function renderColumns({
|
|
27
|
+
visible,
|
|
28
|
+
value,
|
|
29
|
+
focusedValue,
|
|
30
|
+
maxVisibleRows,
|
|
31
|
+
expanded,
|
|
32
|
+
noticeDisplay,
|
|
33
|
+
messages,
|
|
34
|
+
locale,
|
|
35
|
+
}) {
|
|
36
|
+
const formatterDay = new Intl.DateTimeFormat(locale, { weekday: "short", ...UTC_FORMAT });
|
|
37
|
+
const formatterDate = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", ...UTC_FORMAT });
|
|
38
|
+
// Collapse before computing roving focus: the single tabbable value must be
|
|
39
|
+
// one of the rendered buttons, never a slot hidden by the visible row limit.
|
|
40
|
+
const { days: collapsed, rows, hasOverflow } = collapsedDays(visible, maxVisibleRows, expanded);
|
|
41
|
+
|
|
42
|
+
const enabled = enabledValues(collapsed);
|
|
43
|
+
const firstEnabled = enabled[0] || "";
|
|
44
|
+
const activeFocus = rovingValue(enabled, focusedValue, value, firstEnabled);
|
|
45
|
+
|
|
46
|
+
const html = collapsed
|
|
47
|
+
.map(
|
|
48
|
+
/** @param {SlotDay} day @param {number} dayIndex */ (day, dayIndex) => {
|
|
49
|
+
const date = toUtcDate(day.date);
|
|
50
|
+
const weekday = formatterDay.format(date);
|
|
51
|
+
const displayDate = formatterDate.format(date);
|
|
52
|
+
const inline = noticeDisplay === "inline";
|
|
53
|
+
const action = noticeDisplay === "action";
|
|
54
|
+
// The dot is the only control (when the notice is projected at all).
|
|
55
|
+
// `inline` additionally renders the readable block, which never
|
|
56
|
+
// activates anything.
|
|
57
|
+
const headerNotice =
|
|
58
|
+
day.notice && (inline || action)
|
|
59
|
+
? noticeIndicator({ interactive: true, dayIndex, ...day.notice })
|
|
60
|
+
: "";
|
|
61
|
+
const noticeContent = day.notice && inline ? noticeBlock(day.notice) : "";
|
|
62
|
+
const closed = Boolean(day.closed);
|
|
63
|
+
|
|
64
|
+
let body;
|
|
65
|
+
if (day.slots.length === 0) {
|
|
66
|
+
// "Closed" is a day state, distinct from "open but no availability".
|
|
67
|
+
body = `${noticeContent}${dayEmpty(closed ? messages.closed : messages.empty)}`;
|
|
68
|
+
} else {
|
|
69
|
+
const slots = day.slots;
|
|
70
|
+
const slotMarkup = slots
|
|
71
|
+
.map(
|
|
72
|
+
/** @param {import("../model.js").Slot} slot @param {number} slotIndex */ (slot, slotIndex) => {
|
|
73
|
+
const slotVal = slotValue(day.date, slot.start);
|
|
74
|
+
return slotButton({
|
|
75
|
+
date: day.date,
|
|
76
|
+
slot,
|
|
77
|
+
dayIndex,
|
|
78
|
+
slotIndex,
|
|
79
|
+
selected: slotVal === value,
|
|
80
|
+
tabbed: slotVal === activeFocus && !slot.disabled,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
.join("");
|
|
85
|
+
const empties = Array.from(
|
|
86
|
+
{ length: Math.max(0, rows - slots.length) },
|
|
87
|
+
() => '<span class="sp-empty" aria-hidden="true">–</span>',
|
|
88
|
+
).join("");
|
|
89
|
+
body = `${noticeContent}<div class="sp-slots" role="listbox" aria-label="${escapeAttr(`${weekday} ${displayDate}`)}">${slotMarkup}${empties}</div>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return `<section class="sp-day" data-date="${escapeAttr(day.date)}"${closed ? " data-closed" : ""}>
|
|
93
|
+
<header class="sp-day-header">
|
|
94
|
+
<span class="sp-weekday">${escapeHtml(weekday)}</span>
|
|
95
|
+
<strong class="sp-date">${escapeHtml(displayDate)}</strong>
|
|
96
|
+
${headerNotice}
|
|
97
|
+
</header>
|
|
98
|
+
${body}
|
|
99
|
+
</section>`;
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
.join("");
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
html: `<div class="sp-grid" role="presentation">${html}</div>`,
|
|
106
|
+
hasOverflow,
|
|
107
|
+
};
|
|
108
|
+
}
|
package/src/views/day.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { toUtcDate } from "../date.js";
|
|
2
|
+
import { slotValue } from "../model.js";
|
|
3
|
+
import {
|
|
4
|
+
dayEmpty,
|
|
5
|
+
escapeAttr,
|
|
6
|
+
escapeHtml,
|
|
7
|
+
noticeBlock,
|
|
8
|
+
noticeIndicator,
|
|
9
|
+
rovingValue,
|
|
10
|
+
slotButton,
|
|
11
|
+
slotCountText,
|
|
12
|
+
} from "./shared.js";
|
|
13
|
+
|
|
14
|
+
const UTC_FORMAT = { timeZone: "UTC" };
|
|
15
|
+
|
|
16
|
+
/** @typedef {import("../model.js").SlotDay} SlotDay */
|
|
17
|
+
/** @typedef {import("./shared.js").SlotCountMessages & {empty:string,closed:string,days:string}} DayMessages */
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Day projection: a strip of days plus the slots of the consulted day.
|
|
21
|
+
* The strip uses aria-pressed (consulted day), never aria-current="date".
|
|
22
|
+
* Today stays independently discoverable via data-today.
|
|
23
|
+
* @param {{visible:SlotDay[],activeDate:string,today:string,value:string,focusedValue:string,noticeDisplay:"inline"|"action"|"none",messages:DayMessages,locale:string}} options
|
|
24
|
+
*/
|
|
25
|
+
export function renderDay({
|
|
26
|
+
visible,
|
|
27
|
+
activeDate,
|
|
28
|
+
today,
|
|
29
|
+
value,
|
|
30
|
+
focusedValue,
|
|
31
|
+
noticeDisplay,
|
|
32
|
+
messages,
|
|
33
|
+
locale,
|
|
34
|
+
}) {
|
|
35
|
+
const formatterStripDay = new Intl.DateTimeFormat(locale, { weekday: "short", ...UTC_FORMAT });
|
|
36
|
+
const formatterStripDate = new Intl.DateTimeFormat(locale, {
|
|
37
|
+
day: "numeric",
|
|
38
|
+
month: "short",
|
|
39
|
+
...UTC_FORMAT,
|
|
40
|
+
});
|
|
41
|
+
const formatterPanelDay = new Intl.DateTimeFormat(locale, {
|
|
42
|
+
weekday: "long",
|
|
43
|
+
day: "numeric",
|
|
44
|
+
month: "long",
|
|
45
|
+
...UTC_FORMAT,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const activeIndex = Math.max(
|
|
49
|
+
0,
|
|
50
|
+
visible.findIndex((day) => day.date === activeDate),
|
|
51
|
+
);
|
|
52
|
+
const active = visible[activeIndex];
|
|
53
|
+
|
|
54
|
+
const strip = visible
|
|
55
|
+
.map(
|
|
56
|
+
/** @param {SlotDay} day @param {number} dayIndex */ (day, dayIndex) => {
|
|
57
|
+
const date = toUtcDate(day.date);
|
|
58
|
+
const pressed = day.date === active.date;
|
|
59
|
+
const isToday = day.date === today;
|
|
60
|
+
const closed = Boolean(day.closed);
|
|
61
|
+
const count = closed ? messages.closed : slotCountText(day.slots.length, messages);
|
|
62
|
+
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}">
|
|
63
|
+
<span class="sp-strip-weekday">${escapeHtml(formatterStripDay.format(date))}</span>
|
|
64
|
+
<strong class="sp-strip-date">${escapeHtml(formatterStripDate.format(date))}</strong>
|
|
65
|
+
<span class="sp-strip-count" aria-hidden="true">${escapeHtml(count)}</span>
|
|
66
|
+
<span class="sp-visually-hidden">${escapeHtml(`${formatterPanelDay.format(date)}: ${count}`)}</span>
|
|
67
|
+
</button>`;
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
.join("");
|
|
71
|
+
|
|
72
|
+
const enabled = active.slots
|
|
73
|
+
.filter(/** @param {import("../model.js").Slot} slot */ (slot) => !slot.disabled)
|
|
74
|
+
.map(/** @param {import("../model.js").Slot} slot */ (slot) => slotValue(active.date, slot.start));
|
|
75
|
+
const firstEnabled = enabled[0] || "";
|
|
76
|
+
const activeFocus = rovingValue(enabled, focusedValue, value, firstEnabled);
|
|
77
|
+
|
|
78
|
+
// `inline` reveals the full notice here (non-interactive text); `action`
|
|
79
|
+
// keeps the single control in the panel header; `none` renders nothing.
|
|
80
|
+
const inline = noticeDisplay === "inline";
|
|
81
|
+
const notice = active.notice && inline ? noticeBlock(active.notice) : "";
|
|
82
|
+
const headerNotice =
|
|
83
|
+
active.notice && noticeDisplay === "action"
|
|
84
|
+
? noticeIndicator({ interactive: true, dayIndex: activeIndex, ...active.notice })
|
|
85
|
+
: "";
|
|
86
|
+
const panelLabel = formatterPanelDay.format(toUtcDate(active.date));
|
|
87
|
+
|
|
88
|
+
const activeClosed = Boolean(active.closed);
|
|
89
|
+
|
|
90
|
+
let panelBody;
|
|
91
|
+
if (active.slots.length === 0) {
|
|
92
|
+
panelBody = `${notice}${dayEmpty(activeClosed ? messages.closed : messages.empty)}`;
|
|
93
|
+
} else {
|
|
94
|
+
const slots = active.slots
|
|
95
|
+
.map(
|
|
96
|
+
/** @param {import("../model.js").Slot} slot @param {number} slotIndex */ (slot, slotIndex) => {
|
|
97
|
+
const slotVal = slotValue(active.date, slot.start);
|
|
98
|
+
return slotButton({
|
|
99
|
+
date: active.date,
|
|
100
|
+
slot,
|
|
101
|
+
dayIndex: activeIndex,
|
|
102
|
+
slotIndex,
|
|
103
|
+
selected: slotVal === value,
|
|
104
|
+
tabbed: slotVal === activeFocus && !slot.disabled,
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
.join("");
|
|
109
|
+
panelBody = `${notice}<div class="sp-panel-slots" role="listbox" aria-label="${escapeAttr(panelLabel)}">${slots}</div>`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const html = `<div class="sp-daystrip" role="group" aria-label="${escapeAttr(messages.days)}">${strip}</div>
|
|
113
|
+
<section class="sp-panel" data-date="${escapeAttr(active.date)}"${activeClosed ? " data-closed" : ""}>
|
|
114
|
+
<header class="sp-panel-header">
|
|
115
|
+
<strong class="sp-panel-title">${escapeHtml(panelLabel)}</strong>
|
|
116
|
+
${headerNotice}
|
|
117
|
+
</header>
|
|
118
|
+
${panelBody}
|
|
119
|
+
</section>`;
|
|
120
|
+
|
|
121
|
+
return { html };
|
|
122
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { toUtcDate } from "../date.js";
|
|
2
|
+
import { slotValue } from "../model.js";
|
|
3
|
+
|
|
4
|
+
/** @typedef {import("../model.js").Slot} Slot */
|
|
5
|
+
/** @typedef {{oneSlot:string,manySlots:string}} SlotCountMessages */
|
|
6
|
+
/** @typedef {{rangeEmptyTitle:string,rangeEmptyDescription:string,rangeEmptyNext:string,nextAvailability:string,closedRange:string}} RangeEmptyMessages */
|
|
7
|
+
|
|
8
|
+
/** @type {Record<string,string>} */
|
|
9
|
+
const ENTITIES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
10
|
+
|
|
11
|
+
/** @param {string} value */
|
|
12
|
+
export function escapeHtml(value) {
|
|
13
|
+
return String(value).replace(/[&<>"']/g, /** @param {string} char */ (char) => ENTITIES[char] || char);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @param {string} value */
|
|
17
|
+
export function escapeAttr(value) {
|
|
18
|
+
return escapeHtml(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ordered values of enabled slots only. Disabled slots stay discoverable
|
|
23
|
+
* in the DOM but never take the roving tabindex.
|
|
24
|
+
* @param {{date:string,slots:Slot[]}[]} days
|
|
25
|
+
*/
|
|
26
|
+
export function enabledValues(days) {
|
|
27
|
+
/** @type {string[]} */
|
|
28
|
+
const values = [];
|
|
29
|
+
for (const day of days) {
|
|
30
|
+
for (const slot of day.slots) {
|
|
31
|
+
if (!slot.disabled) values.push(slotValue(day.date, slot.start));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return values;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pick the single roving value: first preferred value that is enabled,
|
|
39
|
+
* otherwise the first enabled value, otherwise "".
|
|
40
|
+
* @param {string[]} enabled
|
|
41
|
+
* @param {...string} preferred
|
|
42
|
+
*/
|
|
43
|
+
export function rovingValue(enabled, ...preferred) {
|
|
44
|
+
for (const value of preferred) {
|
|
45
|
+
if (value && enabled.includes(value)) return value;
|
|
46
|
+
}
|
|
47
|
+
return enabled[0] || "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Single slot button. Semantics must stay identical across projections.
|
|
52
|
+
* @param {{date:string,slot:Slot,dayIndex:number,slotIndex:number,selected:boolean,tabbed:boolean}} options
|
|
53
|
+
*/
|
|
54
|
+
export function slotButton({ date, slot, dayIndex, slotIndex, selected, tabbed }) {
|
|
55
|
+
const value = slotValue(date, slot.start);
|
|
56
|
+
const description = slot.description ? ` aria-description="${escapeAttr(slot.description)}"` : "";
|
|
57
|
+
// `option` backs the `aria-selected` contract: browsers ignore selection on
|
|
58
|
+
// a plain button role. The element stays a real <button> with native
|
|
59
|
+
// activation; `aria-disabled` keeps a disabled slot discoverable.
|
|
60
|
+
const disabled = slot.disabled ? ' disabled aria-disabled="true"' : "";
|
|
61
|
+
// Neutral presentation hook: a theme maps tones to color, the core does not.
|
|
62
|
+
const tone = slot.tone ? ` data-tone="${escapeAttr(slot.tone)}"` : "";
|
|
63
|
+
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>`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Per-day notice indicator. Without options it is purely decorative. With
|
|
68
|
+
* `interactive: true` it is the only control that emits `noticeactivate`:
|
|
69
|
+
* the accessible name carries the description, the native `title` is a
|
|
70
|
+
* desktop-only bonus.
|
|
71
|
+
* @param {{interactive?:boolean,dayIndex?:number,label?:string,description?:string}} [options]
|
|
72
|
+
*/
|
|
73
|
+
export function noticeIndicator(options = {}) {
|
|
74
|
+
if (options.interactive) {
|
|
75
|
+
const accessible = options.description || options.label || "";
|
|
76
|
+
const title = options.label ? ` title="${escapeAttr(options.label)}"` : "";
|
|
77
|
+
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>`;
|
|
78
|
+
}
|
|
79
|
+
return '<span class="sp-notice" aria-hidden="true"><span class="sp-notice-dot"></span></span>';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Readable day notice. Deliberately non-interactive: `noticeactivate` belongs
|
|
84
|
+
* to the explicit control, never to displayed text, so there is no mouse-only
|
|
85
|
+
* activation.
|
|
86
|
+
* @param {{label:string,description?:string}} notice
|
|
87
|
+
*/
|
|
88
|
+
export function noticeBlock({ label, description }) {
|
|
89
|
+
const body = `<strong class="sp-day-notice-label">${escapeHtml(label)}</strong>${
|
|
90
|
+
description ? `<span class="sp-day-notice-description">${escapeHtml(description)}</span>` : ""
|
|
91
|
+
}`;
|
|
92
|
+
return `<div class="sp-day-notice">${body}</div>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Real DOM empty state. Text comes from messages, never from CSS.
|
|
97
|
+
* @param {string} message
|
|
98
|
+
*/
|
|
99
|
+
export function dayEmpty(message) {
|
|
100
|
+
return `<div class="sp-day-empty">${escapeHtml(message)}</div>`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Localized slot count for the day strip. Zero renders as an en dash,
|
|
105
|
+
* keeping the indicator distinct from a real sentence.
|
|
106
|
+
* @param {number} count
|
|
107
|
+
* @param {SlotCountMessages} messages
|
|
108
|
+
*/
|
|
109
|
+
export function slotCountText(count, messages) {
|
|
110
|
+
if (count <= 0) return "–";
|
|
111
|
+
if (count === 1) return messages.oneSlot;
|
|
112
|
+
return messages.manySlots.replace("{n}", String(count));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Range-level empty state, distinct from per-day empty days.
|
|
117
|
+
* Rendered only when the whole window has no slot and no notice; the theme
|
|
118
|
+
* decides how prominent it is. Two actions stay distinct: `next()` moves to
|
|
119
|
+
* the immediately following window, while `next availability` (opt-in via
|
|
120
|
+
* `next-availability`) is the contextual business search that may skip
|
|
121
|
+
* several windows through the source. They are never merged.
|
|
122
|
+
* @param {{start:string,end:string,invalid:boolean,canNext:boolean,nextAvailability:boolean,closed:boolean,locale:string,messages:RangeEmptyMessages}} options
|
|
123
|
+
*/
|
|
124
|
+
export function rangeEmpty({ start, end, invalid, canNext, nextAvailability, closed, locale, messages }) {
|
|
125
|
+
const showDescription = !invalid && Boolean(start);
|
|
126
|
+
let description = "";
|
|
127
|
+
if (showDescription) {
|
|
128
|
+
const formatter = new Intl.DateTimeFormat(locale, {
|
|
129
|
+
day: "numeric",
|
|
130
|
+
month: "short",
|
|
131
|
+
timeZone: "UTC",
|
|
132
|
+
});
|
|
133
|
+
const text = messages.rangeEmptyDescription
|
|
134
|
+
.replace("{start}", formatter.format(toUtcDate(start)))
|
|
135
|
+
.replace("{end}", formatter.format(toUtcDate(end)));
|
|
136
|
+
description = `<p class="sp-range-empty-description">${escapeHtml(text)}</p>`;
|
|
137
|
+
}
|
|
138
|
+
const actions = [];
|
|
139
|
+
if (canNext) {
|
|
140
|
+
actions.push(
|
|
141
|
+
`<button type="button" class="sp-range-empty-next">${escapeHtml(messages.rangeEmptyNext)}</button>`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (nextAvailability && !invalid) {
|
|
145
|
+
actions.push(
|
|
146
|
+
`<button type="button" class="sp-range-empty-availability">${escapeHtml(messages.nextAvailability)}</button>`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const action = actions.length ? `<div class="sp-range-empty-actions">${actions.join("")}</div>` : "";
|
|
150
|
+
// A wholly closed civil range is not the same statement as "no availability".
|
|
151
|
+
const title = closed ? messages.closedRange : messages.rangeEmptyTitle;
|
|
152
|
+
return `<div class="sp-range-empty">
|
|
153
|
+
<strong class="sp-range-empty-title">${escapeHtml(title)}</strong>
|
|
154
|
+
${description}
|
|
155
|
+
${action}
|
|
156
|
+
</div>`;
|
|
157
|
+
}
|