@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,99 @@
1
+ export type Slot = import("../model.js").Slot;
2
+ export type SlotCountMessages = {
3
+ oneSlot: string;
4
+ manySlots: string;
5
+ };
6
+ export type RangeEmptyMessages = {
7
+ rangeEmptyTitle: string;
8
+ rangeEmptyDescription: string;
9
+ rangeEmptyNext: string;
10
+ nextAvailability: string;
11
+ closedRange: string;
12
+ };
13
+ /** @param {string} value */
14
+ export declare function escapeHtml(value: string): string;
15
+ /** @param {string} value */
16
+ export declare function escapeAttr(value: string): string;
17
+ /**
18
+ * Ordered values of enabled slots only. Disabled slots stay discoverable
19
+ * in the DOM but never take the roving tabindex.
20
+ * @param {{date:string,slots:Slot[]}[]} days
21
+ */
22
+ export declare function enabledValues(days: {
23
+ date: string;
24
+ slots: Slot[];
25
+ }[]): string[];
26
+ /**
27
+ * Pick the single roving value: first preferred value that is enabled,
28
+ * otherwise the first enabled value, otherwise "".
29
+ * @param {string[]} enabled
30
+ * @param {...string} preferred
31
+ */
32
+ export declare function rovingValue(enabled: string[], ...preferred: string[]): string;
33
+ /**
34
+ * Single slot button. Semantics must stay identical across projections.
35
+ * @param {{date:string,slot:Slot,dayIndex:number,slotIndex:number,selected:boolean,tabbed:boolean}} options
36
+ */
37
+ export declare function slotButton({ date, slot, dayIndex, slotIndex, selected, tabbed }: {
38
+ date: string;
39
+ slot: Slot;
40
+ dayIndex: number;
41
+ slotIndex: number;
42
+ selected: boolean;
43
+ tabbed: boolean;
44
+ }): string;
45
+ /**
46
+ * Per-day notice indicator. Without options it is purely decorative. With
47
+ * `interactive: true` it is the only control that emits `noticeactivate`:
48
+ * the accessible name carries the description, the native `title` is a
49
+ * desktop-only bonus.
50
+ * @param {{interactive?:boolean,dayIndex?:number,label?:string,description?:string}} [options]
51
+ */
52
+ export declare function noticeIndicator(options?: {
53
+ interactive?: boolean;
54
+ dayIndex?: number;
55
+ label?: string;
56
+ description?: string;
57
+ }): string;
58
+ /**
59
+ * Readable day notice. Deliberately non-interactive: `noticeactivate` belongs
60
+ * to the explicit control, never to displayed text, so there is no mouse-only
61
+ * activation.
62
+ * @param {{label:string,description?:string}} notice
63
+ */
64
+ export declare function noticeBlock({ label, description }: {
65
+ label: string;
66
+ description?: string;
67
+ }): string;
68
+ /**
69
+ * Real DOM empty state. Text comes from messages, never from CSS.
70
+ * @param {string} message
71
+ */
72
+ export declare function dayEmpty(message: string): string;
73
+ /**
74
+ * Localized slot count for the day strip. Zero renders as an en dash,
75
+ * keeping the indicator distinct from a real sentence.
76
+ * @param {number} count
77
+ * @param {SlotCountMessages} messages
78
+ */
79
+ export declare function slotCountText(count: number, messages: SlotCountMessages): string;
80
+ /**
81
+ * Range-level empty state, distinct from per-day empty days.
82
+ * Rendered only when the whole window has no slot and no notice; the theme
83
+ * decides how prominent it is. Two actions stay distinct: `next()` moves to
84
+ * the immediately following window, while `next availability` (opt-in via
85
+ * `next-availability`) is the contextual business search that may skip
86
+ * several windows through the source. They are never merged.
87
+ * @param {{start:string,end:string,invalid:boolean,canNext:boolean,nextAvailability:boolean,closed:boolean,locale:string,messages:RangeEmptyMessages}} options
88
+ */
89
+ export declare function rangeEmpty({ start, end, invalid, canNext, nextAvailability, closed, locale, messages }: {
90
+ start: string;
91
+ end: string;
92
+ invalid: boolean;
93
+ canNext: boolean;
94
+ nextAvailability: boolean;
95
+ closed: boolean;
96
+ locale: string;
97
+ messages: RangeEmptyMessages;
98
+ }): string;
99
+ //# sourceMappingURL=shared.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../../src/views/shared.js"],"names":[],"mappings":"AAGI,YAAsC,IAAI,GAAhC,OAAO,aAAa,EAAE,IAAI,CAAM;AAC1C,YAA6C,iBAAiB,GAApD;IAAC,OAAO,EAAC,MAAM,CAAC;IAAA,SAAS,EAAC,MAAM,CAAA;CAAC,CAAmB;AAC9D,YAAkI,kBAAkB,GAA1I;IAAC,eAAe,EAAC,MAAM,CAAC;IAAA,qBAAqB,EAAC,MAAM,CAAC;IAAA,cAAc,EAAC,MAAM,CAAC;IAAA,gBAAgB,EAAC,MAAM,CAAC;IAAA,WAAW,EAAC,MAAM,CAAA;CAAC,CAAoB;AAKxJ,4BAA4B;AAC5B,wBAAgB,UAAU,CAAC,KAAK,EADpB,MACoB,UAE/B;AAED,4BAA4B;AAC5B,wBAAgB,UAAU,CAAC,KAAK,EADpB,MACoB,UAE/B;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAFvB;IAAC,IAAI,EAAC,MAAM,CAAC;IAAA,KAAK,EAAC,IAAI,EAAE,CAAA;CAAC,EAEH,YASjC;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAHxB,MAAM,EAGkB,EAAE,GAAG,SAAS,EAFnC,MAAM,EAE6B,UAKhD;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,EAFrE;IAAC,IAAI,EAAC,MAAM,CAAC;IAAA,IAAI,EAAC,IAAI,CAAC;IAAA,QAAQ,EAAC,MAAM,CAAC;IAAA,SAAS,EAAC,MAAM,CAAC;IAAA,QAAQ,EAAC,OAAO,CAAC;IAAA,MAAM,EAAC,OAAO,CAAA;CAElB,UAU/E;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,AAFpC,CAA4F,EAApF;IAAC,WAAW,CAAC,EAAC,OAAO,CAAC;IAAA,QAAQ,CAAC,EAAC,MAAM,CAAC;IAAA,KAAK,CAAC,EAAC,MAAM,CAAC;IAAA,WAAW,CAAC,EAAC,MAAM,CAAA;CAEvC,UAO3C;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,EAFvC;IAAC,KAAK,EAAC,MAAM,CAAC;IAAA,WAAW,CAAC,EAAC,MAAM,CAAA;CAEM,UAKjD;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAFrB,MAEqB,UAE/B;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAHxB,MAGwB,EAAE,QAAQ,EAFlC,iBAEkC,UAI5C;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,EAAE,KAAK,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,EAF5F;IAAC,KAAK,EAAC,MAAM,CAAC;IAAA,GAAG,EAAC,MAAM,CAAC;IAAA,OAAO,EAAC,OAAO,CAAC;IAAA,OAAO,EAAC,OAAO,CAAC;IAAA,gBAAgB,EAAC,OAAO,CAAC;IAAA,MAAM,EAAC,OAAO,CAAC;IAAA,MAAM,EAAC,MAAM,CAAC;IAAA,QAAQ,EAAC,kBAAkB,CAAA;CAE9C,UAiCtG"}
@@ -0,0 +1,197 @@
1
+ # Use cases
2
+
3
+ These cases define the product more strongly than a feature checklist.
4
+
5
+ ## U1 — Inline appointment availability
6
+
7
+ A consumer provides five civil days containing zero or more appointment slots.
8
+
9
+ Acceptance:
10
+
11
+ - each day is clearly labelled;
12
+ - slots render chronologically;
13
+ - activating a slot dispatches `slotactivate`;
14
+ - the selected value is a local civil datetime (`YYYY-MM-DDTHH:mm`);
15
+ - empty days remain visible;
16
+ - `slot.tone` is a neutral presentation token surfaced as `data-tone`; the core defines no palette and never interprets the token;
17
+ - `slot.description` carries accessible detail; a tooltip or popover remains application-owned.
18
+
19
+ ## U2 — Controlled range navigation
20
+
21
+ The consumer can show three or five days depending on its layout.
22
+
23
+ Acceptance:
24
+
25
+ - `dayCount` means exactly N consecutive civil days;
26
+ - previous/next navigation changes `start`, not `value`;
27
+ - `rangechange` exposes the requested start/end range;
28
+ - min/max bounds disable impossible navigation;
29
+ - responsive policy remains consumer-owned.
30
+
31
+ ## U3 — Remote availability
32
+
33
+ A source function loads availability for the visible range.
34
+
35
+ Acceptance:
36
+
37
+ - the source receives the exact visible range;
38
+ - superseded requests are aborted;
39
+ - stale responses never repaint the current range;
40
+ - source/backend URL conventions are application-owned;
41
+ - loading and error states do not mutate selection;
42
+ - loading is signalled without layout shift: `aria-busy="true"` on the host, the loading text announced through a visually hidden `role="status"`, and only a delayed CSS fade once the response is actually slow;
43
+
44
+ ## U4 — Day notice / exceptional unavailability
45
+
46
+ A day can carry a notice such as "exceptionally unavailable".
47
+
48
+ Acceptance:
49
+
50
+ - the notice is distinct from an appointment slot;
51
+ - it does not become a fake slot;
52
+ - `notice-display` selects the projection strategy: `action` (default), `inline`, or `none`;
53
+ - `action` renders only a real control (the dot button) with an accessible name; no inline text is shown;
54
+ - `inline` additionally renders the readable notice content, by layout: compact label in `columns` (description kept in the DOM, revealed by themes) and full label + description in `day`;
55
+ - `inline` never overflows its column, even with an unbroken long label;
56
+ - displayed notice text is never interactive: `noticeactivate` belongs to an explicit control only, so there is no mouse-only activation;
57
+ - `notice-display="action"` renders the control in the day header (`columns`) or the panel header (`day`); `inline` keeps it in `columns` and relies on the text in `day`;
58
+ - `none` renders nothing; the application owns the notice elsewhere;
59
+ - activating the control dispatches `noticeactivate` with `{ day, notice, anchor }`, bubbling, without a default behavior and without selecting a slot;
60
+ - the component never owns a tooltip, popover or floating engine; the application decides (modal, popover, drawer or nothing).
61
+
62
+ ## U5 — Collapsed availability
63
+
64
+ A dense range can initially show only the first N slot rows.
65
+
66
+ Acceptance:
67
+
68
+ - collapsing is presentation only;
69
+ - hidden slots remain part of the loaded model;
70
+ - "show more" expands without fetching again;
71
+ - selection remains stable across expand/collapse;
72
+ - while collapsed, "show more" floats inside the reserved footer band and a gradient hints at the availability that is not rendered; expanding returns it to normal flow with no overlay;
73
+ - hidden slots are not rendered in the DOM while collapsed, so no invisible button can be focused.
74
+
75
+ ## U6 — Keyboard-only slot choice
76
+
77
+ A user can navigate and select without a pointing device.
78
+
79
+ Acceptance:
80
+
81
+ - one slot is in the Tab sequence;
82
+ - ArrowUp/ArrowDown move within a day;
83
+ - ArrowLeft/ArrowRight move to the nearest row in the adjacent day;
84
+ - Home/End move to the first/last slot of the current day;
85
+ - Enter/Space activates;
86
+ - focus movement never changes `value`;
87
+ - `slotactivate` bubbles, so one ancestor listener can delegate several pickers.
88
+
89
+ ## U7 — Controlled selection
90
+
91
+ An application can set or clear `value`.
92
+
93
+ Acceptance:
94
+
95
+ - `value` is `YYYY-MM-DDTHH:mm` or empty;
96
+ - an impossible civil date (for example `2026-02-31T10:00`) is rejected by the property setter;
97
+ - a malformed `value` attribute is ignored and reads as empty, so imperfect markup never breaks the element upgrade;
98
+ - setting `value` only changes visual selection;
99
+ - programmatic assignment does not emit `slotactivate`;
100
+ - selecting a disabled slot is impossible.
101
+
102
+ ## U8 — Legacy application adapter
103
+
104
+ A legacy application converts its existing event payload to normalized days/slots.
105
+
106
+ Acceptance:
107
+
108
+ - FullCalendar class names never enter the component;
109
+ - booking URLs and domain metadata can be retained as opaque `meta`;
110
+ - the adapter maps its domain status to a neutral `slot.tone`; the core never interprets it;
111
+ - the application owns post-selection booking behavior;
112
+ - replacing the legacy backend does not require changing the picker.
113
+
114
+ ## U9 — Day projection of the same model
115
+
116
+ The same selection model can be projected as a day strip plus the slots of one consulted day.
117
+
118
+ Acceptance:
119
+
120
+ - `layout` is `columns` (default) or `day`; the consumer chooses it explicitly;
121
+ - there is no automatic `columns` to `day` breakpoint on viewport size;
122
+ - `activeDate` is the last day explicitly targeted by the user (day click or slot activation);
123
+ - `activeDate` resolution is civil-only: absent maps to `start`, before-range maps to `start`, after-range maps to `end`; loaded slots never influence it;
124
+ - activating a slot sets `activeDate` to that slot's day before dispatching `slotactivate`;
125
+ - changing `layout` preserves `activeDate` and `value`;
126
+ - the active day uses `aria-pressed`, never `aria-current="date"`;
127
+ - today remains independently discoverable from the consulted day;
128
+ - empty days render a real DOM empty state driven by `messages.empty`, never CSS-generated text;
129
+ - keyboard offers two comprehensible tab stops (day strip, then day slots) with roving tabindex in each zone;
130
+ - Left/Right/Home/End on the strip change `activeDate` without selecting a slot.
131
+
132
+ ## U10 — Bounded and responsive navigation
133
+
134
+ A consumer can bound the visible range, adapt the number of days to the
135
+ component's own width, and navigate by window, by reference date, or to the
136
+ next known availability. A wholly empty window is a distinct state.
137
+
138
+ Acceptance:
139
+
140
+ - `min`/`max` are hard bounds: `min <= start` and `end <= max`;
141
+ - when the requested `dayCount` does not fit the bounds, the resolved `visibleDayCount` shrinks instead of showing out-of-bounds days;
142
+ - `min > max` is an explicit invalid configuration: `range` is `{ start: "", end: "", dayCount: 0 }`, `data-invalid-range` is set, navigation is disabled, and the source is never called;
143
+ - `dayCount` is the consumer's maximum intention; `visibleDayCount` is the capacity resolved from bounds and the width available to the projection;
144
+ - responsive resolution is opt-in via `responsive`, uses `ResizeObserver`, and never reads `window.innerWidth`; it measures the projection width (inside the navigation rail), not the host width; `responsiveBreakpoints` is overridable;
145
+ - changing `visibleDayCount` keeps `activeDate` visible by shifting `start` just enough, within the bounds;
146
+ - `previous()`/`next()` move to the adjacent window of `visibleDayCount` days and never select;
147
+ - `goTo(date)` brings `date` into the window and consults it, respecting the bounds;
148
+ - `goHome()` returns to `homeDate`, a reference date that is never confused with `min`;
149
+ - `goToNextAvailability()` asks the source (`source.next`) or falls back to a `nextrequest` event; it may skip several windows and is never merged with `next()`; it returns the destination actually reached (clamped to `min`/`max`), never the raw source proposal;
150
+ - `configure()` validates all options before applying any: an invalid option throws and leaves the component unchanged;
151
+ - navigation controls are a symmetric `previous`/`next` pair framing the projection; they are the only range-navigation chrome;
152
+ - `goHome()` (opt-in via `home-date`) is a persistent capability but an auxiliary shortcut, kept outside the prev/next rail; it is offered only while `homeDate` is outside the visible range; its wrapper reserves its height whenever `home-date` is set, so the shortcut appearing later never shifts the content;
153
+ - there is no "go to end" action;
154
+ - `goToNextAvailability()` is a contextual action, never permanent chrome: it is surfaced in the range empty state when `next-availability` is set;
155
+ - a window with no slot, no notice and no closed day renders a range empty state (`.sp-range-empty` replacing the projection, with a `next()` action, plus the contextual availability action when `next-availability` is set);
156
+ - a window with a notice or a closed day but no slots keeps the normal projection, so the exception or the closure stays visible;
157
+ - resize changes the range, never `value`; event order is stable (`rangechange`, then `daychange`, then any reload);
158
+ - collapsed `columns` projection keeps a stable footprint derived from `max-visible-rows`: full, sparse and wholly empty ranges reserve the same height, so navigating between equivalent ranges never causes avoidable vertical layout shift;
159
+ - `expanded` content may grow naturally past that floor; in `layout="day"`, only the empty state gets a baseline.
160
+
161
+ ## U11 — Closed days / sparse availability
162
+
163
+ The source can distinguish a closed day from an open day with no availability,
164
+ without the component knowing any opening-hours rule.
165
+
166
+ Acceptance:
167
+
168
+ - a day carries `closed: true` (for example a weekend or a weekly closure); the component never computes opening hours, recurrence or "Monday only";
169
+ - the window still contains `visibleDayCount` consecutive civil days: closed days remain visible and are never compressed or hidden;
170
+ - three cases stay distinct: slots present; `slots: []` and open → `messages.empty`; `slots: []` and `closed` → `messages.closed`;
171
+ - a closed day is stylable through `[data-closed]` on the day, the panel and the day strip; `--sp-closed-opacity` is the default theming hook;
172
+ - `notice` stays reserved for exceptional information and may coexist with `closed`;
173
+ - a window whose only content is closed days keeps the normal projection instead of collapsing into the range empty state;
174
+ - `source.next()` / `goToNextAvailability()` let the application jump to the next real availability between windows; they never change the civil shape of a window;
175
+ - `closed-days="show"` (default) renders every civil day; `closed-days="hide"` removes closed days from the projection only;
176
+ - hiding a closed day never extends the civil range to compensate: `day-count` stays the civil count, and the rendered column count follows the projected days;
177
+ - hiding never alters `range`, `visibleDayCount`, `previous()`/`next()`, `goTo()` or `source.load()`, and toggling `closed-days` never reloads the source;
178
+ - in `layout="day"`, a closed day that is the consulted `activeDate` stays visible until another day is activated; data arrival never moves `activeDate`;
179
+ - a window whose civil days are all closed renders the range empty state with `messages.closedRange`, distinct from `messages.empty`;
180
+ - `closed: true` with bookable slots is invalid input and is rejected by normalization;
181
+ - no recurrence or opening-hours rule enters the core.
182
+
183
+ ## Not a use case for this package
184
+
185
+ Keep these outside the component:
186
+
187
+ - booking confirmation flows;
188
+ - login/signup;
189
+ - doctor/reason/security identifiers;
190
+ - "anti swiss cheese" booking rules;
191
+ - payment;
192
+ - resource scheduling;
193
+ - drag/drop;
194
+ - event rendering;
195
+ - recurrence;
196
+ - timezone conversion;
197
+ - tooltip/flyout positioning frameworks.
package/package.json ADDED
@@ -0,0 +1,148 @@
1
+ {
2
+ "name": "@lekoala/slot-picker",
3
+ "version": "0.1.0",
4
+ "description": "Native-first day and appointment slot picker primitive",
5
+ "type": "module",
6
+ "sideEffects": [
7
+ "./src/define.js",
8
+ "./src/slot-picker.css",
9
+ "./dist/slot-picker.js",
10
+ "./dist/slot-picker.min.js",
11
+ "./dist/slot-picker.css",
12
+ "./dist/slot-picker.min.css"
13
+ ],
14
+ "license": "MIT",
15
+ "customElements": "custom-elements.json",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/lekoala/slot-picker.git"
19
+ },
20
+ "homepage": "https://github.com/lekoala/slot-picker",
21
+ "bugs": "https://github.com/lekoala/slot-picker/issues",
22
+ "packageManager": "bun@1.4.2",
23
+ "keywords": [
24
+ "slot-picker",
25
+ "appointment",
26
+ "availability",
27
+ "web-component",
28
+ "accessibility"
29
+ ],
30
+ "files": [
31
+ "dist",
32
+ "src",
33
+ "docs",
34
+ "custom-elements.json",
35
+ "LICENSE",
36
+ "README.md"
37
+ ],
38
+ "exports": {
39
+ ".": {
40
+ "types": "./dist/types/index.d.ts",
41
+ "import": "./src/index.js"
42
+ },
43
+ "./define": {
44
+ "types": "./dist/types/define.d.ts",
45
+ "import": "./src/define.js"
46
+ },
47
+ "./locales/ar": {
48
+ "types": "./dist/types/locales/ar.d.ts",
49
+ "import": "./src/locales/ar.js"
50
+ },
51
+ "./locales/de": {
52
+ "types": "./dist/types/locales/de.d.ts",
53
+ "import": "./src/locales/de.js"
54
+ },
55
+ "./locales/en": {
56
+ "types": "./dist/types/locales/en.d.ts",
57
+ "import": "./src/locales/en.js"
58
+ },
59
+ "./locales/es": {
60
+ "types": "./dist/types/locales/es.d.ts",
61
+ "import": "./src/locales/es.js"
62
+ },
63
+ "./locales/fr": {
64
+ "types": "./dist/types/locales/fr.d.ts",
65
+ "import": "./src/locales/fr.js"
66
+ },
67
+ "./locales/hi": {
68
+ "types": "./dist/types/locales/hi.d.ts",
69
+ "import": "./src/locales/hi.js"
70
+ },
71
+ "./locales/id": {
72
+ "types": "./dist/types/locales/id.d.ts",
73
+ "import": "./src/locales/id.js"
74
+ },
75
+ "./locales/it": {
76
+ "types": "./dist/types/locales/it.d.ts",
77
+ "import": "./src/locales/it.js"
78
+ },
79
+ "./locales/ja": {
80
+ "types": "./dist/types/locales/ja.d.ts",
81
+ "import": "./src/locales/ja.js"
82
+ },
83
+ "./locales/ko": {
84
+ "types": "./dist/types/locales/ko.d.ts",
85
+ "import": "./src/locales/ko.js"
86
+ },
87
+ "./locales/nl": {
88
+ "types": "./dist/types/locales/nl.d.ts",
89
+ "import": "./src/locales/nl.js"
90
+ },
91
+ "./locales/pl": {
92
+ "types": "./dist/types/locales/pl.d.ts",
93
+ "import": "./src/locales/pl.js"
94
+ },
95
+ "./locales/pt-BR": {
96
+ "types": "./dist/types/locales/pt-BR.d.ts",
97
+ "import": "./src/locales/pt-BR.js"
98
+ },
99
+ "./locales/pt-PT": {
100
+ "types": "./dist/types/locales/pt-PT.d.ts",
101
+ "import": "./src/locales/pt-PT.js"
102
+ },
103
+ "./locales/ru": {
104
+ "types": "./dist/types/locales/ru.d.ts",
105
+ "import": "./src/locales/ru.js"
106
+ },
107
+ "./locales/tr": {
108
+ "types": "./dist/types/locales/tr.d.ts",
109
+ "import": "./src/locales/tr.js"
110
+ },
111
+ "./locales/zh-CN": {
112
+ "types": "./dist/types/locales/zh-CN.d.ts",
113
+ "import": "./src/locales/zh-CN.js"
114
+ },
115
+ "./slot-picker.css": "./src/slot-picker.css",
116
+ "./dist/slot-picker.js": "./dist/slot-picker.js",
117
+ "./dist/slot-picker.min.js": "./dist/slot-picker.min.js",
118
+ "./dist/slot-picker.css": "./dist/slot-picker.css",
119
+ "./dist/slot-picker.min.css": "./dist/slot-picker.min.css"
120
+ },
121
+ "publishConfig": {
122
+ "access": "public"
123
+ },
124
+ "scripts": {
125
+ "dev": "node test/server.js",
126
+ "build:bundle": "bun scripts/build.js",
127
+ "build:types": "bun run types",
128
+ "build": "bun run build:bundle && bun run build:types",
129
+ "sync": "bun run build",
130
+ "lint": "biome check .",
131
+ "format": "biome format --write .",
132
+ "typecheck": "tsc -p jsconfig.json",
133
+ "types": "tsc -p tsconfig.types.json",
134
+ "test": "bun test test/unit",
135
+ "test:syntax": "node --check src/date.js && node --check src/model.js && node --check src/source.js && node --check src/messages.js && node --check src/slot-picker.js && node --check src/index.js && node --check src/define.js && node --check src/views/shared.js && node --check src/views/columns.js && node --check src/views/day.js",
136
+ "test:browser": "bun run build:bundle && playwright test browser --project chromium",
137
+ "test:browser:all": "bun run build:bundle && playwright test browser",
138
+ "check": "bun run test:syntax && bun run lint && bun run typecheck && bun run test",
139
+ "verify": "bun run check && bun run sync",
140
+ "prepack": "bun run sync",
141
+ "prepublishOnly": "bun run verify"
142
+ },
143
+ "devDependencies": {
144
+ "@biomejs/biome": "2.5.13",
145
+ "@playwright/test": "1.63.0",
146
+ "typescript": "7.0.2"
147
+ }
148
+ }
package/src/date.js ADDED
@@ -0,0 +1,66 @@
1
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
2
+
3
+ /** @param {string} value */
4
+ export function isDateValue(value) {
5
+ if (!DATE_RE.test(value)) return false;
6
+ const [year, month, day] = value.split("-").map(Number);
7
+ const date = new Date(Date.UTC(year, month - 1, day));
8
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
9
+ }
10
+
11
+ /** @param {string} value */
12
+ function parts(value) {
13
+ if (!isDateValue(value)) throw new TypeError(`Invalid civil date: ${value}`);
14
+ const [year, month, day] = value.split("-").map(Number);
15
+ return { year, month, day };
16
+ }
17
+
18
+ /** @param {string} value @param {number} amount */
19
+ export function addDays(value, amount) {
20
+ const { year, month, day } = parts(value);
21
+ const date = new Date(Date.UTC(year, month - 1, day + amount));
22
+ return [
23
+ String(date.getUTCFullYear()).padStart(4, "0"),
24
+ String(date.getUTCMonth() + 1).padStart(2, "0"),
25
+ String(date.getUTCDate()).padStart(2, "0"),
26
+ ].join("-");
27
+ }
28
+
29
+ /** @param {string} a @param {string} b */
30
+ export function compareDates(a, b) {
31
+ return a.localeCompare(b);
32
+ }
33
+
34
+ /**
35
+ * Signed civil day difference (b - a).
36
+ * @param {string} a
37
+ * @param {string} b
38
+ */
39
+ export function daysBetween(a, b) {
40
+ return Math.round((toUtcDate(b).getTime() - toUtcDate(a).getTime()) / 86_400_000);
41
+ }
42
+
43
+ /**
44
+ * Inclusive visible civil range.
45
+ * @param {string} start
46
+ * @param {number} dayCount
47
+ */
48
+ export function rangeEnd(start, dayCount) {
49
+ return addDays(start, Math.max(1, dayCount) - 1);
50
+ }
51
+
52
+ /** @param {string} value */
53
+ export function toUtcDate(value) {
54
+ const { year, month, day } = parts(value);
55
+ return new Date(Date.UTC(year, month - 1, day));
56
+ }
57
+
58
+ /** Local civil "today" without timezone conversion of public values. */
59
+ export function todayValue() {
60
+ const now = new Date();
61
+ return [
62
+ String(now.getFullYear()).padStart(4, "0"),
63
+ String(now.getMonth() + 1).padStart(2, "0"),
64
+ String(now.getDate()).padStart(2, "0"),
65
+ ].join("-");
66
+ }
package/src/define.js ADDED
@@ -0,0 +1,5 @@
1
+ import { SlotPickerElement } from "./slot-picker.js";
2
+
3
+ if (!customElements.get("slot-picker")) {
4
+ customElements.define("slot-picker", SlotPickerElement);
5
+ }
package/src/index.js ADDED
@@ -0,0 +1,25 @@
1
+ export { addDays, compareDates, daysBetween, isDateValue, rangeEnd, todayValue } from "./date.js";
2
+ export { DEFAULT_MESSAGES, getDefaultMessages, resolveMessages, setDefaultMessages } from "./messages.js";
3
+ export {
4
+ boundedDayCount,
5
+ clampStart,
6
+ collapsedDays,
7
+ ensureVisible,
8
+ focusableSlots,
9
+ hasDayContent,
10
+ hasSlots,
11
+ isSlotValue,
12
+ isTimeValue,
13
+ isValidRange,
14
+ moveFocus,
15
+ normalizeBreakpoints,
16
+ normalizeDays,
17
+ RESPONSIVE_BREAKPOINTS,
18
+ rangeDetail,
19
+ resolveActiveDate,
20
+ resolveVisibleDayCount,
21
+ slotValue,
22
+ visibleDays,
23
+ } from "./model.js";
24
+ export { SlotPickerElement } from "./slot-picker.js";
25
+ export { SlotSourceController } from "./source.js";
@@ -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: "موعد واحد",
16
+ manySlots: "{n} مواعيد",
17
+ days: "الأيام",
18
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ previous: "Vorherige Tage",
3
+ next: "Nächste Tage",
4
+ home: "Zurück zum Anfang",
5
+ nextAvailability: "Nächste Verfügbarkeit",
6
+ showMore: "Mehr Verfügbarkeiten anzeigen",
7
+ showLess: "Weniger anzeigen",
8
+ loading: "Verfügbarkeiten werden geladen",
9
+ empty: "Keine Verfügbarkeit",
10
+ closed: "Geschlossen",
11
+ closedRange: "In diesem Zeitraum geschlossen",
12
+ rangeEmptyTitle: "Keine Verfügbarkeit in diesem Zeitraum",
13
+ rangeEmptyDescription: "Vom {start} bis {end}",
14
+ rangeEmptyNext: "Nächsten Zeitraum anzeigen",
15
+ oneSlot: "1 Termin",
16
+ manySlots: "{n} Termine",
17
+ days: "Tage",
18
+ };
@@ -0,0 +1,18 @@
1
+ export default {
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
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ previous: "Días anteriores",
3
+ next: "Días siguientes",
4
+ home: "Volver al inicio",
5
+ nextAvailability: "Próxima disponibilidad",
6
+ showMore: "Mostrar más disponibilidad",
7
+ showLess: "Mostrar menos",
8
+ loading: "Cargando disponibilidad",
9
+ empty: "Sin disponibilidad",
10
+ closed: "Cerrado",
11
+ closedRange: "Cerrado durante este período",
12
+ rangeEmptyTitle: "Sin disponibilidad en este período",
13
+ rangeEmptyDescription: "Del {start} al {end}",
14
+ rangeEmptyNext: "Ver el siguiente período",
15
+ oneSlot: "1 hueco",
16
+ manySlots: "{n} huecos",
17
+ days: "Días",
18
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ previous: "Jours précédents",
3
+ next: "Jours suivants",
4
+ home: "Retour au début",
5
+ nextAvailability: "Prochain créneau disponible",
6
+ showMore: "Voir plus de disponibilités",
7
+ showLess: "Voir moins",
8
+ loading: "Chargement des disponibilités",
9
+ empty: "Aucune disponibilité",
10
+ closed: "Fermé",
11
+ closedRange: "Fermé sur cette période",
12
+ rangeEmptyTitle: "Aucun créneau disponible sur cette période",
13
+ rangeEmptyDescription: "Du {start} au {end}",
14
+ rangeEmptyNext: "Voir la période suivante",
15
+ oneSlot: "1 créneau",
16
+ manySlots: "{n} créneaux",
17
+ days: "Jours",
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
+ };