@lekoala/slot-picker 0.1.0 → 0.1.2

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 (78) hide show
  1. package/README.md +85 -39
  2. package/custom-elements.json +0 -5
  3. package/dist/slot-picker.css +22 -0
  4. package/dist/slot-picker.js +160 -134
  5. package/dist/slot-picker.min.css +1 -1
  6. package/dist/slot-picker.min.js +24 -24
  7. package/dist/types/date.d.ts +8 -1
  8. package/dist/types/date.d.ts.map +1 -1
  9. package/dist/types/index.d.ts +2 -2
  10. package/dist/types/index.d.ts.map +1 -1
  11. package/dist/types/locales/ar.d.ts +0 -1
  12. package/dist/types/locales/ar.d.ts.map +1 -1
  13. package/dist/types/locales/de.d.ts +0 -1
  14. package/dist/types/locales/de.d.ts.map +1 -1
  15. package/dist/types/locales/en.d.ts +0 -1
  16. package/dist/types/locales/en.d.ts.map +1 -1
  17. package/dist/types/locales/es.d.ts +0 -1
  18. package/dist/types/locales/es.d.ts.map +1 -1
  19. package/dist/types/locales/fr.d.ts +0 -1
  20. package/dist/types/locales/fr.d.ts.map +1 -1
  21. package/dist/types/locales/hi.d.ts +0 -1
  22. package/dist/types/locales/hi.d.ts.map +1 -1
  23. package/dist/types/locales/id.d.ts +0 -1
  24. package/dist/types/locales/id.d.ts.map +1 -1
  25. package/dist/types/locales/it.d.ts +0 -1
  26. package/dist/types/locales/it.d.ts.map +1 -1
  27. package/dist/types/locales/ja.d.ts +0 -1
  28. package/dist/types/locales/ja.d.ts.map +1 -1
  29. package/dist/types/locales/ko.d.ts +0 -1
  30. package/dist/types/locales/ko.d.ts.map +1 -1
  31. package/dist/types/locales/nl.d.ts +0 -1
  32. package/dist/types/locales/nl.d.ts.map +1 -1
  33. package/dist/types/locales/pl.d.ts +0 -1
  34. package/dist/types/locales/pl.d.ts.map +1 -1
  35. package/dist/types/locales/pt-BR.d.ts +0 -1
  36. package/dist/types/locales/pt-BR.d.ts.map +1 -1
  37. package/dist/types/locales/pt-PT.d.ts +0 -1
  38. package/dist/types/locales/pt-PT.d.ts.map +1 -1
  39. package/dist/types/locales/ru.d.ts +0 -1
  40. package/dist/types/locales/ru.d.ts.map +1 -1
  41. package/dist/types/locales/tr.d.ts +0 -1
  42. package/dist/types/locales/tr.d.ts.map +1 -1
  43. package/dist/types/locales/zh-CN.d.ts +0 -1
  44. package/dist/types/locales/zh-CN.d.ts.map +1 -1
  45. package/dist/types/messages.d.ts +0 -3
  46. package/dist/types/messages.d.ts.map +1 -1
  47. package/dist/types/model.d.ts +58 -23
  48. package/dist/types/model.d.ts.map +1 -1
  49. package/dist/types/slot-picker.d.ts +25 -15
  50. package/dist/types/slot-picker.d.ts.map +1 -1
  51. package/dist/types/views/shared.d.ts +5 -4
  52. package/dist/types/views/shared.d.ts.map +1 -1
  53. package/docs/USE_CASES.md +24 -18
  54. package/package.json +1 -1
  55. package/src/date.js +11 -1
  56. package/src/index.js +13 -2
  57. package/src/locales/ar.js +0 -1
  58. package/src/locales/de.js +0 -1
  59. package/src/locales/en.js +0 -1
  60. package/src/locales/es.js +0 -1
  61. package/src/locales/fr.js +0 -1
  62. package/src/locales/hi.js +0 -1
  63. package/src/locales/id.js +0 -1
  64. package/src/locales/it.js +0 -1
  65. package/src/locales/ja.js +0 -1
  66. package/src/locales/ko.js +0 -1
  67. package/src/locales/nl.js +0 -1
  68. package/src/locales/pl.js +0 -1
  69. package/src/locales/pt-BR.js +0 -1
  70. package/src/locales/pt-PT.js +0 -1
  71. package/src/locales/ru.js +0 -1
  72. package/src/locales/tr.js +0 -1
  73. package/src/locales/zh-CN.js +0 -1
  74. package/src/messages.js +0 -1
  75. package/src/model.js +149 -55
  76. package/src/slot-picker.css +22 -0
  77. package/src/slot-picker.js +123 -100
  78. package/src/views/shared.js +10 -7
package/src/model.js CHANGED
@@ -1,4 +1,4 @@
1
- import { addDays, compareDates, daysBetween, isDateValue, rangeEnd } from "./date.js";
1
+ import { addDays, compareDates, isDateValue, weekdayIndex } from "./date.js";
2
2
 
3
3
  const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
4
4
 
@@ -24,8 +24,10 @@ export const RESPONSIVE_BREAKPOINTS = Object.freeze([
24
24
  */
25
25
  /** @typedef {{label:string,description?:string,meta?:unknown}} DayNotice */
26
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.
27
+ * `closed` is the business state of one date: it exists in the projection,
28
+ * keeps its column and simply is not open. It is never calendar structure —
29
+ * a weekday that should not be a column at all belongs to `hiddenDays` — and
30
+ * stays distinct from `notice` and from an open day with no availability.
29
31
  * @typedef {{date:string,slots:Slot[],closed?:boolean,notice?:DayNotice}} SlotDay
30
32
  */
31
33
 
@@ -108,18 +110,15 @@ export function normalizeDays(input) {
108
110
 
109
111
  /**
110
112
  * Fill missing days so an empty day remains visible.
113
+ * The projected dates decide which columns exist; loaded data never does.
111
114
  * @param {SlotDay[]} days
112
- * @param {string} start
113
- * @param {number} dayCount
115
+ * @param {string[]} dates
114
116
  */
115
- export function visibleDays(days, start, dayCount) {
117
+ export function visibleDays(days, dates) {
116
118
  const normalized = normalizeDays(days);
117
119
  const byDate = new Map(normalized.map((day) => [day.date, day]));
118
120
 
119
- return Array.from({ length: dayCount }, (_, index) => {
120
- const date = addDays(start, index);
121
- return byDate.get(date) ?? { date, slots: [] };
122
- });
121
+ return dates.map((date) => byDate.get(date) ?? { date, slots: [] });
123
122
  }
124
123
 
125
124
  /**
@@ -151,18 +150,108 @@ export function isValidRange(min, max) {
151
150
  }
152
151
 
153
152
  /**
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
153
+ * Keep a civil date inside an inclusive interval. Empty bounds are open.
154
+ * @param {string} date
158
155
  * @param {string} min
159
156
  * @param {string} max
160
157
  */
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);
158
+ export function clampDate(date, min, max) {
159
+ if (min && compareDates(date, min) < 0) return min;
160
+ if (max && compareDates(date, max) > 0) return max;
161
+ return date;
162
+ }
163
+
164
+ /**
165
+ * Weekdays that are never projected as a column, as `Date#getDay` indexes
166
+ * (0 = Sunday). This is calendar structure, known before any data is loaded:
167
+ * it is not an availability rule and never inspects a `SlotDay`.
168
+ * @param {readonly number[]|null|undefined} input
169
+ * @returns {number[]}
170
+ */
171
+ export function normalizeHiddenDays(input) {
172
+ if (input === null || input === undefined) return [];
173
+ if (!Array.isArray(input)) throw new TypeError("hiddenDays must be an array of weekday indexes");
174
+ const indexes = input.map((value) => {
175
+ const index = Number(value);
176
+ if (!Number.isInteger(index) || index < 0 || index > 6) {
177
+ throw new TypeError("hiddenDays entries must be integers from 0 (Sunday) to 6 (Saturday)");
178
+ }
179
+ return index;
180
+ });
181
+ const hidden = [...new Set(indexes)].sort((a, b) => a - b);
182
+ // Hiding every weekday would leave no day to project at all.
183
+ if (hidden.length === 7) throw new TypeError("hiddenDays cannot hide every weekday");
184
+ return hidden;
185
+ }
186
+
187
+ /**
188
+ * Collect up to `count` projected days walking one civil day at a time.
189
+ * `step` is 1 or -1; `bound` is the civil date the walk must not cross.
190
+ * @param {string} from
191
+ * @param {number} count
192
+ * @param {number[]} hidden
193
+ * @param {1|-1} step
194
+ * @param {string} bound
195
+ */
196
+ function walk(from, count, hidden, step, bound) {
197
+ /** @type {string[]} */
198
+ const dates = [];
199
+ let date = from;
200
+ // `hidden` can never hold all seven weekdays, so the walk always advances.
201
+ while (dates.length < count) {
202
+ if (bound && (step > 0 ? compareDates(date, bound) > 0 : compareDates(date, bound) < 0)) break;
203
+ if (!hidden.includes(weekdayIndex(date))) dates.push(date);
204
+ date = addDays(date, step);
205
+ }
206
+ return dates;
207
+ }
208
+
209
+ /**
210
+ * The projection: the civil dates actually rendered as columns.
211
+ *
212
+ * `dayCount` counts columns, never civil days. A hidden weekday widens the
213
+ * civil envelope instead of eating a column, so at equal width and bounds a
214
+ * window always keeps its capacity. Only `min`/`max` can return fewer dates
215
+ * than requested, because the interval itself holds too few projectable days.
216
+ * @param {string} start
217
+ * @param {number} dayCount
218
+ * @param {readonly number[]} [hiddenDays]
219
+ * @param {string} [min]
220
+ * @param {string} [max]
221
+ * @returns {string[]}
222
+ */
223
+ export function projectDates(start, dayCount, hiddenDays = [], min = "", max = "") {
224
+ const count = Math.max(0, Math.trunc(Number(dayCount) || 0));
225
+ if (!count || !isDateValue(start) || !isValidRange(min, max)) return [];
226
+ const hidden = normalizeHiddenDays(hiddenDays);
227
+ const from = clampDate(start, min, max);
228
+ const forward = walk(from, count, hidden, 1, max);
229
+ if (forward.length === count) return forward;
230
+ // The upper bound truncated the window: recover the missing columns before
231
+ // `from`, so bounds reduce the capacity only when the interval really is
232
+ // too short, never because the window happens to sit at the end.
233
+ const backward = walk(addDays(forward[0] ?? from, -1), count - forward.length, hidden, -1, min);
234
+ return [...backward.reverse(), ...forward];
235
+ }
236
+
237
+ /**
238
+ * Adjacent window, stepping by projected days so navigation preserves the
239
+ * column capacity. `direction` is 1 or -1.
240
+ * @param {string[]} dates
241
+ * @param {1|-1} direction
242
+ * @param {readonly number[]} [hiddenDays]
243
+ * @param {string} [min]
244
+ * @param {string} [max]
245
+ */
246
+ export function stepStart(dates, direction, hiddenDays = [], min = "", max = "") {
247
+ if (!dates.length) return "";
248
+ const hidden = normalizeHiddenDays(hiddenDays);
249
+ const count = dates.length;
250
+ if (direction > 0) {
251
+ return projectDates(addDays(dates[count - 1], 1), count, hidden, min, max)[0] ?? dates[0];
252
+ }
253
+ const backward = walk(addDays(dates[0], -1), count, hidden, -1, min);
254
+ return projectDates(backward[backward.length - 1] ?? dates[0], count, hidden, min, max)[0] ?? dates[0];
166
255
  }
167
256
 
168
257
  /**
@@ -196,43 +285,43 @@ export function resolveVisibleDayCount(dayCount, width, breakpoints = RESPONSIVE
196
285
  }
197
286
 
198
287
  /**
199
- * Keep a window inside its bounds without shrinking it below the requested
200
- * count unless the interval itself is shorter.
288
+ * First projected day of the window `start` belongs to, inside the bounds.
201
289
  * @param {string} start
202
290
  * @param {string} min
203
291
  * @param {string} max
204
292
  * @param {number} dayCount
293
+ * @param {readonly number[]} [hiddenDays]
205
294
  */
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;
295
+ export function clampStart(start, min, max, dayCount, hiddenDays = []) {
296
+ return projectDates(start, dayCount, hiddenDays, min, max)[0] ?? clampDate(start, min, max);
216
297
  }
217
298
 
218
299
  /**
219
- * Stable range adjustment: keep `start` when `date` is already visible,
300
+ * Stable range adjustment: keep `start` when `date` is already projected,
220
301
  * otherwise shift just enough to bring `date` back into the window.
221
- * Shared by resize, `goTo`, and min/max/day-count changes.
302
+ * Shared by resize, `goTo`, and min/max/day-count/hidden-day changes.
222
303
  * @param {string} start
223
304
  * @param {number} dayCount
224
305
  * @param {string} date
225
306
  * @param {string} min
226
307
  * @param {string} max
308
+ * @param {readonly number[]} [hiddenDays]
227
309
  */
228
- export function ensureVisible(start, dayCount, date, min, max) {
310
+ export function ensureVisible(start, dayCount, date, min, max, hiddenDays = []) {
311
+ const hidden = normalizeHiddenDays(hiddenDays);
229
312
  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);
313
+ const dates = projectDates(start, count, hidden, min, max);
314
+ if (!dates.length) return clampDate(start, min, max);
315
+ const first = dates[0];
316
+ if (!isDateValue(date)) return first;
317
+ if (compareDates(date, first) < 0) return projectDates(date, count, hidden, min, max)[0] ?? first;
318
+ if (compareDates(date, dates[dates.length - 1]) <= 0) return first;
319
+ // After the window: align it on the first projected day at or after `date`,
320
+ // without moving further than necessary.
321
+ const target = walk(date, 1, hidden, 1, max)[0];
322
+ if (!target) return first;
323
+ const backward = walk(target, count, hidden, -1, min);
324
+ return projectDates(backward[backward.length - 1] ?? target, count, hidden, min, max)[0] ?? first;
236
325
  }
237
326
 
238
327
  /**
@@ -257,27 +346,32 @@ export function hasDayContent(days) {
257
346
  }
258
347
 
259
348
  /**
260
- * @param {string} start
261
- * @param {number} dayCount
349
+ * Public range of a projection: first and last rendered day plus the number
350
+ * of columns. `end` is the last projected day, so the civil envelope
351
+ * (`start` to `end`) may span more days than `dayCount` when weekdays are
352
+ * hidden. That envelope is exactly what a source has to load.
353
+ * @param {string[]} dates
262
354
  */
263
- export function rangeDetail(start, dayCount) {
264
- return { start, end: rangeEnd(start, dayCount), dayCount };
355
+ export function rangeDetail(dates) {
356
+ if (!dates.length) return { start: "", end: "", dayCount: 0 };
357
+ return { start: dates[0], end: dates[dates.length - 1], dayCount: dates.length };
265
358
  }
266
359
 
267
360
  /**
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
361
+ * Civil-only active day resolution against the projected dates.
362
+ * Loaded slots never influence the consulted day: absent/invalid maps to the
363
+ * first column, out-of-range clamps to the window, and a date falling on a
364
+ * hidden weekday resolves to the next projected column.
365
+ * @param {string[]} dates
273
366
  * @param {string} activeDate
274
367
  */
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;
368
+ export function resolveActiveDate(dates, activeDate) {
369
+ if (!dates.length) return "";
370
+ const last = dates[dates.length - 1];
371
+ if (!isDateValue(activeDate)) return dates[0];
372
+ if (compareDates(activeDate, dates[0]) <= 0) return dates[0];
373
+ if (compareDates(activeDate, last) >= 0) return last;
374
+ return dates.find((date) => compareDates(date, activeDate) >= 0) ?? last;
281
375
  }
282
376
 
283
377
  /**
@@ -56,6 +56,11 @@
56
56
  font: inherit;
57
57
  }
58
58
 
59
+ /* The component renders in light DOM, so a page stylesheet can reach these
60
+ declarations. `!important` is what keeps assistive-technology-only text (the
61
+ loading status, the day strip's spoken labels) out of sight: losing it is an
62
+ accessibility regression, not a cosmetic one. */
63
+ /* biome-ignore-start lint/complexity/noImportantStyles: visually-hidden must win over page styles */
59
64
  .sp-visually-hidden {
60
65
  position: absolute !important;
61
66
  inline-size: 1px !important;
@@ -67,6 +72,7 @@
67
72
  white-space: nowrap !important;
68
73
  border: 0 !important;
69
74
  }
75
+ /* biome-ignore-end lint/complexity/noImportantStyles: visually-hidden must win over page styles */
70
76
 
71
77
  .sp-shell {
72
78
  display: block;
@@ -214,6 +220,9 @@ slot-picker[home-date] .sp-shortcuts {
214
220
 
215
221
  .sp-slots {
216
222
  display: grid;
223
+ /* Bound the single column to the day column: an `auto` track follows the
224
+ widest slot's min-content and would push every slot past the day card. */
225
+ grid-template-columns: minmax(0, 1fr);
217
226
  gap: var(--sp-gap);
218
227
  }
219
228
 
@@ -225,6 +234,19 @@ slot-picker[home-date] .sp-shortcuts {
225
234
  box-sizing: border-box;
226
235
  border-radius: var(--sp-radius);
227
236
  font: inherit;
237
+ /* A grid item defaults to `min-width: auto`, i.e. its min-content; that would
238
+ let a wide slot (a theme icon next to the time) push the bounded day column.
239
+ Clamp the button to its track instead; the content stays centered. */
240
+ min-inline-size: 0;
241
+ max-inline-size: 100%;
242
+ }
243
+
244
+ /* Time wrapper: inline-flex so a theme can append an in-flow icon (`::after`)
245
+ that stays adjacent to the time and vertically centered. */
246
+ .sp-slot-time {
247
+ display: inline-flex;
248
+ align-items: center;
249
+ justify-content: center;
228
250
  }
229
251
 
230
252
  .sp-slot {