@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
@@ -0,0 +1,18 @@
1
+ export default {
2
+ previous: "Hari sebelumnya",
3
+ next: "Hari berikutnya",
4
+ home: "Kembali ke awal",
5
+ nextAvailability: "Ketersediaan berikutnya",
6
+ showMore: "Tampilkan lebih banyak ketersediaan",
7
+ showLess: "Tampilkan lebih sedikit",
8
+ loading: "Memuat ketersediaan",
9
+ empty: "Tidak ada ketersediaan",
10
+ closed: "Tutup",
11
+ closedRange: "Tutup selama periode ini",
12
+ rangeEmptyTitle: "Tidak ada ketersediaan pada periode ini",
13
+ rangeEmptyDescription: "Dari {start} hingga {end}",
14
+ rangeEmptyNext: "Lihat periode berikutnya",
15
+ oneSlot: "1 slot",
16
+ manySlots: "{n} slot",
17
+ days: "Hari",
18
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ previous: "Giorni precedenti",
3
+ next: "Giorni successivi",
4
+ home: "Torna all'inizio",
5
+ nextAvailability: "Prossima disponibilità",
6
+ showMore: "Mostra più disponibilità",
7
+ showLess: "Mostra meno",
8
+ loading: "Caricamento disponibilità",
9
+ empty: "Nessuna disponibilità",
10
+ closed: "Chiuso",
11
+ closedRange: "Chiuso in questo periodo",
12
+ rangeEmptyTitle: "Nessuna disponibilità in questo periodo",
13
+ rangeEmptyDescription: "Dal {start} al {end}",
14
+ rangeEmptyNext: "Vedi il periodo successivo",
15
+ oneSlot: "1 slot",
16
+ manySlots: "{n} slot",
17
+ days: "Giorni",
18
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ previous: "前の日",
3
+ next: "次の日",
4
+ home: "最初に戻る",
5
+ nextAvailability: "次の空き",
6
+ showMore: "空きをさらに表示",
7
+ showLess: "表示を減らす",
8
+ loading: "空き状況を読み込み中",
9
+ empty: "空きがありません",
10
+ closed: "休診",
11
+ closedRange: "この期間は休診",
12
+ rangeEmptyTitle: "この期間に空きがありません",
13
+ rangeEmptyDescription: "{start} から {end} まで",
14
+ rangeEmptyNext: "次の期間を表示",
15
+ oneSlot: "1件",
16
+ manySlots: "{n}件",
17
+ days: "日",
18
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ previous: "이전 날짜",
3
+ next: "다음 날짜",
4
+ home: "처음으로 돌아가기",
5
+ nextAvailability: "다음 예약 가능",
6
+ showMore: "가능한 시간 더 보기",
7
+ showLess: "간단히 보기",
8
+ loading: "가능한 시간을 불러오는 중",
9
+ empty: "가능한 시간 없음",
10
+ closed: "휴진",
11
+ closedRange: "이 기간 동안 휴진",
12
+ rangeEmptyTitle: "이 기간에는 예약 가능한 시간이 없습니다",
13
+ rangeEmptyDescription: "{start}부터 {end}까지",
14
+ rangeEmptyNext: "다음 기간 보기",
15
+ oneSlot: "1개",
16
+ manySlots: "{n}개",
17
+ days: "일",
18
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ previous: "Vorige dagen",
3
+ next: "Volgende dagen",
4
+ home: "Terug naar begin",
5
+ nextAvailability: "Volgende beschikbaarheid",
6
+ showMore: "Meer beschikbaarheid tonen",
7
+ showLess: "Minder tonen",
8
+ loading: "Beschikbaarheid laden",
9
+ empty: "Geen beschikbaarheid",
10
+ closed: "Gesloten",
11
+ closedRange: "Gesloten in deze periode",
12
+ rangeEmptyTitle: "Geen beschikbaarheid in deze periode",
13
+ rangeEmptyDescription: "Van {start} tot {end}",
14
+ rangeEmptyNext: "Volgende periode bekijken",
15
+ oneSlot: "1 tijdslot",
16
+ manySlots: "{n} tijdsloten",
17
+ days: "Dagen",
18
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ previous: "Poprzednie dni",
3
+ next: "Następne dni",
4
+ home: "Powrót do początku",
5
+ nextAvailability: "Najbliższa dostępność",
6
+ showMore: "Pokaż więcej dostępności",
7
+ showLess: "Pokaż mniej",
8
+ loading: "Ładowanie dostępności",
9
+ empty: "Brak dostępności",
10
+ closed: "Zamknięte",
11
+ closedRange: "Zamknięte w tym okresie",
12
+ rangeEmptyTitle: "Brak dostępności w tym okresie",
13
+ rangeEmptyDescription: "Od {start} do {end}",
14
+ rangeEmptyNext: "Zobacz następny okres",
15
+ oneSlot: "1 termin",
16
+ manySlots: "{n} terminów",
17
+ days: "Dni",
18
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ previous: "Dias anteriores",
3
+ next: "Próximos dias",
4
+ home: "Voltar ao início",
5
+ nextAvailability: "Próxima disponibilidade",
6
+ showMore: "Mostrar mais disponibilidade",
7
+ showLess: "Mostrar menos",
8
+ loading: "Carregando disponibilidade",
9
+ empty: "Sem disponibilidade",
10
+ closed: "Fechado",
11
+ closedRange: "Fechado neste período",
12
+ rangeEmptyTitle: "Sem disponibilidade neste período",
13
+ rangeEmptyDescription: "De {start} a {end}",
14
+ rangeEmptyNext: "Ver o próximo período",
15
+ oneSlot: "1 horário",
16
+ manySlots: "{n} horários",
17
+ days: "Dias",
18
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ previous: "Dias anteriores",
3
+ next: "Dias seguintes",
4
+ home: "Voltar ao início",
5
+ nextAvailability: "Próxima disponibilidade",
6
+ showMore: "Mostrar mais disponibilidade",
7
+ showLess: "Mostrar menos",
8
+ loading: "A carregar disponibilidade",
9
+ empty: "Sem disponibilidade",
10
+ closed: "Fechado",
11
+ closedRange: "Fechado neste período",
12
+ rangeEmptyTitle: "Sem disponibilidade neste período",
13
+ rangeEmptyDescription: "De {start} a {end}",
14
+ rangeEmptyNext: "Ver o período seguinte",
15
+ oneSlot: "1 horário",
16
+ manySlots: "{n} horários",
17
+ days: "Dias",
18
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ previous: "Предыдущие дни",
3
+ next: "Следующие дни",
4
+ home: "Вернуться к началу",
5
+ nextAvailability: "Ближайшая доступность",
6
+ showMore: "Показать больше доступных слотов",
7
+ showLess: "Показать меньше",
8
+ loading: "Загрузка доступности",
9
+ empty: "Нет доступных слотов",
10
+ closed: "Закрыто",
11
+ closedRange: "Закрыто в этот период",
12
+ rangeEmptyTitle: "На этот период нет доступных слотов",
13
+ rangeEmptyDescription: "С {start} по {end}",
14
+ rangeEmptyNext: "Показать следующий период",
15
+ oneSlot: "1 слот",
16
+ manySlots: "{n} слотов",
17
+ days: "Дни",
18
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ previous: "Önceki günler",
3
+ next: "Sonraki günler",
4
+ home: "Başa dön",
5
+ nextAvailability: "Sonraki uygunluk",
6
+ showMore: "Daha fazla uygunluk göster",
7
+ showLess: "Daha az göster",
8
+ loading: "Uygunluk yükleniyor",
9
+ empty: "Uygun zaman yok",
10
+ closed: "Kapalı",
11
+ closedRange: "Bu dönemde kapalı",
12
+ rangeEmptyTitle: "Bu dönemde uygun zaman yok",
13
+ rangeEmptyDescription: "{start} ile {end} arası",
14
+ rangeEmptyNext: "Sonraki dönemi gör",
15
+ oneSlot: "1 randevu",
16
+ manySlots: "{n} randevu",
17
+ days: "Günler",
18
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ previous: "前几天",
3
+ next: "后几天",
4
+ home: "返回起始",
5
+ nextAvailability: "下一个可预约时间",
6
+ showMore: "显示更多可预约时间",
7
+ showLess: "收起",
8
+ loading: "正在加载可预约时间",
9
+ empty: "暂无可预约时间",
10
+ closed: "休息",
11
+ closedRange: "此期间休息",
12
+ rangeEmptyTitle: "该时间段暂无可预约时间",
13
+ rangeEmptyDescription: "从 {start} 到 {end}",
14
+ rangeEmptyNext: "查看下一个时间段",
15
+ oneSlot: "1 个时段",
16
+ manySlots: "{n} 个时段",
17
+ days: "日期",
18
+ };
@@ -0,0 +1,44 @@
1
+ const DEFAULT_MESSAGES = {
2
+ previous: "Previous days",
3
+ next: "Next days",
4
+ home: "Back to start",
5
+ nextAvailability: "Next availability",
6
+ showMore: "Show more availability",
7
+ showLess: "Show less",
8
+ loading: "Loading availability",
9
+ empty: "No availability",
10
+ closed: "Closed",
11
+ closedRange: "Closed during this period",
12
+ rangeEmptyTitle: "No availability in this period",
13
+ rangeEmptyDescription: "From {start} to {end}",
14
+ rangeEmptyNext: "See the next period",
15
+ oneSlot: "1 slot",
16
+ manySlots: "{n} slots",
17
+ days: "Days",
18
+ };
19
+
20
+ let defaults = { ...DEFAULT_MESSAGES };
21
+
22
+ /** Copy of the current global message defaults. */
23
+ export function getDefaultMessages() {
24
+ return { ...defaults };
25
+ }
26
+
27
+ /**
28
+ * Update the global defaults applied to every instance without its own
29
+ * `messages` override. Merges into the current defaults.
30
+ * @param {Partial<typeof DEFAULT_MESSAGES>|null|undefined} messages
31
+ */
32
+ export function setDefaultMessages(messages) {
33
+ defaults = { ...defaults, ...(messages ?? {}) };
34
+ }
35
+
36
+ /**
37
+ * Resolution hierarchy: DEFAULT_MESSAGES -> global defaults -> instance.
38
+ * @param {Partial<typeof DEFAULT_MESSAGES>|null|undefined} messages
39
+ */
40
+ export function resolveMessages(messages) {
41
+ return { ...defaults, ...(messages ?? {}) };
42
+ }
43
+
44
+ export { DEFAULT_MESSAGES };
package/src/model.js ADDED
@@ -0,0 +1,357 @@
1
+ import { addDays, compareDates, daysBetween, isDateValue, rangeEnd } from "./date.js";
2
+
3
+ const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
4
+
5
+ /**
6
+ * Default width-to-capacity ladder for `responsive` projection.
7
+ * Resolved against the component's own inline size, never the viewport.
8
+ * Calibrated after navigation stopped reserving grid tracks: each step is the
9
+ * width at which a column still measures ~110px in the fixtures.
10
+ * @type {readonly {minWidth:number,dayCount:number}[]}
11
+ */
12
+ export const RESPONSIVE_BREAKPOINTS = Object.freeze([
13
+ Object.freeze({ minWidth: 600, dayCount: 5 }),
14
+ Object.freeze({ minWidth: 480, dayCount: 4 }),
15
+ Object.freeze({ minWidth: 360, dayCount: 3 }),
16
+ Object.freeze({ minWidth: 260, dayCount: 2 }),
17
+ Object.freeze({ minWidth: 0, dayCount: 1 }),
18
+ ]);
19
+
20
+ /**
21
+ * `tone` is a neutral presentation token surfaced as `data-tone`; the core
22
+ * never interprets it. `meta` stays opaque and is never inspected.
23
+ * @typedef {{start:string,end?:string,disabled?:boolean,description?:string,tone?:string,meta?:unknown}} Slot
24
+ */
25
+ /** @typedef {{label:string,description?:string,meta?:unknown}} DayNotice */
26
+ /**
27
+ * `closed` is a normal day state (weekend, weekly closure), not an exception:
28
+ * it stays distinct from `notice` and from an open day with no availability.
29
+ * @typedef {{date:string,slots:Slot[],closed?:boolean,notice?:DayNotice}} SlotDay
30
+ */
31
+
32
+ const SLOT_RE = /^(\d{4}-\d{2}-\d{2})T((?:[01]\d|2[0-3]):[0-5]\d)$/;
33
+
34
+ /** @param {string} value */
35
+ export function isTimeValue(value) {
36
+ return TIME_RE.test(value);
37
+ }
38
+
39
+ /**
40
+ * Canonical slot value: a civil date and a time, no timezone.
41
+ * Rejects impossible dates such as 2026-02-31.
42
+ * @param {string} value
43
+ */
44
+ export function isSlotValue(value) {
45
+ const match = SLOT_RE.exec(value);
46
+ return match !== null && isDateValue(match[1]) && isTimeValue(match[2]);
47
+ }
48
+
49
+ /** @param {string} date @param {string} time */
50
+ export function slotValue(date, time) {
51
+ if (!isDateValue(date) || !isTimeValue(time)) throw new TypeError("Invalid slot date/time");
52
+ return `${date}T${time}`;
53
+ }
54
+
55
+ /** @param {SlotDay[]} input */
56
+ export function normalizeDays(input) {
57
+ if (!Array.isArray(input)) throw new TypeError("days must be an array");
58
+
59
+ const seen = new Set();
60
+ return input
61
+ .map((day) => {
62
+ if (!day || !isDateValue(day.date)) throw new TypeError("Each day needs a YYYY-MM-DD date");
63
+ if (seen.has(day.date)) throw new TypeError(`Duplicate day: ${day.date}`);
64
+ seen.add(day.date);
65
+
66
+ const slots = Array.isArray(day.slots) ? day.slots : [];
67
+ const seenStarts = new Set();
68
+ const normalizedSlots = slots
69
+ .map((slot) => {
70
+ if (!slot || !isTimeValue(slot.start)) {
71
+ throw new TypeError(`Invalid slot start on ${day.date}`);
72
+ }
73
+ if (slot.end && !isTimeValue(slot.end)) {
74
+ throw new TypeError(`Invalid slot end on ${day.date}`);
75
+ }
76
+ if (slot.tone !== undefined && (typeof slot.tone !== "string" || slot.tone.trim() === "")) {
77
+ throw new TypeError(`Invalid slot tone on ${day.date}`);
78
+ }
79
+ // The public value identifies a slot by date and time: a duplicate
80
+ // would break the single roving focus and the single selection.
81
+ if (seenStarts.has(slot.start)) {
82
+ throw new TypeError(`Duplicate slot: ${day.date}T${slot.start}`);
83
+ }
84
+ seenStarts.add(slot.start);
85
+ return { ...slot };
86
+ })
87
+ .sort((a, b) => a.start.localeCompare(b.start));
88
+
89
+ const notice = day.notice && typeof day.notice.label === "string" ? { ...day.notice } : undefined;
90
+ if (day.closed !== undefined && typeof day.closed !== "boolean") {
91
+ throw new TypeError(`Invalid day closed on ${day.date}`);
92
+ }
93
+ // `closed: true` promises no bookable slot: a contradictory payload is a
94
+ // source bug, never silently hidden by the projection policy.
95
+ if (day.closed === true && normalizedSlots.length > 0) {
96
+ throw new TypeError(`Closed day cannot have slots: ${day.date}`);
97
+ }
98
+
99
+ return {
100
+ date: day.date,
101
+ slots: normalizedSlots,
102
+ ...(day.closed ? { closed: true } : {}),
103
+ ...(notice ? { notice } : {}),
104
+ };
105
+ })
106
+ .sort((a, b) => a.date.localeCompare(b.date));
107
+ }
108
+
109
+ /**
110
+ * Fill missing days so an empty day remains visible.
111
+ * @param {SlotDay[]} days
112
+ * @param {string} start
113
+ * @param {number} dayCount
114
+ */
115
+ export function visibleDays(days, start, dayCount) {
116
+ const normalized = normalizeDays(days);
117
+ const byDate = new Map(normalized.map((day) => [day.date, day]));
118
+
119
+ return Array.from({ length: dayCount }, (_, index) => {
120
+ const date = addDays(start, index);
121
+ return byDate.get(date) ?? { date, slots: [] };
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Presentation-only collapse: keep the first N slot rows.
127
+ * Hidden slots stay part of the loaded model, so selection is unaffected;
128
+ * rendering and keyboard navigation must agree on this exact subset.
129
+ * @param {SlotDay[]} days
130
+ * @param {number} maxVisibleRows
131
+ * @param {boolean} expanded
132
+ */
133
+ export function collapsedDays(days, maxVisibleRows, expanded) {
134
+ const maxRows = Math.max(0, ...days.map((day) => day.slots.length));
135
+ const rows = expanded ? maxRows : Math.min(maxRows, maxVisibleRows);
136
+ return {
137
+ rows,
138
+ hasOverflow: maxRows > maxVisibleRows,
139
+ days: days.map((day) => ({ ...day, slots: day.slots.slice(0, rows) })),
140
+ };
141
+ }
142
+
143
+ /**
144
+ * True when `min`/`max` describe a consistent bound. Both empty is valid
145
+ * (unbounded); `min > max` is an explicit invalid configuration.
146
+ * @param {string} min
147
+ * @param {string} max
148
+ */
149
+ export function isValidRange(min, max) {
150
+ return !min || !max || compareDates(min, max) <= 0;
151
+ }
152
+
153
+ /**
154
+ * Reduce a requested day count to the days actually available inside the
155
+ * bounds. Step 2 of the pipeline: requested count -> bounded count.
156
+ * Invalid bounds (`min > max`) resolve to 0, never a magic window.
157
+ * @param {number} dayCount
158
+ * @param {string} min
159
+ * @param {string} max
160
+ */
161
+ export function boundedDayCount(dayCount, min, max) {
162
+ const requested = Math.max(1, Number(dayCount) || 1);
163
+ if (!min || !max) return requested;
164
+ if (compareDates(min, max) > 0) return 0;
165
+ return Math.min(requested, daysBetween(min, max) + 1);
166
+ }
167
+
168
+ /**
169
+ * Normalize an override ladder: descending widths, sane counts.
170
+ * @param {{minWidth?:number,dayCount?:number}[]} breakpoints
171
+ * @returns {{minWidth:number,dayCount:number}[]}
172
+ */
173
+ export function normalizeBreakpoints(breakpoints) {
174
+ return breakpoints
175
+ .map((breakpoint) => ({
176
+ minWidth: Math.max(0, Number(breakpoint?.minWidth) || 0),
177
+ dayCount: Math.max(1, Math.min(14, Number(breakpoint?.dayCount) || 1)),
178
+ }))
179
+ .sort((a, b) => b.minWidth - a.minWidth);
180
+ }
181
+
182
+ /**
183
+ * Step 3 of the pipeline: adapt the bounded count to the component's own
184
+ * inline size. Never exceeds the bounded count, never exceeds a breakpoint.
185
+ * @param {number} dayCount
186
+ * @param {number} width
187
+ * @param {readonly {minWidth:number,dayCount:number}[]} [breakpoints]
188
+ */
189
+ export function resolveVisibleDayCount(dayCount, width, breakpoints = RESPONSIVE_BREAKPOINTS) {
190
+ const requested = Math.max(0, Number(dayCount) || 0);
191
+ if (requested === 0) return 0;
192
+ const ladder = breakpoints.length ? breakpoints : RESPONSIVE_BREAKPOINTS;
193
+ const size = Number.isFinite(width) ? width : 0;
194
+ const match = ladder.find((breakpoint) => size >= breakpoint.minWidth) ?? ladder[ladder.length - 1];
195
+ return Math.min(requested, match.dayCount);
196
+ }
197
+
198
+ /**
199
+ * Keep a window inside its bounds without shrinking it below the requested
200
+ * count unless the interval itself is shorter.
201
+ * @param {string} start
202
+ * @param {string} min
203
+ * @param {string} max
204
+ * @param {number} dayCount
205
+ */
206
+ export function clampStart(start, min, max, dayCount) {
207
+ let next = start;
208
+ if (min && compareDates(next, min) < 0) next = min;
209
+ const count = Math.max(1, dayCount);
210
+ if (max) {
211
+ const latest = addDays(max, -(count - 1));
212
+ if (compareDates(latest, min || next) < 0) next = min || latest;
213
+ else if (compareDates(next, latest) > 0) next = latest;
214
+ }
215
+ return next;
216
+ }
217
+
218
+ /**
219
+ * Stable range adjustment: keep `start` when `date` is already visible,
220
+ * otherwise shift just enough to bring `date` back into the window.
221
+ * Shared by resize, `goTo`, and min/max/day-count changes.
222
+ * @param {string} start
223
+ * @param {number} dayCount
224
+ * @param {string} date
225
+ * @param {string} min
226
+ * @param {string} max
227
+ */
228
+ export function ensureVisible(start, dayCount, date, min, max) {
229
+ const count = Math.max(1, dayCount);
230
+ const bounded = clampStart(start, min, max, count);
231
+ if (!isDateValue(date)) return bounded;
232
+ const end = rangeEnd(bounded, count);
233
+ if (compareDates(date, bounded) >= 0 && compareDates(date, end) <= 0) return bounded;
234
+ const candidate = compareDates(date, bounded) < 0 ? date : addDays(date, -(count - 1));
235
+ return clampStart(candidate, min, max, count);
236
+ }
237
+
238
+ /**
239
+ * Any appointment slot in the visible window.
240
+ * @param {SlotDay[]} days
241
+ */
242
+ export function hasSlots(days) {
243
+ return days.some((day) => Array.isArray(day.slots) && day.slots.length > 0);
244
+ }
245
+
246
+ /**
247
+ * Any meaningful day content: a slot, a notice or a closed day. A window with
248
+ * no slot but a notice or a closed state must keep its projection, so an
249
+ * exception or a recurring closure stays visible and is not collapsed into
250
+ * "no availability".
251
+ * @param {SlotDay[]} days
252
+ */
253
+ export function hasDayContent(days) {
254
+ return days.some(
255
+ (day) => (Array.isArray(day.slots) && day.slots.length > 0) || Boolean(day.notice) || Boolean(day.closed),
256
+ );
257
+ }
258
+
259
+ /**
260
+ * @param {string} start
261
+ * @param {number} dayCount
262
+ */
263
+ export function rangeDetail(start, dayCount) {
264
+ return { start, end: rangeEnd(start, dayCount), dayCount };
265
+ }
266
+
267
+ /**
268
+ * Civil-only active day resolution.
269
+ * Loaded slots never influence the consulted day:
270
+ * absent/invalid maps to start, out-of-range clamps to the visible range.
271
+ * @param {string} start
272
+ * @param {number} dayCount
273
+ * @param {string} activeDate
274
+ */
275
+ export function resolveActiveDate(start, dayCount, activeDate) {
276
+ if (!isDateValue(activeDate)) return start;
277
+ if (compareDates(activeDate, start) < 0) return start;
278
+ const end = rangeEnd(start, dayCount);
279
+ if (compareDates(activeDate, end) > 0) return end;
280
+ return activeDate;
281
+ }
282
+
283
+ /**
284
+ * @param {SlotDay[]} days
285
+ * @returns {{dayIndex:number, slotIndex:number, value:string}[]}
286
+ */
287
+ export function focusableSlots(days) {
288
+ /** @type {{dayIndex:number, slotIndex:number, value:string}[]} */
289
+ const result = [];
290
+ days.forEach((day, dayIndex) => {
291
+ day.slots.forEach((slot, slotIndex) => {
292
+ result.push({ dayIndex, slotIndex, value: slotValue(day.date, slot.start) });
293
+ });
294
+ });
295
+ return result;
296
+ }
297
+
298
+ /** @param {SlotDay} day */
299
+ function firstEnabledIndex(day) {
300
+ return day.slots.findIndex((slot) => !slot.disabled);
301
+ }
302
+
303
+ /** @param {SlotDay} day */
304
+ function lastEnabledIndex(day) {
305
+ for (let index = day.slots.length - 1; index >= 0; index--) {
306
+ if (!day.slots[index].disabled) return index;
307
+ }
308
+ return -1;
309
+ }
310
+
311
+ /**
312
+ * Keyboard focus movement. Disabled slots are skipped: they stay visible
313
+ * and activatable-guarded, but never take keyboard focus.
314
+ * @param {SlotDay[]} days
315
+ * @param {{dayIndex:number,slotIndex:number}} current
316
+ * @param {"up"|"down"|"left"|"right"|"home"|"end"} direction
317
+ */
318
+ export function moveFocus(days, current, direction) {
319
+ const currentDay = days[current.dayIndex];
320
+ if (!currentDay) return current;
321
+
322
+ if (direction === "home") {
323
+ const index = firstEnabledIndex(currentDay);
324
+ return index < 0 ? current : { dayIndex: current.dayIndex, slotIndex: index };
325
+ }
326
+ if (direction === "end") {
327
+ const index = lastEnabledIndex(currentDay);
328
+ return index < 0 ? current : { dayIndex: current.dayIndex, slotIndex: index };
329
+ }
330
+
331
+ if (direction === "up" || direction === "down") {
332
+ const delta = direction === "up" ? -1 : 1;
333
+ let index = current.slotIndex + delta;
334
+ while (index >= 0 && index < currentDay.slots.length) {
335
+ if (!currentDay.slots[index].disabled) return { dayIndex: current.dayIndex, slotIndex: index };
336
+ index += delta;
337
+ }
338
+ return current;
339
+ }
340
+
341
+ const delta = direction === "left" ? -1 : 1;
342
+ for (let dayIndex = current.dayIndex + delta; dayIndex >= 0 && dayIndex < days.length; dayIndex += delta) {
343
+ const day = days[dayIndex];
344
+ if (!day.slots.length || firstEnabledIndex(day) < 0) continue;
345
+ const clamped = Math.min(current.slotIndex, day.slots.length - 1);
346
+ if (!day.slots[clamped].disabled) return { dayIndex, slotIndex: clamped };
347
+ // Nearest enabled row to the clamped position.
348
+ for (let distance = 1; distance < day.slots.length; distance++) {
349
+ const before = clamped - distance;
350
+ const after = clamped + distance;
351
+ if (before >= 0 && !day.slots[before].disabled) return { dayIndex, slotIndex: before };
352
+ if (after < day.slots.length && !day.slots[after].disabled) return { dayIndex, slotIndex: after };
353
+ }
354
+ }
355
+
356
+ return current;
357
+ }