@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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +370 -0
  3. package/custom-elements.json +53 -0
  4. package/dist/slot-picker.css +597 -0
  5. package/dist/slot-picker.js +1382 -0
  6. package/dist/slot-picker.min.css +1 -0
  7. package/dist/slot-picker.min.js +36 -0
  8. package/dist/types/date.d.ts +23 -0
  9. package/dist/types/date.d.ts.map +1 -0
  10. package/dist/types/define.d.ts +2 -0
  11. package/dist/types/define.d.ts.map +1 -0
  12. package/dist/types/index.d.ts +6 -0
  13. package/dist/types/index.d.ts.map +1 -0
  14. package/dist/types/locales/ar.d.ts +20 -0
  15. package/dist/types/locales/ar.d.ts.map +1 -0
  16. package/dist/types/locales/de.d.ts +20 -0
  17. package/dist/types/locales/de.d.ts.map +1 -0
  18. package/dist/types/locales/en.d.ts +20 -0
  19. package/dist/types/locales/en.d.ts.map +1 -0
  20. package/dist/types/locales/es.d.ts +20 -0
  21. package/dist/types/locales/es.d.ts.map +1 -0
  22. package/dist/types/locales/fr.d.ts +20 -0
  23. package/dist/types/locales/fr.d.ts.map +1 -0
  24. package/dist/types/locales/hi.d.ts +20 -0
  25. package/dist/types/locales/hi.d.ts.map +1 -0
  26. package/dist/types/locales/id.d.ts +20 -0
  27. package/dist/types/locales/id.d.ts.map +1 -0
  28. package/dist/types/locales/it.d.ts +20 -0
  29. package/dist/types/locales/it.d.ts.map +1 -0
  30. package/dist/types/locales/ja.d.ts +20 -0
  31. package/dist/types/locales/ja.d.ts.map +1 -0
  32. package/dist/types/locales/ko.d.ts +20 -0
  33. package/dist/types/locales/ko.d.ts.map +1 -0
  34. package/dist/types/locales/nl.d.ts +20 -0
  35. package/dist/types/locales/nl.d.ts.map +1 -0
  36. package/dist/types/locales/pl.d.ts +20 -0
  37. package/dist/types/locales/pl.d.ts.map +1 -0
  38. package/dist/types/locales/pt-BR.d.ts +20 -0
  39. package/dist/types/locales/pt-BR.d.ts.map +1 -0
  40. package/dist/types/locales/pt-PT.d.ts +20 -0
  41. package/dist/types/locales/pt-PT.d.ts.map +1 -0
  42. package/dist/types/locales/ru.d.ts +20 -0
  43. package/dist/types/locales/ru.d.ts.map +1 -0
  44. package/dist/types/locales/tr.d.ts +20 -0
  45. package/dist/types/locales/tr.d.ts.map +1 -0
  46. package/dist/types/locales/zh-CN.d.ts +20 -0
  47. package/dist/types/locales/zh-CN.d.ts.map +1 -0
  48. package/dist/types/messages.d.ts +67 -0
  49. package/dist/types/messages.d.ts.map +1 -0
  50. package/dist/types/model.d.ts +213 -0
  51. package/dist/types/model.d.ts.map +1 -0
  52. package/dist/types/slot-picker.d.ts +182 -0
  53. package/dist/types/slot-picker.d.ts.map +1 -0
  54. package/dist/types/source.d.ts +45 -0
  55. package/dist/types/source.d.ts.map +1 -0
  56. package/dist/types/views/columns.d.ts +28 -0
  57. package/dist/types/views/columns.d.ts.map +1 -0
  58. package/dist/types/views/day.d.ts +27 -0
  59. package/dist/types/views/day.d.ts.map +1 -0
  60. package/dist/types/views/shared.d.ts +99 -0
  61. package/dist/types/views/shared.d.ts.map +1 -0
  62. package/docs/USE_CASES.md +197 -0
  63. package/package.json +148 -0
  64. package/src/date.js +66 -0
  65. package/src/define.js +5 -0
  66. package/src/index.js +25 -0
  67. package/src/locales/ar.js +18 -0
  68. package/src/locales/de.js +18 -0
  69. package/src/locales/en.js +18 -0
  70. package/src/locales/es.js +18 -0
  71. package/src/locales/fr.js +18 -0
  72. package/src/locales/hi.js +18 -0
  73. package/src/locales/id.js +18 -0
  74. package/src/locales/it.js +18 -0
  75. package/src/locales/ja.js +18 -0
  76. package/src/locales/ko.js +18 -0
  77. package/src/locales/nl.js +18 -0
  78. package/src/locales/pl.js +18 -0
  79. package/src/locales/pt-BR.js +18 -0
  80. package/src/locales/pt-PT.js +18 -0
  81. package/src/locales/ru.js +18 -0
  82. package/src/locales/tr.js +18 -0
  83. package/src/locales/zh-CN.js +18 -0
  84. package/src/messages.js +44 -0
  85. package/src/model.js +357 -0
  86. package/src/slot-picker.css +597 -0
  87. package/src/slot-picker.js +1094 -0
  88. package/src/source.js +87 -0
  89. package/src/views/columns.js +108 -0
  90. package/src/views/day.js +122 -0
  91. 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
+ }
@@ -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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
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
+ }