@delightstack/components 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 (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/SKILL.md +149 -0
  4. package/bin/agents.js +63 -0
  5. package/dist/actions/Alert.svelte +202 -0
  6. package/dist/actions/Alert.svelte.d.ts +36 -0
  7. package/dist/actions/Alert.svelte.d.ts.map +1 -0
  8. package/dist/actions/Button.svelte +1450 -0
  9. package/dist/actions/Button.svelte.d.ts +56 -0
  10. package/dist/actions/Button.svelte.d.ts.map +1 -0
  11. package/dist/actions/ButtonGroup.svelte +111 -0
  12. package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
  13. package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
  14. package/dist/actions/CommandPalette.svelte +939 -0
  15. package/dist/actions/CommandPalette.svelte.d.ts +37 -0
  16. package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
  17. package/dist/actions/ContextMenu.svelte +138 -0
  18. package/dist/actions/ContextMenu.svelte.d.ts +54 -0
  19. package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
  20. package/dist/actions/Modal.svelte +474 -0
  21. package/dist/actions/Modal.svelte.d.ts +28 -0
  22. package/dist/actions/Modal.svelte.d.ts.map +1 -0
  23. package/dist/actions/Popover.svelte +1214 -0
  24. package/dist/actions/Popover.svelte.d.ts +31 -0
  25. package/dist/actions/Popover.svelte.d.ts.map +1 -0
  26. package/dist/actions/Portal.svelte +80 -0
  27. package/dist/actions/Portal.svelte.d.ts +17 -0
  28. package/dist/actions/Portal.svelte.d.ts.map +1 -0
  29. package/dist/actions/ThemeToggle.svelte +345 -0
  30. package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
  31. package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
  32. package/dist/actions/index.d.ts +13 -0
  33. package/dist/actions/index.d.ts.map +1 -0
  34. package/dist/actions/index.js +10 -0
  35. package/dist/actions/scrollbar.d.ts +48 -0
  36. package/dist/actions/scrollbar.d.ts.map +1 -0
  37. package/dist/actions/scrollbar.js +404 -0
  38. package/dist/display/Accordion.svelte +586 -0
  39. package/dist/display/Accordion.svelte.d.ts +41 -0
  40. package/dist/display/Accordion.svelte.d.ts.map +1 -0
  41. package/dist/display/Avatar.svelte +527 -0
  42. package/dist/display/Avatar.svelte.d.ts +22 -0
  43. package/dist/display/Avatar.svelte.d.ts.map +1 -0
  44. package/dist/display/AvatarGroup.svelte +298 -0
  45. package/dist/display/AvatarGroup.svelte.d.ts +31 -0
  46. package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
  47. package/dist/display/Calendar.svelte +1366 -0
  48. package/dist/display/Calendar.svelte.d.ts +58 -0
  49. package/dist/display/Calendar.svelte.d.ts.map +1 -0
  50. package/dist/display/Chart.svelte +1426 -0
  51. package/dist/display/Chart.svelte.d.ts +35 -0
  52. package/dist/display/Chart.svelte.d.ts.map +1 -0
  53. package/dist/display/Code.svelte +780 -0
  54. package/dist/display/Code.svelte.d.ts +19 -0
  55. package/dist/display/Code.svelte.d.ts.map +1 -0
  56. package/dist/display/Comparison.svelte +686 -0
  57. package/dist/display/Comparison.svelte.d.ts +22 -0
  58. package/dist/display/Comparison.svelte.d.ts.map +1 -0
  59. package/dist/display/Counter.svelte +285 -0
  60. package/dist/display/Counter.svelte.d.ts +21 -0
  61. package/dist/display/Counter.svelte.d.ts.map +1 -0
  62. package/dist/display/Expand.svelte +48 -0
  63. package/dist/display/Expand.svelte.d.ts +9 -0
  64. package/dist/display/Expand.svelte.d.ts.map +1 -0
  65. package/dist/display/List.svelte +294 -0
  66. package/dist/display/List.svelte.d.ts +40 -0
  67. package/dist/display/List.svelte.d.ts.map +1 -0
  68. package/dist/display/ListContextReset.svelte +19 -0
  69. package/dist/display/ListContextReset.svelte.d.ts +7 -0
  70. package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
  71. package/dist/display/ListItem.svelte +834 -0
  72. package/dist/display/ListItem.svelte.d.ts +22 -0
  73. package/dist/display/ListItem.svelte.d.ts.map +1 -0
  74. package/dist/display/QR.svelte +1193 -0
  75. package/dist/display/QR.svelte.d.ts +23 -0
  76. package/dist/display/QR.svelte.d.ts.map +1 -0
  77. package/dist/display/SplitPane.svelte +744 -0
  78. package/dist/display/SplitPane.svelte.d.ts +25 -0
  79. package/dist/display/SplitPane.svelte.d.ts.map +1 -0
  80. package/dist/display/Stat.svelte +439 -0
  81. package/dist/display/Stat.svelte.d.ts +24 -0
  82. package/dist/display/Stat.svelte.d.ts.map +1 -0
  83. package/dist/display/Table.svelte +4654 -0
  84. package/dist/display/Table.svelte.d.ts +249 -0
  85. package/dist/display/Table.svelte.d.ts.map +1 -0
  86. package/dist/display/TableCellEditor.svelte +935 -0
  87. package/dist/display/TableCellEditor.svelte.d.ts +58 -0
  88. package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
  89. package/dist/display/Timeline.svelte +1258 -0
  90. package/dist/display/Timeline.svelte.d.ts +43 -0
  91. package/dist/display/Timeline.svelte.d.ts.map +1 -0
  92. package/dist/display/Tree.svelte +1740 -0
  93. package/dist/display/Tree.svelte.d.ts +74 -0
  94. package/dist/display/Tree.svelte.d.ts.map +1 -0
  95. package/dist/display/Typewriter.svelte +338 -0
  96. package/dist/display/Typewriter.svelte.d.ts +22 -0
  97. package/dist/display/Typewriter.svelte.d.ts.map +1 -0
  98. package/dist/display/index.d.ts +24 -0
  99. package/dist/display/index.d.ts.map +1 -0
  100. package/dist/display/index.js +18 -0
  101. package/dist/feedback/Callout.svelte +529 -0
  102. package/dist/feedback/Callout.svelte.d.ts +24 -0
  103. package/dist/feedback/Callout.svelte.d.ts.map +1 -0
  104. package/dist/feedback/Confetti.svelte +631 -0
  105. package/dist/feedback/Confetti.svelte.d.ts +90 -0
  106. package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
  107. package/dist/feedback/Progress.svelte +382 -0
  108. package/dist/feedback/Progress.svelte.d.ts +25 -0
  109. package/dist/feedback/Progress.svelte.d.ts.map +1 -0
  110. package/dist/feedback/Toast.svelte +967 -0
  111. package/dist/feedback/Toast.svelte.d.ts +54 -0
  112. package/dist/feedback/Toast.svelte.d.ts.map +1 -0
  113. package/dist/feedback/index.d.ts +7 -0
  114. package/dist/feedback/index.d.ts.map +1 -0
  115. package/dist/feedback/index.js +4 -0
  116. package/dist/form/Checkbox.svelte +449 -0
  117. package/dist/form/Checkbox.svelte.d.ts +27 -0
  118. package/dist/form/Checkbox.svelte.d.ts.map +1 -0
  119. package/dist/form/Fieldset.svelte +410 -0
  120. package/dist/form/Fieldset.svelte.d.ts +22 -0
  121. package/dist/form/Fieldset.svelte.d.ts.map +1 -0
  122. package/dist/form/FileUpload.svelte +934 -0
  123. package/dist/form/FileUpload.svelte.d.ts +41 -0
  124. package/dist/form/FileUpload.svelte.d.ts.map +1 -0
  125. package/dist/form/Form.svelte +530 -0
  126. package/dist/form/Form.svelte.d.ts +120 -0
  127. package/dist/form/Form.svelte.d.ts.map +1 -0
  128. package/dist/form/Input.svelte +2858 -0
  129. package/dist/form/Input.svelte.d.ts +66 -0
  130. package/dist/form/Input.svelte.d.ts.map +1 -0
  131. package/dist/form/Radio.svelte +507 -0
  132. package/dist/form/Radio.svelte.d.ts +39 -0
  133. package/dist/form/Radio.svelte.d.ts.map +1 -0
  134. package/dist/form/Range.svelte +912 -0
  135. package/dist/form/Range.svelte.d.ts +33 -0
  136. package/dist/form/Range.svelte.d.ts.map +1 -0
  137. package/dist/form/Rating.svelte +429 -0
  138. package/dist/form/Rating.svelte.d.ts +28 -0
  139. package/dist/form/Rating.svelte.d.ts.map +1 -0
  140. package/dist/form/Select.svelte +1933 -0
  141. package/dist/form/Select.svelte.d.ts +54 -0
  142. package/dist/form/Select.svelte.d.ts.map +1 -0
  143. package/dist/form/Toggle.svelte +645 -0
  144. package/dist/form/Toggle.svelte.d.ts +50 -0
  145. package/dist/form/Toggle.svelte.d.ts.map +1 -0
  146. package/dist/form/index.d.ts +15 -0
  147. package/dist/form/index.d.ts.map +1 -0
  148. package/dist/form/index.js +10 -0
  149. package/dist/index.d.ts +7 -0
  150. package/dist/index.d.ts.map +1 -0
  151. package/dist/index.js +6 -0
  152. package/dist/layout/README.md +172 -0
  153. package/dist/media/Carousel.svelte +2424 -0
  154. package/dist/media/Carousel.svelte.d.ts +47 -0
  155. package/dist/media/Carousel.svelte.d.ts.map +1 -0
  156. package/dist/media/Gallery.svelte +2881 -0
  157. package/dist/media/Gallery.svelte.d.ts +82 -0
  158. package/dist/media/Gallery.svelte.d.ts.map +1 -0
  159. package/dist/media/Image.svelte +389 -0
  160. package/dist/media/Image.svelte.d.ts +33 -0
  161. package/dist/media/Image.svelte.d.ts.map +1 -0
  162. package/dist/media/PDF.svelte +1793 -0
  163. package/dist/media/PDF.svelte.d.ts +44 -0
  164. package/dist/media/PDF.svelte.d.ts.map +1 -0
  165. package/dist/media/Panorama.svelte +1391 -0
  166. package/dist/media/Panorama.svelte.d.ts +47 -0
  167. package/dist/media/Panorama.svelte.d.ts.map +1 -0
  168. package/dist/media/Video.svelte +2501 -0
  169. package/dist/media/Video.svelte.d.ts +58 -0
  170. package/dist/media/Video.svelte.d.ts.map +1 -0
  171. package/dist/media/carousel.d.ts +211 -0
  172. package/dist/media/carousel.d.ts.map +1 -0
  173. package/dist/media/carousel.js +408 -0
  174. package/dist/media/index.d.ts +11 -0
  175. package/dist/media/index.d.ts.map +1 -0
  176. package/dist/media/index.js +5 -0
  177. package/dist/navigation/BottomSheet.svelte +636 -0
  178. package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
  179. package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
  180. package/dist/navigation/Breadcrumbs.svelte +611 -0
  181. package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
  182. package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
  183. package/dist/navigation/Pagination.svelte +641 -0
  184. package/dist/navigation/Pagination.svelte.d.ts +27 -0
  185. package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
  186. package/dist/navigation/Steps.svelte +965 -0
  187. package/dist/navigation/Steps.svelte.d.ts +43 -0
  188. package/dist/navigation/Steps.svelte.d.ts.map +1 -0
  189. package/dist/navigation/Tabs.svelte +698 -0
  190. package/dist/navigation/Tabs.svelte.d.ts +41 -0
  191. package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
  192. package/dist/navigation/index.d.ts +8 -0
  193. package/dist/navigation/index.d.ts.map +1 -0
  194. package/dist/navigation/index.js +5 -0
  195. package/package.json +139 -0
@@ -0,0 +1,1366 @@
1
+ <script lang="ts" module>
2
+ export interface CalendarEvent {
3
+ /** Unique identifier for the event */
4
+ id: string;
5
+ /** The event title shown on the calendar */
6
+ title: string;
7
+ /** When the event starts */
8
+ start: Date;
9
+ /** When the event ends (omit for instantaneous/single-slot events) */
10
+ end?: Date;
11
+ /** Custom color for the event chip */
12
+ color?: string;
13
+ /** Whether the event spans the whole day (rendered in the all-day row) */
14
+ allDay?: boolean;
15
+ }
16
+
17
+ export interface MarkedDate {
18
+ /** The date to mark with an indicator dot */
19
+ date: Date;
20
+ /** Custom color for the indicator dot */
21
+ color?: string;
22
+ /** Accessible label / tooltip text describing the mark */
23
+ label?: string;
24
+ }
25
+ </script>
26
+
27
+ <script lang="ts">
28
+ import { ripple } from '@delightstack/utilities';
29
+ import { scrollbar } from '../actions/scrollbar';
30
+ const propId = $props.id();
31
+
32
+ let {
33
+ /** Selected date(s) */
34
+ value = $bindable(undefined) as Date | Date[] | [Date, Date] | undefined,
35
+
36
+ /** Selection mode */
37
+ mode = 'single' as 'single' | 'range' | 'multiple',
38
+
39
+ /** Currently displayed month */
40
+ month = $bindable(new Date()),
41
+
42
+ /** Minimum selectable date */
43
+ min = undefined as Date | undefined,
44
+
45
+ /** Maximum selectable date */
46
+ max = undefined as Date | undefined,
47
+
48
+ /** Disabled dates or predicate */
49
+ disabled = [] as Date[] | ((date: Date) => boolean),
50
+
51
+ /** Dates with colored markers */
52
+ marked = [] as MarkedDate[],
53
+
54
+ /** Events to display */
55
+ events = [] as CalendarEvent[],
56
+
57
+ /** First day of week (0=Sun, 1=Mon, ...) */
58
+ week_starts_on = 1 as 0 | 1 | 2 | 3 | 4 | 5 | 6,
59
+
60
+ /** BCP 47 locale string */
61
+ locale = undefined as string | undefined,
62
+
63
+ /** Show time slot picker */
64
+ show_time_slots = false,
65
+
66
+ /** Time slot interval in minutes */
67
+ time_slot_interval = 30,
68
+
69
+ /** Earliest time slot */
70
+ time_slot_min = '00:00',
71
+
72
+ /** Latest time slot */
73
+ time_slot_max = '23:59',
74
+
75
+ /** Compact spacing */
76
+ dense = false,
77
+
78
+ /** Relaxed spacing */
79
+ comfortable = false,
80
+
81
+ /** Fill the container with a subtle surface so it reads as a card.
82
+ * Transparent by default so the calendar composes onto any surface. */
83
+ filled = false,
84
+
85
+ /** Give the container a 1px outline + rounded corners (transparent fill).
86
+ * Visible rounded card edge without imposing a surface fill. */
87
+ outline = false,
88
+
89
+ /** Loading skeleton */
90
+ skeleton = false,
91
+
92
+ /** Element ID */
93
+ id = propId,
94
+
95
+ /** Additional CSS classes */
96
+ class: class_name = '',
97
+
98
+ /** Selection changed */
99
+ onselect = undefined as
100
+ | ((payload: { value: Date | Date[] | [Date, Date] }) => void)
101
+ | undefined,
102
+
103
+ /** Month navigated */
104
+ onmonthchange = undefined as ((payload: { month: Date }) => void) | undefined,
105
+
106
+ /** Time slot selected */
107
+ ontimeslotselect = undefined as
108
+ | ((payload: { time: string; date: Date }) => void)
109
+ | undefined,
110
+ } = $props();
111
+
112
+ /* ------------------------------------------------------------------ */
113
+ /* Date helpers (all comparisons strip time) */
114
+ /* ------------------------------------------------------------------ */
115
+
116
+ function toDateKey(d: Date): string {
117
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
118
+ }
119
+
120
+ function sameDay(a: Date, b: Date): boolean {
121
+ return (
122
+ a.getFullYear() === b.getFullYear() &&
123
+ a.getMonth() === b.getMonth() &&
124
+ a.getDate() === b.getDate()
125
+ );
126
+ }
127
+
128
+ function stripTime(d: Date): Date {
129
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate());
130
+ }
131
+
132
+ function addDays(d: Date, n: number): Date {
133
+ const r = new Date(d);
134
+ r.setDate(r.getDate() + n);
135
+ return r;
136
+ }
137
+
138
+ function isBefore(a: Date, b: Date): boolean {
139
+ return stripTime(a).getTime() < stripTime(b).getTime();
140
+ }
141
+
142
+ function isAfter(a: Date, b: Date): boolean {
143
+ return stripTime(a).getTime() > stripTime(b).getTime();
144
+ }
145
+
146
+ function isBetween(d: Date, start: Date, end: Date): boolean {
147
+ const t = stripTime(d).getTime();
148
+ const s = stripTime(start).getTime();
149
+ const e = stripTime(end).getTime();
150
+ const lo = Math.min(s, e);
151
+ const hi = Math.max(s, e);
152
+ return t >= lo && t <= hi;
153
+ }
154
+
155
+ /* ------------------------------------------------------------------ */
156
+ /* Locale-aware formatting */
157
+ /* ------------------------------------------------------------------ */
158
+
159
+ const month_year_formatter = $derived(
160
+ new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }),
161
+ );
162
+
163
+ const day_name_formatter = $derived(
164
+ new Intl.DateTimeFormat(locale, { weekday: 'short' }),
165
+ );
166
+
167
+ /* ------------------------------------------------------------------ */
168
+ /* Grid computation */
169
+ /* ------------------------------------------------------------------ */
170
+
171
+ const today = $derived(stripTime(new Date()));
172
+
173
+ const view_year = $derived(month.getFullYear());
174
+ const view_month = $derived(month.getMonth());
175
+
176
+ const header_label = $derived(
177
+ month_year_formatter.format(new Date(view_year, view_month, 1)),
178
+ );
179
+
180
+ /** Day-of-week headers respecting week_starts_on */
181
+ const weekday_headers = $derived.by(() => {
182
+ const headers: string[] = [];
183
+ // Use a known reference: Jan 4 2026 is a Sunday (day 0)
184
+ for (let i = 0; i < 7; i++) {
185
+ const day_index = (week_starts_on + i) % 7;
186
+ // Build a date that is the correct weekday
187
+ // Jan 4 2026 = Sunday. Add day_index to get desired weekday.
188
+ const ref = new Date(2026, 0, 4 + day_index);
189
+ headers.push(day_name_formatter.format(ref));
190
+ }
191
+ return headers;
192
+ });
193
+
194
+ interface CalendarDay {
195
+ date: Date;
196
+ key: string;
197
+ day_number: number;
198
+ is_current_month: boolean;
199
+ is_today: boolean;
200
+ is_disabled: boolean;
201
+ is_selected: boolean;
202
+ is_range_start: boolean;
203
+ is_range_end: boolean;
204
+ is_in_range: boolean;
205
+ is_range_hover: boolean;
206
+ markers: MarkedDate[];
207
+ day_events: CalendarEvent[];
208
+ }
209
+
210
+ /** Compute full 6-week grid of days */
211
+ const calendar_days = $derived.by(() => {
212
+ const first_of_month = new Date(view_year, view_month, 1);
213
+ const first_weekday = first_of_month.getDay(); // 0=Sun
214
+ // How many days to go back to reach the start of the grid
215
+ const offset = (first_weekday - week_starts_on + 7) % 7;
216
+ const grid_start = addDays(first_of_month, -offset);
217
+
218
+ const days: CalendarDay[] = [];
219
+ for (let i = 0; i < 42; i++) {
220
+ const date = addDays(grid_start, i);
221
+ const key = toDateKey(date);
222
+ const is_current_month =
223
+ date.getMonth() === view_month && date.getFullYear() === view_year;
224
+
225
+ days.push({
226
+ date,
227
+ key,
228
+ day_number: date.getDate(),
229
+ is_current_month,
230
+ is_today: sameDay(date, today),
231
+ is_disabled: isDateDisabled(date),
232
+ is_selected: isDateSelected(date),
233
+ is_range_start: isRangeStart(date),
234
+ is_range_end: isRangeEnd(date),
235
+ is_in_range: isInRange(date),
236
+ is_range_hover: isRangeHover(date),
237
+ markers: getMarkers(date),
238
+ day_events: getEvents(date),
239
+ });
240
+ }
241
+ return days;
242
+ });
243
+
244
+ /** Determine if a row (week) is fully outside the month -- trim to 5 or 6 rows */
245
+ const visible_days = $derived.by(() => {
246
+ const days = calendar_days;
247
+ // Check if last row (days 35-41) has any current-month days
248
+ const last_row = days.slice(35);
249
+ const has_current_month = last_row.some((d) => d.is_current_month);
250
+ return has_current_month ? days : days.slice(0, 35);
251
+ });
252
+
253
+ /* ------------------------------------------------------------------ */
254
+ /* Disabled check */
255
+ /* ------------------------------------------------------------------ */
256
+
257
+ function isDateDisabled(date: Date): boolean {
258
+ if (min && isBefore(date, min)) return true;
259
+ if (max && isAfter(date, max)) return true;
260
+ if (typeof disabled === 'function') return disabled(date);
261
+ if (Array.isArray(disabled)) {
262
+ return disabled.some((d) => sameDay(d, date));
263
+ }
264
+ return false;
265
+ }
266
+
267
+ /* ------------------------------------------------------------------ */
268
+ /* Selection state */
269
+ /* ------------------------------------------------------------------ */
270
+
271
+ function isDateSelected(date: Date): boolean {
272
+ if (!value) return false;
273
+ if (mode === 'single') {
274
+ return value instanceof Date && sameDay(value, date);
275
+ }
276
+ if (mode === 'multiple') {
277
+ return Array.isArray(value) && (value as Date[]).some((d) => sameDay(d, date));
278
+ }
279
+ if (mode === 'range') {
280
+ if (!Array.isArray(value)) return false;
281
+ const [start, end] = value as [Date, Date];
282
+ if (start && sameDay(start, date)) return true;
283
+ if (end && sameDay(end, date)) return true;
284
+ return false;
285
+ }
286
+ return false;
287
+ }
288
+
289
+ function isRangeStart(date: Date): boolean {
290
+ if (mode !== 'range' || !Array.isArray(value)) return false;
291
+ const [start] = value as [Date, Date | undefined];
292
+ return start ? sameDay(start, date) : false;
293
+ }
294
+
295
+ function isRangeEnd(date: Date): boolean {
296
+ if (mode !== 'range' || !Array.isArray(value)) return false;
297
+ const [, end] = value as [Date, Date | undefined];
298
+ return end ? sameDay(end, date) : false;
299
+ }
300
+
301
+ function isInRange(date: Date): boolean {
302
+ if (mode !== 'range' || !Array.isArray(value)) return false;
303
+ const [start, end] = value as [Date, Date | undefined];
304
+ if (!start || !end) return false;
305
+ return isBetween(date, start, end) && !sameDay(date, start) && !sameDay(date, end);
306
+ }
307
+
308
+ let hover_date = $state<Date | null>(null);
309
+
310
+ function isRangeHover(date: Date): boolean {
311
+ if (mode !== 'range') return false;
312
+ if (!Array.isArray(value)) return false;
313
+ const [start, end] = value as [Date, Date | undefined];
314
+ if (!start || end) return false;
315
+ if (!hover_date) return false;
316
+ return isBetween(date, start, hover_date) && !sameDay(date, start);
317
+ }
318
+
319
+ /* ------------------------------------------------------------------ */
320
+ /* Markers & events */
321
+ /* ------------------------------------------------------------------ */
322
+
323
+ function getMarkers(date: Date): MarkedDate[] {
324
+ return marked.filter((m) => sameDay(m.date, date));
325
+ }
326
+
327
+ function getEvents(date: Date): CalendarEvent[] {
328
+ return events.filter((e) => {
329
+ if (e.end) {
330
+ return (
331
+ isBetween(date, e.start, e.end) ||
332
+ sameDay(date, e.start) ||
333
+ sameDay(date, e.end)
334
+ );
335
+ }
336
+ return sameDay(e.start, date);
337
+ });
338
+ }
339
+
340
+ /* ------------------------------------------------------------------ */
341
+ /* Time slots */
342
+ /* ------------------------------------------------------------------ */
343
+
344
+ function parseTime(str: string): { hours: number; minutes: number } {
345
+ const [h, m] = str.split(':').map(Number);
346
+ return { hours: h, minutes: m };
347
+ }
348
+
349
+ const time_slots = $derived.by(() => {
350
+ if (!show_time_slots) return [];
351
+ const start = parseTime(time_slot_min);
352
+ const end = parseTime(time_slot_max);
353
+ const start_minutes = start.hours * 60 + start.minutes;
354
+ const end_minutes = end.hours * 60 + end.minutes;
355
+ const slots: string[] = [];
356
+ for (let m = start_minutes; m <= end_minutes; m += time_slot_interval) {
357
+ const h = Math.floor(m / 60);
358
+ const min = m % 60;
359
+ slots.push(`${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`);
360
+ }
361
+ return slots;
362
+ });
363
+
364
+ const time_formatter = $derived(
365
+ new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }),
366
+ );
367
+
368
+ function formatTimeSlot(slot: string): string {
369
+ const { hours, minutes } = parseTime(slot);
370
+ const d = new Date(2000, 0, 1, hours, minutes);
371
+ return time_formatter.format(d);
372
+ }
373
+
374
+ /* ------------------------------------------------------------------ */
375
+ /* Navigation */
376
+ /* ------------------------------------------------------------------ */
377
+
378
+ function navigateMonth(delta: number) {
379
+ const new_month = new Date(view_year, view_month + delta, 1);
380
+ month = new_month;
381
+ onmonthchange?.({ month: new_month });
382
+ }
383
+
384
+ /* ------------------------------------------------------------------ */
385
+ /* Selection */
386
+ /* ------------------------------------------------------------------ */
387
+
388
+ function selectDate(date: Date) {
389
+ if (isDateDisabled(date)) return;
390
+
391
+ const stripped = stripTime(date);
392
+
393
+ if (mode === 'single') {
394
+ value = stripped;
395
+ onselect?.({ value: stripped });
396
+ } else if (mode === 'multiple') {
397
+ const current = Array.isArray(value) ? [...(value as Date[])] : [];
398
+ const idx = current.findIndex((d) => sameDay(d, stripped));
399
+ if (idx >= 0) {
400
+ current.splice(idx, 1);
401
+ } else {
402
+ current.push(stripped);
403
+ }
404
+ value = current;
405
+ onselect?.({ value: current });
406
+ } else if (mode === 'range') {
407
+ if (
408
+ !Array.isArray(value) ||
409
+ ((value as [Date, Date | undefined]).length === 2 && (value as [Date, Date])[1])
410
+ ) {
411
+ // Start new range
412
+ value = [stripped] as unknown as [Date, Date];
413
+ onselect?.({ value: [stripped] as unknown as [Date, Date] });
414
+ } else {
415
+ const [start] = value as [Date];
416
+ const pair: [Date, Date] = isBefore(stripped, start)
417
+ ? [stripped, start]
418
+ : [start, stripped];
419
+ value = pair;
420
+ onselect?.({ value: pair });
421
+ }
422
+ }
423
+
424
+ // If date is in a different month, navigate to it
425
+ if (date.getMonth() !== view_month || date.getFullYear() !== view_year) {
426
+ const new_month = new Date(date.getFullYear(), date.getMonth(), 1);
427
+ month = new_month;
428
+ onmonthchange?.({ month: new_month });
429
+ }
430
+ }
431
+
432
+ let selected_slot = $state<string | null>(null);
433
+
434
+ function selectTimeSlot(slot: string) {
435
+ selected_slot = slot;
436
+ const selected_date = mode === 'single' && value instanceof Date ? value : today;
437
+ const { hours, minutes } = parseTime(slot);
438
+ const date_with_time = new Date(
439
+ selected_date.getFullYear(),
440
+ selected_date.getMonth(),
441
+ selected_date.getDate(),
442
+ hours,
443
+ minutes,
444
+ );
445
+ ontimeslotselect?.({ time: slot, date: date_with_time });
446
+ }
447
+
448
+ /* ------------------------------------------------------------------ */
449
+ /* Keyboard navigation */
450
+ /* ------------------------------------------------------------------ */
451
+
452
+ let focused_date = $state<Date | null>(null);
453
+
454
+ function ensureFocusedDate(): Date {
455
+ if (focused_date) return focused_date;
456
+ if (mode === 'single' && value instanceof Date) return stripTime(value);
457
+ if (mode === 'range' && Array.isArray(value) && value.length > 0)
458
+ return stripTime((value as Date[])[0]);
459
+ if (mode === 'multiple' && Array.isArray(value) && value.length > 0)
460
+ return stripTime((value as Date[])[0]);
461
+ // Default to today if it's in the current month, otherwise first of month
462
+ if (today.getMonth() === view_month && today.getFullYear() === view_year)
463
+ return today;
464
+ return new Date(view_year, view_month, 1);
465
+ }
466
+
467
+ function focusCell(date: Date) {
468
+ focused_date = stripTime(date);
469
+ // Navigate month if needed
470
+ if (date.getMonth() !== view_month || date.getFullYear() !== view_year) {
471
+ const new_month = new Date(date.getFullYear(), date.getMonth(), 1);
472
+ month = new_month;
473
+ onmonthchange?.({ month: new_month });
474
+ }
475
+ // Focus the DOM element after update
476
+ requestAnimationFrame(() => {
477
+ const key = toDateKey(date);
478
+ const el = document.querySelector(
479
+ `[data-calendar-id="${id}"] [data-date="${key}"]`,
480
+ ) as HTMLElement | null;
481
+ el?.focus();
482
+ });
483
+ }
484
+
485
+ function handleGridKeyDown(e: KeyboardEvent) {
486
+ const current = ensureFocusedDate();
487
+ let next: Date | null = null;
488
+
489
+ switch (e.key) {
490
+ case 'ArrowLeft':
491
+ e.preventDefault();
492
+ next = addDays(current, -1);
493
+ break;
494
+ case 'ArrowRight':
495
+ e.preventDefault();
496
+ next = addDays(current, 1);
497
+ break;
498
+ case 'ArrowUp':
499
+ e.preventDefault();
500
+ next = addDays(current, -7);
501
+ break;
502
+ case 'ArrowDown':
503
+ e.preventDefault();
504
+ next = addDays(current, 7);
505
+ break;
506
+ case 'PageUp':
507
+ e.preventDefault();
508
+ if (e.shiftKey) {
509
+ next = new Date(
510
+ current.getFullYear() - 1,
511
+ current.getMonth(),
512
+ current.getDate(),
513
+ );
514
+ } else {
515
+ next = new Date(
516
+ current.getFullYear(),
517
+ current.getMonth() - 1,
518
+ current.getDate(),
519
+ );
520
+ }
521
+ break;
522
+ case 'PageDown':
523
+ e.preventDefault();
524
+ if (e.shiftKey) {
525
+ next = new Date(
526
+ current.getFullYear() + 1,
527
+ current.getMonth(),
528
+ current.getDate(),
529
+ );
530
+ } else {
531
+ next = new Date(
532
+ current.getFullYear(),
533
+ current.getMonth() + 1,
534
+ current.getDate(),
535
+ );
536
+ }
537
+ break;
538
+ case 'Home':
539
+ e.preventDefault();
540
+ {
541
+ const day_of_week = current.getDay();
542
+ const diff = (day_of_week - week_starts_on + 7) % 7;
543
+ next = addDays(current, -diff);
544
+ }
545
+ break;
546
+ case 'End':
547
+ e.preventDefault();
548
+ {
549
+ const day_of_week = current.getDay();
550
+ const diff = (day_of_week - week_starts_on + 7) % 7;
551
+ next = addDays(current, 6 - diff);
552
+ }
553
+ break;
554
+ case 'Enter':
555
+ case ' ':
556
+ e.preventDefault();
557
+ selectDate(current);
558
+ return;
559
+ default:
560
+ return;
561
+ }
562
+
563
+ if (next) {
564
+ focusCell(next);
565
+ }
566
+ }
567
+
568
+ function handleDayFocus(date: Date) {
569
+ focused_date = stripTime(date);
570
+ }
571
+
572
+ function handleDayHover(date: Date) {
573
+ hover_date = stripTime(date);
574
+ }
575
+
576
+ function handleGridMouseLeave() {
577
+ hover_date = null;
578
+ }
579
+ </script>
580
+
581
+ <!-- Skeleton and live calendar share one DOM structure so the loading state
582
+ has the exact size/shape/layout of the real calendar (no content shift on
583
+ swap) — only the leaf content (numbers/words) becomes a shimmer. -->
584
+ <div
585
+ class={['calendar', class_name].filter(Boolean).join(' ')}
586
+ class:dense
587
+ class:comfortable
588
+ class:filled
589
+ class:outline
590
+ class:skeleton
591
+ class:has-time-slots={show_time_slots}
592
+ {id}
593
+ data-calendar-id={id}
594
+ role="group"
595
+ aria-label="Calendar"
596
+ aria-busy={skeleton || undefined}
597
+ aria-hidden={skeleton || undefined}>
598
+ <div class="main">
599
+ <!-- Header -->
600
+ <div class="header">
601
+ <button
602
+ type="button"
603
+ class="nav"
604
+ aria-label="Previous month"
605
+ disabled={skeleton}
606
+ onclick={skeleton ? undefined : () => navigateMonth(-1)}
607
+ {@attach ripple({ enabled: !skeleton, zIndex: 1 })}>
608
+ {#if skeleton}
609
+ <span class="skeleton-fill skeleton-nav"></span>
610
+ {:else}
611
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
612
+ <path
613
+ d="M10 3L5 8L10 13"
614
+ stroke="currentColor"
615
+ stroke-width="1.5"
616
+ stroke-linecap="round"
617
+ stroke-linejoin="round" />
618
+ </svg>
619
+ {/if}
620
+ </button>
621
+ <!-- The real label stays in the DOM (rendered transparent under the
622
+ shimmer) so the skeleton keeps the live calendar's exact metrics. -->
623
+ <div class="title" aria-live="polite">
624
+ {header_label}
625
+ {#if skeleton}
626
+ <span class="skeleton-fill skeleton-title"></span>
627
+ {/if}
628
+ </div>
629
+ <button
630
+ type="button"
631
+ class="nav"
632
+ aria-label="Next month"
633
+ disabled={skeleton}
634
+ onclick={skeleton ? undefined : () => navigateMonth(1)}
635
+ {@attach ripple({ enabled: !skeleton, zIndex: 1 })}>
636
+ {#if skeleton}
637
+ <span class="skeleton-fill skeleton-nav"></span>
638
+ {:else}
639
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
640
+ <path
641
+ d="M6 3L11 8L6 13"
642
+ stroke="currentColor"
643
+ stroke-width="1.5"
644
+ stroke-linecap="round"
645
+ stroke-linejoin="round" />
646
+ </svg>
647
+ {/if}
648
+ </button>
649
+ </div>
650
+
651
+ <!-- Weekday headers -->
652
+ <div class="weekdays" role="row">
653
+ {#each weekday_headers as header}
654
+ <div class="weekday" role="columnheader" aria-label={header}>
655
+ {header}
656
+ {#if skeleton}
657
+ <span class="skeleton-fill skeleton-weekday"></span>
658
+ {/if}
659
+ </div>
660
+ {/each}
661
+ </div>
662
+
663
+ <!-- Day grid -->
664
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
665
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
666
+ <div
667
+ class="grid"
668
+ role="grid"
669
+ tabindex={skeleton ? -1 : 0}
670
+ aria-label="Calendar dates"
671
+ onkeydown={skeleton ? undefined : handleGridKeyDown}
672
+ onmouseleave={skeleton ? undefined : handleGridMouseLeave}>
673
+ {#each visible_days as day, i (day.key)}
674
+ {@const has_dots = day.markers.length > 0 || day.day_events.length > 0}
675
+ {@const dot_items = [
676
+ ...day.markers
677
+ .slice(0, 3)
678
+ .map((m) => m.color || 'var(--color-action, #3b82f6)'),
679
+ ...day.day_events
680
+ .slice(0, Math.max(0, 3 - day.markers.length))
681
+ .map((e) => e.color || 'var(--color-action, #3b82f6)'),
682
+ ].slice(0, 3)}
683
+ {@const marker_labels = day.markers
684
+ .filter((m) => m.label)
685
+ .map((m) => m.label)
686
+ .join(', ')}
687
+ {@const event_labels = day.day_events.map((e) => e.title).join(', ')}
688
+ {@const aria_desc_parts = [marker_labels, event_labels]
689
+ .filter(Boolean)
690
+ .join('; ')}
691
+ <button
692
+ type="button"
693
+ class="day"
694
+ class:other-month={!day.is_current_month}
695
+ class:today={!skeleton && day.is_today}
696
+ class:selected={!skeleton && day.is_selected}
697
+ class:range-start={!skeleton && day.is_range_start}
698
+ class:range-end={!skeleton && day.is_range_end}
699
+ class:in-range={!skeleton && day.is_in_range}
700
+ class:range-hover={!skeleton && day.is_range_hover}
701
+ class:disabled={!skeleton && day.is_disabled}
702
+ role="gridcell"
703
+ aria-selected={!skeleton && day.is_selected}
704
+ aria-disabled={skeleton || day.is_disabled}
705
+ aria-label={`${day.date.getDate()}${day.is_today ? ', today' : ''}${aria_desc_parts ? `, ${aria_desc_parts}` : ''}`}
706
+ tabindex={skeleton
707
+ ? -1
708
+ : focused_date
709
+ ? sameDay(day.date, focused_date)
710
+ ? 0
711
+ : -1
712
+ : day.is_today && day.is_current_month
713
+ ? 0
714
+ : -1}
715
+ data-date={day.key}
716
+ disabled={skeleton || day.is_disabled}
717
+ onclick={skeleton ? undefined : () => selectDate(day.date)}
718
+ onfocus={skeleton ? undefined : () => handleDayFocus(day.date)}
719
+ onmouseenter={skeleton ? undefined : () => handleDayHover(day.date)}
720
+ {@attach ripple({ enabled: !skeleton && !day.is_disabled, zIndex: 1 })}>
721
+ <!-- Number stays in the DOM (transparent under the disc when
722
+ skeleton) so cell metrics are identical across the swap. -->
723
+ <span class="number">{day.day_number}</span>
724
+ {#if skeleton}
725
+ <span class="skeleton-fill skeleton-day" style:--shimmer-delay="{i * 25}ms">
726
+ </span>
727
+ {:else if has_dots}
728
+ <div class="dots">
729
+ {#each dot_items as color}
730
+ <span class="dot" style:background={color}></span>
731
+ {/each}
732
+ </div>
733
+ {/if}
734
+ </button>
735
+ {/each}
736
+ </div>
737
+ </div>
738
+
739
+ <!-- Time slots panel -->
740
+ {#if show_time_slots}
741
+ <div class="slots" role="listbox" aria-label="Time slots" {@attach scrollbar()}>
742
+ {#if skeleton}
743
+ {#each { length: 8 } as _, i}
744
+ <div class="slot" aria-hidden="true">
745
+ <span class="skeleton-fill skeleton-slot" style:--shimmer-delay="{i * 40}ms">
746
+ </span>
747
+ </div>
748
+ {/each}
749
+ {:else}
750
+ {#each time_slots as slot}
751
+ <button
752
+ type="button"
753
+ class="slot"
754
+ class:selected={selected_slot === slot}
755
+ role="option"
756
+ aria-selected={selected_slot === slot}
757
+ onclick={() => selectTimeSlot(slot)}
758
+ {@attach ripple({ zIndex: 1 })}>
759
+ {formatTimeSlot(slot)}
760
+ </button>
761
+ {/each}
762
+ {/if}
763
+ </div>
764
+ {/if}
765
+ </div>
766
+
767
+ <style>
768
+ /* ========== Container ========== */
769
+ .calendar {
770
+ display: inline-flex;
771
+ font-family: inherit;
772
+ color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
773
+ user-select: none;
774
+ -webkit-tap-highlight-color: transparent;
775
+ /* Generous, concentric corners (the inner padding + cell radius nests
776
+ neatly inside). Transparent by default so the calendar composes onto
777
+ any surface; `filled`/`outline` give it a card edge. */
778
+ /* Clamp so an over-rounded radius token can't blob the calendar — see
779
+ --radius-cap. Variants just reassign --_radius; the base border-radius
780
+ + squircle block below pick it up. */
781
+ --_radius: min(var(--radius-xl, 20px), var(--radius-cap, 40px));
782
+ border-radius: var(--_radius);
783
+ @supports (corner-shape: squircle) {
784
+ corner-shape: squircle;
785
+ border-radius: calc(var(--_radius) * var(--squircle-ratio, 2));
786
+ }
787
+ background: transparent;
788
+
789
+ &.dense {
790
+ --_radius: min(var(--radius-lg, 10px), var(--radius-cap, 40px));
791
+ }
792
+
793
+ &.comfortable {
794
+ --_radius: min(var(--radius-2xl, 30px), var(--radius-cap, 40px));
795
+ }
796
+
797
+ &.filled {
798
+ background: var(--color-bg-active);
799
+ }
800
+
801
+ &.outline {
802
+ border: 1px solid var(--color-border);
803
+ }
804
+
805
+ /* Clip the side-by-side panels + their divider to the rounded corners. */
806
+ &.has-time-slots {
807
+ overflow: hidden;
808
+ }
809
+ }
810
+
811
+ .main {
812
+ display: flex;
813
+ flex-direction: column;
814
+ }
815
+
816
+ /* ========== Header ========== */
817
+ .header {
818
+ display: flex;
819
+ align-items: center;
820
+ justify-content: space-between;
821
+ padding: 0.75rem;
822
+ gap: 0.5rem;
823
+
824
+ .dense & {
825
+ padding: 0.375rem 0.5rem;
826
+ }
827
+
828
+ .comfortable & {
829
+ padding: 1rem 1.25rem;
830
+ }
831
+ }
832
+
833
+ .title {
834
+ flex: 1;
835
+ text-align: center;
836
+ font-weight: 600;
837
+ font-size: 0.9375rem;
838
+ white-space: nowrap;
839
+
840
+ .dense & {
841
+ font-size: 0.8125rem;
842
+ }
843
+
844
+ .comfortable & {
845
+ font-size: 1.0625rem;
846
+ }
847
+ }
848
+
849
+ .nav {
850
+ display: inline-flex;
851
+ align-items: center;
852
+ justify-content: center;
853
+ width: 2rem;
854
+ height: 2rem;
855
+ border: none;
856
+ background: transparent;
857
+ border-radius: var(--radius-md, 0.25rem);
858
+ @supports (corner-shape: squircle) {
859
+ corner-shape: squircle;
860
+ border-radius: calc(var(--radius-md, 0.25rem) * var(--squircle-ratio, 2));
861
+ }
862
+ cursor: pointer;
863
+ color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
864
+ flex-shrink: 0;
865
+ padding: 0;
866
+ position: relative;
867
+ overflow: hidden;
868
+ transition:
869
+ background 120ms ease,
870
+ transform 200ms ease;
871
+
872
+ &:hover {
873
+ background: light-dark(
874
+ rgb(from var(--color-text, #000) r g b / 0.06),
875
+ rgb(from var(--color-text, #fff) r g b / 0.08)
876
+ );
877
+ transition: transform 200ms ease;
878
+ }
879
+ /* Per-button perspective so the press recedes toward this button's own
880
+ * center, not the calendar's center. */
881
+ &:active {
882
+ transform: perspective(20px)
883
+ translate3d(0px, 1px, clamp(-10px, calc(0.2em - 12px), -2px));
884
+ }
885
+
886
+ &:focus-visible {
887
+ outline: 2px solid var(--color-action, #3b82f6);
888
+ outline-offset: -2px;
889
+ }
890
+
891
+ .dense & {
892
+ width: 1.5rem;
893
+ height: 1.5rem;
894
+ }
895
+
896
+ .comfortable & {
897
+ width: 2.25rem;
898
+ height: 2.25rem;
899
+ }
900
+ }
901
+
902
+ /* ========== Weekday Headers ========== */
903
+ .weekdays {
904
+ display: grid;
905
+ grid-template-columns: repeat(7, 1fr);
906
+ gap: 2px;
907
+ padding: 0 0.75rem;
908
+
909
+ .dense & {
910
+ padding: 0 0.5rem;
911
+ }
912
+
913
+ .comfortable & {
914
+ padding: 0 1.25rem;
915
+ }
916
+ }
917
+
918
+ .weekday {
919
+ text-align: center;
920
+ font-size: 0.6875rem;
921
+ font-weight: 500;
922
+ text-transform: uppercase;
923
+ letter-spacing: 0.04em;
924
+ padding: 0.25rem 0;
925
+ color: light-dark(var(--color-text-muted, #6b7280), var(--color-text-muted, #9ca3af));
926
+
927
+ .dense & {
928
+ font-size: 0.625rem;
929
+ padding: 0.125rem 0;
930
+ }
931
+
932
+ .comfortable & {
933
+ font-size: 0.75rem;
934
+ padding: 0.375rem 0;
935
+ }
936
+ }
937
+
938
+ /* ========== Day Grid ========== */
939
+ .grid {
940
+ display: grid;
941
+ grid-template-columns: repeat(7, 1fr);
942
+ gap: 2px;
943
+ padding: 0.375rem 0.75rem 0.75rem;
944
+
945
+ .dense & {
946
+ padding: 0.25rem 0.5rem 0.5rem;
947
+ }
948
+
949
+ .comfortable & {
950
+ padding: 0.5rem 1.25rem 1.25rem;
951
+ }
952
+ }
953
+
954
+ /* ========== Day Cell ========== */
955
+ .day {
956
+ position: relative;
957
+ display: flex;
958
+ flex-direction: column;
959
+ align-items: center;
960
+ justify-content: center;
961
+ aspect-ratio: 1;
962
+ border: none;
963
+ background: transparent;
964
+ border-radius: var(--radius-md, 0.25rem);
965
+ @supports (corner-shape: squircle) {
966
+ corner-shape: squircle;
967
+ border-radius: calc(var(--radius-md, 0.25rem) * var(--squircle-ratio, 2));
968
+ }
969
+ cursor: pointer;
970
+ padding: 0;
971
+ font-size: 0.8125rem;
972
+ font-family: inherit;
973
+ font-variant-numeric: tabular-nums;
974
+ color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
975
+ overflow: hidden;
976
+ transition:
977
+ background 100ms ease,
978
+ color 100ms ease,
979
+ transform 200ms ease;
980
+ outline: none;
981
+
982
+ &:hover:not(.disabled) {
983
+ background: light-dark(
984
+ rgb(from var(--color-text, #000) r g b / 0.06),
985
+ rgb(from var(--color-text, #fff) r g b / 0.08)
986
+ );
987
+ transition: transform 200ms ease;
988
+ }
989
+ /* Per-button perspective so the press recedes toward this cell's own
990
+ * center, not the grid's center. */
991
+ &:active:not(.disabled) {
992
+ transform: perspective(100px)
993
+ translate3d(0px, 1px, clamp(-10px, calc(0.2em - 12px), -2px));
994
+ }
995
+
996
+ &:focus-visible {
997
+ box-shadow: inset 0 0 0 2px var(--color-action, #3b82f6);
998
+ z-index: 1;
999
+ }
1000
+
1001
+ .dense & {
1002
+ font-size: 0.75rem;
1003
+ }
1004
+
1005
+ .comfortable & {
1006
+ font-size: 0.875rem;
1007
+ }
1008
+
1009
+ /* Other month */
1010
+ &.other-month {
1011
+ color: light-dark(
1012
+ var(--color-text-muted, #6b7280),
1013
+ var(--color-text-muted, #9ca3af)
1014
+ );
1015
+ opacity: 0.4;
1016
+ }
1017
+
1018
+ /* Today ring */
1019
+ &.today {
1020
+ box-shadow: inset 0 0 0 1.5px
1021
+ light-dark(var(--color-border, #d1d5db), var(--color-border, #4b5563));
1022
+ }
1023
+
1024
+ /* Selected */
1025
+ &.selected {
1026
+ background: var(--color-action, #3b82f6);
1027
+ color: var(--color-action-text, #fff);
1028
+
1029
+ &:hover:not(.disabled) {
1030
+ background: var(--color-action, #3b82f6);
1031
+ filter: brightness(1.1);
1032
+ transition: none;
1033
+ }
1034
+
1035
+ &.today {
1036
+ box-shadow: none;
1037
+ }
1038
+ }
1039
+
1040
+ /* Range start/end */
1041
+ &.range-start {
1042
+ border-radius: var(--radius-md, 0.25rem) 0 0 var(--radius-md, 0.25rem);
1043
+ @supports (corner-shape: squircle) {
1044
+ corner-shape: squircle;
1045
+ border-radius: calc(var(--radius-md, 0.25rem) * var(--squircle-ratio, 2)) 0 0
1046
+ calc(var(--radius-md, 0.25rem) * var(--squircle-ratio, 2));
1047
+ }
1048
+ background: var(--color-action, #3b82f6);
1049
+ color: var(--color-action-text, #fff);
1050
+ }
1051
+
1052
+ &.range-end {
1053
+ border-radius: 0 var(--radius-md, 0.25rem) var(--radius-md, 0.25rem) 0;
1054
+ @supports (corner-shape: squircle) {
1055
+ corner-shape: squircle;
1056
+ border-radius: 0 calc(var(--radius-md, 0.25rem) * var(--squircle-ratio, 2))
1057
+ calc(var(--radius-md, 0.25rem) * var(--squircle-ratio, 2)) 0;
1058
+ }
1059
+ background: var(--color-action, #3b82f6);
1060
+ color: var(--color-action-text, #fff);
1061
+ }
1062
+
1063
+ &.range-start.range-end {
1064
+ border-radius: var(--radius-md, 0.25rem);
1065
+ @supports (corner-shape: squircle) {
1066
+ corner-shape: squircle;
1067
+ border-radius: calc(var(--radius-md, 0.25rem) * var(--squircle-ratio, 2));
1068
+ }
1069
+ }
1070
+
1071
+ /* In-range fill */
1072
+ &.in-range {
1073
+ background: light-dark(
1074
+ rgb(from var(--color-action, #3b82f6) r g b / 0.12),
1075
+ rgb(from var(--color-action, #3b82f6) r g b / 0.2)
1076
+ );
1077
+ border-radius: 0;
1078
+ }
1079
+
1080
+ /* Range hover preview */
1081
+ &.range-hover {
1082
+ background: light-dark(
1083
+ rgb(from var(--color-action, #3b82f6) r g b / 0.08),
1084
+ rgb(from var(--color-action, #3b82f6) r g b / 0.14)
1085
+ );
1086
+ border-radius: 0;
1087
+ }
1088
+
1089
+ /* Disabled */
1090
+ &.disabled {
1091
+ opacity: 0.3;
1092
+ cursor: not-allowed;
1093
+ }
1094
+ }
1095
+
1096
+ /* ========== Day number ========== */
1097
+ .number {
1098
+ line-height: 1;
1099
+ }
1100
+
1101
+ /* ========== Dots (markers & events) ========== */
1102
+ .dots {
1103
+ display: flex;
1104
+ gap: 2px;
1105
+ position: absolute;
1106
+ bottom: 3px;
1107
+ left: 50%;
1108
+ transform: translateX(-50%);
1109
+
1110
+ .dense & {
1111
+ bottom: 1px;
1112
+ }
1113
+
1114
+ .comfortable & {
1115
+ bottom: 5px;
1116
+ }
1117
+ }
1118
+
1119
+ .dot {
1120
+ width: 4px;
1121
+ height: 4px;
1122
+ border-radius: 50%;
1123
+ flex-shrink: 0;
1124
+
1125
+ .dense & {
1126
+ width: 3px;
1127
+ height: 3px;
1128
+ }
1129
+ }
1130
+
1131
+ /* ========== Time Slots ========== */
1132
+ .slots {
1133
+ display: flex;
1134
+ flex-direction: column;
1135
+ /* Hairline divider from the day grid; both panels stay transparent so the
1136
+ container's fill (when `filled`) shows through evenly. */
1137
+ border-left: 1px solid var(--color-border);
1138
+ overflow-y: auto;
1139
+ overscroll-behavior: contain;
1140
+ max-height: 320px;
1141
+ min-width: 6rem;
1142
+ padding: 0.5rem;
1143
+ gap: 3px;
1144
+
1145
+ .dense & {
1146
+ min-width: 5rem;
1147
+ max-height: 260px;
1148
+ padding: 0.375rem;
1149
+ gap: 2px;
1150
+ }
1151
+
1152
+ .comfortable & {
1153
+ min-width: 7rem;
1154
+ max-height: 400px;
1155
+ padding: 0.625rem;
1156
+ gap: 4px;
1157
+ }
1158
+ }
1159
+
1160
+ .slot {
1161
+ display: flex;
1162
+ align-items: center;
1163
+ justify-content: center;
1164
+ padding: 0.5rem 0.75rem;
1165
+ font-size: 0.8125rem;
1166
+ font-weight: 500;
1167
+ font-family: inherit;
1168
+ font-variant-numeric: tabular-nums;
1169
+ border: 1px solid transparent;
1170
+ background: transparent;
1171
+ border-radius: var(--radius-md, 5px);
1172
+ @supports (corner-shape: squircle) {
1173
+ corner-shape: squircle;
1174
+ border-radius: calc(var(--radius-md, 5px) * var(--squircle-ratio, 2));
1175
+ }
1176
+ cursor: pointer;
1177
+ color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
1178
+ white-space: nowrap;
1179
+ position: relative;
1180
+ overflow: hidden;
1181
+ flex-shrink: 0;
1182
+ /* OUT transition — ease colors back to rest on leave (per the snap-in,
1183
+ ease-out hover convention). */
1184
+ transition:
1185
+ background 200ms ease,
1186
+ border-color 200ms ease,
1187
+ color 200ms ease,
1188
+ transform 200ms ease;
1189
+
1190
+ &:hover:not(.selected) {
1191
+ background: light-dark(
1192
+ rgb(from var(--color-text, #000) r g b / 0.06),
1193
+ rgb(from var(--color-text, #fff) r g b / 0.08)
1194
+ );
1195
+ border-color: var(--color-border);
1196
+ /* IN transition — omit the colors so they snap in; keep the press. */
1197
+ transition: transform 200ms ease;
1198
+ }
1199
+ /* Per-button perspective so the press recedes toward this slot's own
1200
+ * center, not the column's center. */
1201
+ &:active {
1202
+ transform: perspective(100px)
1203
+ translate3d(0px, 1px, clamp(-10px, calc(0.2em - 12px), -2px));
1204
+ }
1205
+
1206
+ &:focus-visible {
1207
+ outline: 2px solid var(--color-action, #3b82f6);
1208
+ outline-offset: -2px;
1209
+ }
1210
+
1211
+ /* Picked slot — solid action fill, like a selected day. */
1212
+ &.selected {
1213
+ background: var(--color-action, #3b82f6);
1214
+ border-color: var(--color-action, #3b82f6);
1215
+ color: var(--color-action-text, #fff);
1216
+ font-weight: 600;
1217
+
1218
+ &:hover {
1219
+ filter: brightness(1.08);
1220
+ }
1221
+ }
1222
+
1223
+ .dense & {
1224
+ padding: 0.375rem 0.5rem;
1225
+ font-size: 0.75rem;
1226
+ }
1227
+
1228
+ .comfortable & {
1229
+ padding: 0.625rem 0.875rem;
1230
+ font-size: 0.875rem;
1231
+ }
1232
+ }
1233
+
1234
+ /* ========== Skeleton ========== */
1235
+ /* The skeleton renders the *same* markup as the live calendar (same header,
1236
+ weekday row, 7-col grid, time-slot column) — only the leaf content swaps
1237
+ to a shimmer. So the placeholder element just sits inside the real layout
1238
+ slot it stands in for; that's what guarantees no content shift on swap. */
1239
+ .calendar.skeleton {
1240
+ pointer-events: none;
1241
+
1242
+ /* Hide the real leaf text but keep it in the box so it still drives the
1243
+ intrinsic width/height the live calendar will use — the shimmer overlays
1244
+ it absolutely. This is what eliminates the skeleton→live content shift. */
1245
+ .title,
1246
+ .weekday,
1247
+ .number {
1248
+ color: transparent;
1249
+ }
1250
+
1251
+ .title,
1252
+ .weekday {
1253
+ position: relative;
1254
+ }
1255
+ }
1256
+
1257
+ /* Shimmer placeholder primitive (reused for every skeletonized leaf). */
1258
+ .skeleton-fill {
1259
+ display: block;
1260
+ border-radius: var(--radius-sm, 2px);
1261
+ @supports (corner-shape: squircle) {
1262
+ corner-shape: squircle;
1263
+ border-radius: calc(var(--radius-sm, 2px) * var(--squircle-ratio, 2));
1264
+ }
1265
+ background: var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1));
1266
+ position: relative;
1267
+ overflow: hidden;
1268
+
1269
+ &::after {
1270
+ content: '';
1271
+ position: absolute;
1272
+ inset: 0;
1273
+ transform: translateX(-100%);
1274
+ background-image: linear-gradient(
1275
+ 105deg,
1276
+ transparent 25%,
1277
+ var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
1278
+ transparent 75%
1279
+ );
1280
+ animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
1281
+ infinite;
1282
+ animation-delay: var(--shimmer-delay, 0s);
1283
+ }
1284
+ }
1285
+
1286
+ /* Centered absolute overlay shared by the title/weekday/day placeholders so
1287
+ none of them affect the layout they sit on top of. */
1288
+ .skeleton-title,
1289
+ .skeleton-weekday,
1290
+ .skeleton-day {
1291
+ position: absolute;
1292
+ top: 50%;
1293
+ left: 50%;
1294
+ translate: -50% -50%;
1295
+ }
1296
+
1297
+ /* Nav-icon footprint inside the (still full-size) 2rem nav button. */
1298
+ .skeleton-nav {
1299
+ width: 1rem;
1300
+ height: 1rem;
1301
+ }
1302
+
1303
+ /* Month/year title placeholder. */
1304
+ .skeleton-title {
1305
+ width: 7rem;
1306
+ max-width: 70%;
1307
+ height: 0.9em;
1308
+
1309
+ .dense & {
1310
+ width: 5.5rem;
1311
+ }
1312
+
1313
+ .comfortable & {
1314
+ width: 8rem;
1315
+ }
1316
+ }
1317
+
1318
+ /* Weekday label placeholder. */
1319
+ .skeleton-weekday {
1320
+ width: 60%;
1321
+ height: 0.6875rem;
1322
+
1323
+ .dense & {
1324
+ height: 0.625rem;
1325
+ }
1326
+
1327
+ .comfortable & {
1328
+ height: 0.75rem;
1329
+ }
1330
+ }
1331
+
1332
+ /* Day-number — a centered disc echoing the digit. */
1333
+ .skeleton-day {
1334
+ width: 45%;
1335
+ aspect-ratio: 1;
1336
+ border-radius: 50%;
1337
+ }
1338
+
1339
+ /* Time-slot label bar (its own column, no underlying text to preserve). */
1340
+ .skeleton-slot {
1341
+ width: 100%;
1342
+ height: 0.9em;
1343
+ }
1344
+
1345
+ @keyframes -global-delight-skeleton-shimmer {
1346
+ 0% {
1347
+ transform: translateX(-100%);
1348
+ }
1349
+ 55%,
1350
+ 100% {
1351
+ transform: translateX(100%);
1352
+ }
1353
+ }
1354
+
1355
+ @media (prefers-reduced-motion: reduce) {
1356
+ .skeleton-fill::after {
1357
+ animation: none;
1358
+ }
1359
+
1360
+ .day,
1361
+ .slot,
1362
+ .nav {
1363
+ transition: none;
1364
+ }
1365
+ }
1366
+ </style>