svelte-comp 1.2.7 → 1.3.5

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 (139) hide show
  1. package/README.md +12 -11
  2. package/dist/App.svelte +497 -2
  3. package/dist/app.css +2 -3
  4. package/dist/app.d.ts +10 -0
  5. package/dist/lang.d.ts +3 -3
  6. package/dist/lang.js +3 -3
  7. package/dist/lib/Accordion.svelte +14 -14
  8. package/dist/lib/Badge.svelte +44 -0
  9. package/dist/lib/Badge.svelte.d.ts +10 -0
  10. package/dist/lib/Button.svelte +23 -8
  11. package/dist/lib/Calendar.svelte +384 -377
  12. package/dist/lib/Card.svelte +6 -6
  13. package/dist/lib/Carousel.svelte +16 -16
  14. package/dist/lib/Carousel.svelte.d.ts +1 -1
  15. package/dist/lib/CheckBox.svelte +2 -2
  16. package/dist/lib/CodeView.svelte +6 -5
  17. package/dist/lib/ColorPicker.svelte +6 -6
  18. package/dist/lib/ContextMenu.svelte +328 -0
  19. package/dist/lib/ContextMenu.svelte.d.ts +14 -0
  20. package/dist/lib/DatePicker.svelte +161 -161
  21. package/dist/lib/Dialog.svelte +10 -10
  22. package/dist/lib/Field.svelte +1 -1
  23. package/dist/lib/FilePicker.svelte +127 -74
  24. package/dist/lib/FilePicker.svelte.d.ts +6 -3
  25. package/dist/lib/Hamburger.svelte +27 -21
  26. package/dist/lib/InstallPWA.svelte +94 -0
  27. package/dist/lib/InstallPWA.svelte.d.ts +8 -0
  28. package/dist/lib/Menu.svelte +18 -18
  29. package/dist/lib/NoticeBase.svelte +140 -0
  30. package/dist/lib/NoticeBase.svelte.d.ts +43 -0
  31. package/dist/lib/PrimaryColorSelect.svelte +6 -6
  32. package/dist/lib/ProgressCircle.svelte +7 -9
  33. package/dist/lib/ProgressCircle.svelte.d.ts +7 -9
  34. package/dist/lib/SearchInput.svelte +6 -6
  35. package/dist/lib/Select.svelte +2 -2
  36. package/dist/lib/Slider.svelte +1 -1
  37. package/dist/lib/Splitter.svelte +15 -6
  38. package/dist/lib/Switch.svelte +5 -4
  39. package/dist/lib/Tabs.svelte +6 -6
  40. package/dist/lib/ThemeToggle.svelte +1 -0
  41. package/dist/lib/TimePicker.svelte +103 -95
  42. package/dist/lib/TimePickerNew.svelte +634 -0
  43. package/dist/lib/TimePickerNew.svelte.d.ts +49 -0
  44. package/dist/lib/Toast.svelte +17 -120
  45. package/dist/lib/Tooltip.svelte +7 -7
  46. package/dist/lib/Topbar.svelte +112 -0
  47. package/dist/lib/Topbar.svelte.d.ts +44 -0
  48. package/dist/lib/__tests__/Accordion.test.d.ts +1 -0
  49. package/dist/lib/__tests__/Accordion.test.js +171 -0
  50. package/dist/lib/__tests__/Badge.test.d.ts +1 -0
  51. package/dist/lib/__tests__/Badge.test.js +41 -0
  52. package/dist/lib/__tests__/Button.test.d.ts +1 -0
  53. package/dist/lib/__tests__/Button.test.js +269 -0
  54. package/dist/lib/__tests__/Calendar.test.d.ts +1 -0
  55. package/dist/lib/__tests__/Calendar.test.js +171 -0
  56. package/dist/lib/__tests__/Card.test.d.ts +1 -0
  57. package/dist/lib/__tests__/Card.test.js +148 -0
  58. package/dist/lib/__tests__/Carousel.test.d.ts +1 -0
  59. package/dist/lib/__tests__/Carousel.test.js +439 -0
  60. package/dist/lib/__tests__/CheckBox.test.d.ts +1 -0
  61. package/dist/lib/__tests__/CheckBox.test.js +152 -0
  62. package/dist/lib/__tests__/CodeView.test.d.ts +1 -0
  63. package/dist/lib/__tests__/CodeView.test.js +157 -0
  64. package/dist/lib/__tests__/ColorPicker.test.d.ts +1 -0
  65. package/dist/lib/__tests__/ColorPicker.test.js +93 -0
  66. package/dist/lib/__tests__/ContextMenu.test.d.ts +1 -0
  67. package/dist/lib/__tests__/ContextMenu.test.js +67 -0
  68. package/dist/lib/__tests__/DatePicker.test.d.ts +1 -0
  69. package/dist/lib/__tests__/DatePicker.test.js +108 -0
  70. package/dist/lib/__tests__/Dialog.test.d.ts +1 -0
  71. package/dist/lib/__tests__/Dialog.test.js +183 -0
  72. package/dist/lib/__tests__/Field.test.d.ts +1 -0
  73. package/dist/lib/__tests__/Field.test.js +190 -0
  74. package/dist/lib/__tests__/FilePicker.test.d.ts +1 -0
  75. package/dist/lib/__tests__/FilePicker.test.js +179 -0
  76. package/dist/lib/__tests__/Form.integration.test.d.ts +1 -0
  77. package/dist/lib/__tests__/Form.integration.test.js +158 -0
  78. package/dist/lib/__tests__/Form.test.d.ts +1 -0
  79. package/dist/lib/__tests__/Form.test.js +463 -0
  80. package/dist/lib/__tests__/Hamburger.test.d.ts +1 -0
  81. package/dist/lib/__tests__/Hamburger.test.js +161 -0
  82. package/dist/lib/__tests__/InstallPWA.test.d.ts +1 -0
  83. package/dist/lib/__tests__/InstallPWA.test.js +15 -0
  84. package/dist/lib/__tests__/Menu.test.d.ts +1 -0
  85. package/dist/lib/__tests__/Menu.test.js +285 -0
  86. package/dist/lib/__tests__/NoticeBase.test.d.ts +1 -0
  87. package/dist/lib/__tests__/NoticeBase.test.js +60 -0
  88. package/dist/lib/__tests__/PaginatedCard.test.d.ts +1 -0
  89. package/dist/lib/__tests__/PaginatedCard.test.js +89 -0
  90. package/dist/lib/__tests__/Pagination.test.d.ts +1 -0
  91. package/dist/lib/__tests__/Pagination.test.js +168 -0
  92. package/dist/lib/__tests__/PrimaryColorSelect.test.d.ts +1 -0
  93. package/dist/lib/__tests__/PrimaryColorSelect.test.js +92 -0
  94. package/dist/lib/__tests__/ProgressBar.test.d.ts +1 -0
  95. package/dist/lib/__tests__/ProgressBar.test.js +69 -0
  96. package/dist/lib/__tests__/ProgressCircle.test.d.ts +1 -0
  97. package/dist/lib/__tests__/ProgressCircle.test.js +71 -0
  98. package/dist/lib/__tests__/Radio.test.d.ts +1 -0
  99. package/dist/lib/__tests__/Radio.test.js +127 -0
  100. package/dist/lib/__tests__/SearchInput.test.d.ts +1 -0
  101. package/dist/lib/__tests__/SearchInput.test.js +80 -0
  102. package/dist/lib/__tests__/Select.test.d.ts +1 -0
  103. package/dist/lib/__tests__/Select.test.js +408 -0
  104. package/dist/lib/__tests__/Slider.test.d.ts +1 -0
  105. package/dist/lib/__tests__/Slider.test.js +213 -0
  106. package/dist/lib/__tests__/Splitter.test.d.ts +1 -0
  107. package/dist/lib/__tests__/Splitter.test.js +87 -0
  108. package/dist/lib/__tests__/Switch.test.d.ts +1 -0
  109. package/dist/lib/__tests__/Switch.test.js +97 -0
  110. package/dist/lib/__tests__/Table.test.d.ts +1 -0
  111. package/dist/lib/__tests__/Table.test.js +349 -0
  112. package/dist/lib/__tests__/Tabs.test.d.ts +1 -0
  113. package/dist/lib/__tests__/Tabs.test.js +262 -0
  114. package/dist/lib/__tests__/ThemeToggle.test.d.ts +1 -0
  115. package/dist/lib/__tests__/ThemeToggle.test.js +84 -0
  116. package/dist/lib/__tests__/TimePicker.test.d.ts +1 -0
  117. package/dist/lib/__tests__/TimePicker.test.js +146 -0
  118. package/dist/lib/__tests__/TimePickerNew.test.d.ts +1 -0
  119. package/dist/lib/__tests__/TimePickerNew.test.js +322 -0
  120. package/dist/lib/__tests__/Toast.test.d.ts +1 -0
  121. package/dist/lib/__tests__/Toast.test.js +135 -0
  122. package/dist/lib/__tests__/Tooltip.test.d.ts +1 -0
  123. package/dist/lib/__tests__/Tooltip.test.js +171 -0
  124. package/dist/lib/__tests__/Topbar.test.d.ts +1 -0
  125. package/dist/lib/__tests__/Topbar.test.js +25 -0
  126. package/dist/lib/__tests__/setupLangContext.d.ts +1 -0
  127. package/dist/lib/__tests__/setupLangContext.js +65 -0
  128. package/dist/lib/__tests__/storage.test.d.ts +1 -0
  129. package/dist/lib/__tests__/storage.test.js +124 -0
  130. package/dist/lib/__tests__/utils.test.d.ts +1 -0
  131. package/dist/lib/__tests__/utils.test.js +11 -0
  132. package/dist/lib/index.d.ts +5 -0
  133. package/dist/lib/index.js +5 -0
  134. package/dist/lib/lang.d.ts +64 -0
  135. package/dist/lib/lang.js +64 -0
  136. package/dist/lib/types/index.d.ts +1 -0
  137. package/dist/styles.css +2 -0
  138. package/dist/utils/index.js +15 -4
  139. package/package.json +12 -12
@@ -1,377 +1,384 @@
1
- <!-- src/lib/Calendar.svelte -->
2
- <script lang="ts">
3
- /**
4
- * @component Calendar
5
- * @description Monthly calendar grid with navigation and date selection.
6
- *
7
- * @prop value {string | null} - Selected date in ISO `YYYY-MM-DD` (bindable)
8
- * @default null
9
- *
10
- * @prop min {string} - Minimum selectable date (ISO `YYYY-MM-DD`)
11
- *
12
- * @prop max {string} - Maximum selectable date (ISO `YYYY-MM-DD`)
13
- *
14
- * @prop locale {string} - Locale used for month/day labels
15
- * @default "en-US"
16
- *
17
- * @prop weekStartsOn {0|1|2|3|4|5|6} - First day of week (0=Sun ... 6=Sat)
18
- * @default 1
19
- *
20
- * @prop showOutsideDays {boolean} - Render days from adjacent months
21
- * @default true
22
- *
23
- * @prop disabled {boolean} - Disables selection and navigation
24
- * @default false
25
- *
26
- * @prop onChange {(value: string | null) => void} - Fired on date selection
27
- *
28
- * @prop class {string} - Additional classes for the root wrapper
29
- * @default ""
30
- */
31
- import type { HTMLAttributes } from "svelte/elements";
32
- import { cx } from "../utils";
33
- import { TEXT } from "./types";
34
-
35
- type Props = HTMLAttributes<HTMLDivElement> & {
36
- value?: string | null;
37
- min?: string;
38
- max?: string;
39
- locale?: string;
40
- weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
41
- showOutsideDays?: boolean;
42
- disabled?: boolean;
43
- onChange?: (value: string | null) => void;
44
- class?: string;
45
- };
46
-
47
- let {
48
- value = $bindable<string | null>(null),
49
- min = "1926-01-01",
50
- max,
51
- locale = "en-US",
52
- weekStartsOn = 1,
53
- showOutsideDays = true,
54
- disabled = false,
55
- onChange,
56
- class: externalClass = "",
57
- ...rest
58
- }: Props = $props();
59
-
60
- type ViewMode = "days" | "months" | "years";
61
-
62
- function startOfDay(date: Date) {
63
- return new Date(date.getFullYear(), date.getMonth(), date.getDate());
64
- }
65
-
66
- function parseIso(value: string | null | undefined): Date | null {
67
- if (!value) return null;
68
- const [y, m, d] = value.split("-").map((part) => Number(part));
69
- if (!y || !m || !d) return null;
70
- const date = new Date(y, m - 1, d);
71
- return Number.isNaN(date.getTime()) ? null : date;
72
- }
73
-
74
- function toIso(date: Date) {
75
- const y = date.getFullYear();
76
- const m = String(date.getMonth() + 1).padStart(2, "0");
77
- const d = String(date.getDate()).padStart(2, "0");
78
- return `${y}-${m}-${d}`;
79
- }
80
-
81
- function isSameDay(a: Date, b: Date) {
82
- return (
83
- a.getFullYear() === b.getFullYear() &&
84
- a.getMonth() === b.getMonth() &&
85
- a.getDate() === b.getDate()
86
- );
87
- }
88
-
89
- let viewDate = $state(startOfDay(parseIso(value) ?? new Date()));
90
- let viewMode = $state<ViewMode>("days");
91
-
92
- const selectedDate = $derived(parseIso(value));
93
- const minDate = $derived(parseIso(min));
94
- const maxDate = $derived(parseIso(max));
95
- const today = $derived(startOfDay(new Date()));
96
-
97
- let lastValue = $state<string | null>(value ?? null);
98
-
99
- $effect(() => {
100
- if (value === lastValue) return;
101
- lastValue = value;
102
- const selected = parseIso(value);
103
- if (!selected) return;
104
- viewDate = new Date(selected.getFullYear(), selected.getMonth(), 1);
105
- });
106
-
107
- const monthLabel = $derived(
108
- new Intl.DateTimeFormat(locale, { month: "long" }).format(viewDate)
109
- );
110
-
111
- const yearLabel = $derived(String(viewDate.getFullYear()));
112
-
113
- const weekdayLabels = $derived.by(() => {
114
- const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
115
- const labels: string[] = [];
116
- const base = new Date(2023, 0, 1);
117
- for (let i = 0; i < 7; i += 1) {
118
- const offset = (weekStartsOn + i) % 7;
119
- const date = new Date(base);
120
- date.setDate(base.getDate() + offset);
121
- labels.push(formatter.format(date));
122
- }
123
- return labels;
124
- });
125
-
126
- const dayLabelFormatter = $derived.by(
127
- () =>
128
- new Intl.DateTimeFormat(locale, {
129
- weekday: "long",
130
- year: "numeric",
131
- month: "long",
132
- day: "numeric",
133
- })
134
- );
135
-
136
- const monthOptions = $derived.by(() => {
137
- const formatter = new Intl.DateTimeFormat(locale, { month: "short" });
138
- return Array.from({ length: 12 }, (_, idx) => ({
139
- index: idx,
140
- label: formatter.format(new Date(2024, idx, 1)),
141
- }));
142
- });
143
-
144
- const yearRangeStart = $derived(() => {
145
- const year = viewDate.getFullYear();
146
- return year - (year % 12);
147
- });
148
-
149
- const yearOptions = $derived.by(() =>
150
- Array.from({ length: 12 }, (_, idx) => yearRangeStart() + idx)
151
- );
152
-
153
- type DayCell = {
154
- date: Date;
155
- iso: string;
156
- inMonth: boolean;
157
- isToday: boolean;
158
- isSelected: boolean;
159
- isDisabled: boolean;
160
- };
161
-
162
- const days = $derived.by(() => {
163
- const year = viewDate.getFullYear();
164
- const month = viewDate.getMonth();
165
- const first = new Date(year, month, 1);
166
- const startOffset = (first.getDay() - weekStartsOn + 7) % 7;
167
- const start = new Date(year, month, 1 - startOffset);
168
- const cells: DayCell[] = [];
169
- for (let i = 0; i < 42; i += 1) {
170
- const date = new Date(start);
171
- date.setDate(start.getDate() + i);
172
- const day = startOfDay(date);
173
- const inMonth = day.getMonth() === month;
174
- const isToday = isSameDay(day, today);
175
- const isSelected = selectedDate ? isSameDay(day, selectedDate) : false;
176
- const beforeMin = minDate ? day < startOfDay(minDate) : false;
177
- const afterMax = maxDate ? day > startOfDay(maxDate) : false;
178
- const isDisabled =
179
- disabled || beforeMin || afterMax || (!showOutsideDays && !inMonth);
180
- cells.push({
181
- date: day,
182
- iso: toIso(day),
183
- inMonth,
184
- isToday,
185
- isSelected,
186
- isDisabled,
187
- });
188
- }
189
- return cells;
190
- });
191
-
192
- function shift(delta: number) {
193
- if (disabled) return;
194
- if (viewMode === "days") {
195
- viewDate = new Date(
196
- viewDate.getFullYear(),
197
- viewDate.getMonth() + delta,
198
- 1
199
- );
200
- } else if (viewMode === "months") {
201
- viewDate = new Date(viewDate.getFullYear() + delta, viewDate.getMonth(), 1);
202
- } else {
203
- viewDate = new Date(
204
- viewDate.getFullYear() + delta * 12,
205
- viewDate.getMonth(),
206
- 1
207
- );
208
- }
209
- }
210
-
211
- function selectDay(cell: DayCell) {
212
- if (cell.isDisabled) return;
213
- value = cell.iso;
214
- onChange?.(cell.iso);
215
- }
216
-
217
- function selectMonth(index: number) {
218
- if (disabled) return;
219
- viewDate = new Date(viewDate.getFullYear(), index, 1);
220
- viewMode = "days";
221
- }
222
-
223
- function selectYear(year: number) {
224
- if (disabled) return;
225
- viewDate = new Date(year, viewDate.getMonth(), 1);
226
- viewMode = "months";
227
- }
228
-
229
- const wrapperClass = $derived(cx("w-full", externalClass));
230
-
231
- const headerButtonBase =
232
- "px-1 py-0.5 rounded-[var(--radius-sm)] transition-colors text-[var(--color-text-default)] hover:bg-[var(--color-bg-hover)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-color-focus)] disabled:opacity-[var(--opacity-disabled)] disabled:cursor-not-allowed text-[var(--text-xs)]";
233
-
234
- const arrowButtonBase =
235
- "inline-flex items-center justify-center h-3 w-3 rounded-[var(--radius-sm)] text-[var(--color-text-default)] hover:bg-[var(--color-bg-hover)] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-color-focus)] disabled:opacity-[var(--opacity-disabled)] disabled:cursor-not-allowed";
236
-
237
- const dayButtonBase =
238
- "rounded-full flex items-center justify-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-color-focus)] text-[var(--text-xs)]";
239
- </script>
240
-
241
- <div class={wrapperClass} style="width: 240px; max-width: 100%;" {...rest}>
242
- <div class="flex items-center justify-between mb-[var(--spacing-sm)]">
243
- <div class="flex items-center gap-1">
244
- <button
245
- type="button"
246
- class={cx(headerButtonBase, TEXT.sm)}
247
- onclick={() => (viewMode = viewMode === "months" ? "days" : "months")}
248
- disabled={disabled}
249
- >
250
- {monthLabel}
251
- </button>
252
- <button
253
- type="button"
254
- class={cx(headerButtonBase, TEXT.sm)}
255
- onclick={() => (viewMode = viewMode === "years" ? "days" : "years")}
256
- disabled={disabled}
257
- >
258
- {yearLabel}
259
- </button>
260
- </div>
261
-
262
- <div class="flex items-center gap-1">
263
- <button
264
- type="button"
265
- class={arrowButtonBase}
266
- aria-label="Previous"
267
- onclick={() => shift(-1)}
268
- disabled={disabled}
269
- >
270
-
271
- </button>
272
- <button
273
- type="button"
274
- class={arrowButtonBase}
275
- aria-label="Next"
276
- onclick={() => shift(1)}
277
- disabled={disabled}
278
- >
279
-
280
- </button>
281
- </div>
282
- </div>
283
-
284
- {#if viewMode === "days"}
285
- <div
286
- class={cx("text-center", TEXT.xs)}
287
- style="display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: var(--spacing-xs);"
288
- >
289
- {#each weekdayLabels as label, i (i)}
290
- <div class="py-[var(--spacing-xs)] [color:var(--color-text-muted)]">
291
- {label}
292
- </div>
293
- {/each}
294
- </div>
295
-
296
- <div
297
- style="display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: var(--spacing-xs); margin-top: var(--spacing-xs);"
298
- >
299
- {#each days as cell (cell.iso)}
300
- <button
301
- type="button"
302
- class={cx(
303
- dayButtonBase,
304
- cell.isSelected &&
305
- "bg-[var(--color-bg-primary)] text-white hover:brightness-110",
306
- !cell.isSelected && "text-[var(--color-text-default)]",
307
- cell.isToday &&
308
- !cell.isSelected &&
309
- "border border-[var(--border-color-primary)]",
310
- !cell.inMonth && "text-[var(--color-text-muted)]",
311
- cell.isDisabled &&
312
- "opacity-[var(--opacity-disabled)] cursor-not-allowed",
313
- !cell.isDisabled && "hover:bg-[var(--color-bg-hover)]"
314
- )}
315
- style="width: 22px; height: 22px; justify-self: center;"
316
- aria-pressed={cell.isSelected}
317
- aria-current={cell.isToday ? "date" : undefined}
318
- aria-label={dayLabelFormatter.format(cell.date)}
319
- disabled={cell.isDisabled}
320
- onclick={() => selectDay(cell)}
321
- >
322
- {#if cell.inMonth || showOutsideDays}
323
- {cell.date.getDate()}
324
- {/if}
325
- </button>
326
- {/each}
327
- </div>
328
- {:else if viewMode === "months"}
329
- <div
330
- style="display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--spacing-sm);"
331
- >
332
- {#each monthOptions as month (month.index)}
333
- <button
334
- type="button"
335
- class={cx(
336
- "rounded-[var(--radius-md)] text-center transition-colors",
337
- TEXT.xs,
338
- month.index === viewDate.getMonth() &&
339
- "bg-[var(--color-bg-primary)] text-white",
340
- month.index !== viewDate.getMonth() &&
341
- "text-[var(--color-text-default)] hover:bg-[var(--color-bg-hover)]",
342
- disabled && "opacity-[var(--opacity-disabled)] cursor-not-allowed"
343
- )}
344
- style="height: 22px; width: 22px;"
345
- onclick={() => selectMonth(month.index)}
346
- disabled={disabled}
347
- >
348
- {month.label}
349
- </button>
350
- {/each}
351
- </div>
352
- {:else}
353
- <div
354
- style="display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--spacing-sm);"
355
- >
356
- {#each yearOptions as year (year)}
357
- <button
358
- type="button"
359
- class={cx(
360
- "rounded-[var(--radius-md)] text-center transition-colors",
361
- TEXT.xs,
362
- year === viewDate.getFullYear() &&
363
- "bg-[var(--color-bg-primary)] text-white",
364
- year !== viewDate.getFullYear() &&
365
- "text-[var(--color-text-default)] hover:bg-[var(--color-bg-hover)]",
366
- disabled && "opacity-[var(--opacity-disabled)] cursor-not-allowed"
367
- )}
368
- style="height: 22px; width: 22px;"
369
- onclick={() => selectYear(year)}
370
- disabled={disabled}
371
- >
372
- {year}
373
- </button>
374
- {/each}
375
- </div>
376
- {/if}
377
- </div>
1
+ <!-- src/lib/Calendar.svelte -->
2
+ <script lang="ts">
3
+ /**
4
+ * @component Calendar
5
+ * @description Monthly calendar grid with navigation and date selection.
6
+ *
7
+ * @prop value {string | null} - Selected date in ISO `YYYY-MM-DD` (bindable)
8
+ * @default null
9
+ *
10
+ * @prop min {string} - Minimum selectable date (ISO `YYYY-MM-DD`)
11
+ *
12
+ * @prop max {string} - Maximum selectable date (ISO `YYYY-MM-DD`)
13
+ *
14
+ * @prop locale {string} - Locale used for month/day labels
15
+ * @default "en-US"
16
+ *
17
+ * @prop weekStartsOn {0|1|2|3|4|5|6} - First day of week (0=Sun ... 6=Sat)
18
+ * @default 1
19
+ *
20
+ * @prop showOutsideDays {boolean} - Render days from adjacent months
21
+ * @default true
22
+ *
23
+ * @prop disabled {boolean} - Disables selection and navigation
24
+ * @default false
25
+ *
26
+ * @prop onChange {(value: string | null) => void} - Fired on date selection
27
+ *
28
+ * @prop class {string} - Additional classes for the root wrapper
29
+ * @default ""
30
+ */
31
+ import type { HTMLAttributes } from "svelte/elements";
32
+ import { cx } from "../utils";
33
+ import { TEXT } from "./types";
34
+
35
+ type Props = HTMLAttributes<HTMLDivElement> & {
36
+ value?: string | null;
37
+ min?: string;
38
+ max?: string;
39
+ locale?: string;
40
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
41
+ showOutsideDays?: boolean;
42
+ disabled?: boolean;
43
+ onChange?: (value: string | null) => void;
44
+ class?: string;
45
+ };
46
+
47
+ let {
48
+ value = $bindable<string | null>(null),
49
+ min = "1926-01-01",
50
+ max,
51
+ locale = "en-US",
52
+ weekStartsOn = 1,
53
+ showOutsideDays = true,
54
+ disabled = false,
55
+ onChange,
56
+ class: externalClass = "",
57
+ ...rest
58
+ }: Props = $props();
59
+
60
+ type ViewMode = "days" | "months" | "years";
61
+
62
+ function startOfDay(date: Date) {
63
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
64
+ }
65
+
66
+ function parseIso(value: string | null | undefined): Date | null {
67
+ if (!value) return null;
68
+ const [y, m, d] = value.split("-").map((part) => Number(part));
69
+ if (!y || !m || !d) return null;
70
+ const date = new Date(y, m - 1, d);
71
+ return Number.isNaN(date.getTime()) ? null : date;
72
+ }
73
+
74
+ function toIso(date: Date) {
75
+ const y = date.getFullYear();
76
+ const m = String(date.getMonth() + 1).padStart(2, "0");
77
+ const d = String(date.getDate()).padStart(2, "0");
78
+ return `${y}-${m}-${d}`;
79
+ }
80
+
81
+ function isSameDay(a: Date, b: Date) {
82
+ return (
83
+ a.getFullYear() === b.getFullYear() &&
84
+ a.getMonth() === b.getMonth() &&
85
+ a.getDate() === b.getDate()
86
+ );
87
+ }
88
+
89
+ let internalValue = $state<string | null>(value ?? null);
90
+ let lastValue = $state<string | null>(value ?? null);
91
+ let viewDate = $state(startOfDay(new Date()));
92
+ let viewMode = $state<ViewMode>("days");
93
+
94
+ const selectedDate = $derived(parseIso(internalValue));
95
+ const minDate = $derived(parseIso(min));
96
+ const maxDate = $derived(parseIso(max));
97
+ const today = $derived(startOfDay(new Date()));
98
+
99
+ $effect(() => {
100
+ const next = value ?? null;
101
+ if (next !== lastValue) {
102
+ lastValue = next;
103
+ internalValue = next;
104
+ }
105
+ });
106
+
107
+ $effect(() => {
108
+ const selected = parseIso(internalValue);
109
+ if (!selected) return;
110
+ viewDate = new Date(selected.getFullYear(), selected.getMonth(), 1);
111
+ });
112
+
113
+ const monthLabel = $derived(
114
+ new Intl.DateTimeFormat(locale, { month: "long" }).format(viewDate)
115
+ );
116
+
117
+ const yearLabel = $derived(String(viewDate.getFullYear()));
118
+
119
+ const weekdayLabels = $derived.by(() => {
120
+ const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
121
+ const labels: string[] = [];
122
+ for (let i = 0; i < 7; i += 1) {
123
+ const offset = (weekStartsOn + i) % 7;
124
+ const date = new Date(2023, 0, 1 + offset);
125
+ labels.push(formatter.format(date));
126
+ }
127
+ return labels;
128
+ });
129
+
130
+ const dayLabelFormatter = $derived.by(
131
+ () =>
132
+ new Intl.DateTimeFormat(locale, {
133
+ weekday: "long",
134
+ year: "numeric",
135
+ month: "long",
136
+ day: "numeric",
137
+ })
138
+ );
139
+
140
+ const monthOptions = $derived.by(() => {
141
+ const formatter = new Intl.DateTimeFormat(locale, { month: "short" });
142
+ return Array.from({ length: 12 }, (_, idx) => ({
143
+ index: idx,
144
+ label: formatter.format(new Date(2024, idx, 1)),
145
+ }));
146
+ });
147
+
148
+ const yearRangeStart = $derived(() => {
149
+ const year = viewDate.getFullYear();
150
+ return year - (year % 12);
151
+ });
152
+
153
+ const yearOptions = $derived.by(() =>
154
+ Array.from({ length: 12 }, (_, idx) => yearRangeStart() + idx)
155
+ );
156
+
157
+ type DayCell = {
158
+ date: Date;
159
+ iso: string;
160
+ inMonth: boolean;
161
+ isToday: boolean;
162
+ isSelected: boolean;
163
+ isDisabled: boolean;
164
+ };
165
+
166
+ const days = $derived.by(() => {
167
+ const year = viewDate.getFullYear();
168
+ const month = viewDate.getMonth();
169
+ const first = new Date(year, month, 1);
170
+ const startOffset = (first.getDay() - weekStartsOn + 7) % 7;
171
+ const cells: DayCell[] = [];
172
+ for (let i = 0; i < 42; i += 1) {
173
+ const date = new Date(year, month, 1 - startOffset + i);
174
+ const day = startOfDay(date);
175
+ const inMonth = day.getMonth() === month;
176
+ const isToday = isSameDay(day, today);
177
+ const isSelected = selectedDate ? isSameDay(day, selectedDate) : false;
178
+ const beforeMin = minDate ? day < startOfDay(minDate) : false;
179
+ const afterMax = maxDate ? day > startOfDay(maxDate) : false;
180
+ const isDisabled =
181
+ disabled || beforeMin || afterMax || (!showOutsideDays && !inMonth);
182
+ cells.push({
183
+ date: day,
184
+ iso: toIso(day),
185
+ inMonth,
186
+ isToday,
187
+ isSelected,
188
+ isDisabled,
189
+ });
190
+ }
191
+ return cells;
192
+ });
193
+
194
+ function handleShift(delta: number) {
195
+ if (disabled) return;
196
+ if (viewMode === "days") {
197
+ viewDate = new Date(
198
+ viewDate.getFullYear(),
199
+ viewDate.getMonth() + delta,
200
+ 1
201
+ );
202
+ } else if (viewMode === "months") {
203
+ viewDate = new Date(viewDate.getFullYear() + delta, viewDate.getMonth(), 1);
204
+ } else {
205
+ viewDate = new Date(
206
+ viewDate.getFullYear() + delta * 12,
207
+ viewDate.getMonth(),
208
+ 1
209
+ );
210
+ }
211
+ }
212
+
213
+ function handleWheel(event: WheelEvent) {
214
+ if (disabled || viewMode !== "days") return;
215
+ if (event.deltaY === 0) return;
216
+ event.preventDefault();
217
+ handleShift(event.deltaY > 0 ? 1 : -1);
218
+ }
219
+
220
+ function selectDay(cell: DayCell) {
221
+ if (cell.isDisabled) return;
222
+ internalValue = cell.iso;
223
+ value = cell.iso;
224
+ onChange?.(cell.iso);
225
+ }
226
+
227
+ function selectMonth(index: number) {
228
+ if (disabled) return;
229
+ viewDate = new Date(viewDate.getFullYear(), index, 1);
230
+ viewMode = "days";
231
+ }
232
+
233
+ function selectYear(year: number) {
234
+ if (disabled) return;
235
+ viewDate = new Date(year, viewDate.getMonth(), 1);
236
+ viewMode = "months";
237
+ }
238
+
239
+ const wrapperClass = $derived(cx("w-full", externalClass));
240
+
241
+ const headerButtonBase =
242
+ "px-1 py-0.5 rounded-[var(--radius-sm)] text-[var(--color-text-default)] hover:bg-[var(--color-bg-hover)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-color-focus)] disabled:opacity-[var(--opacity-disabled)] disabled:cursor-not-allowed text-[var(--text-xs)] transition-none";
243
+
244
+ const arrowButtonBase =
245
+ "inline-flex items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-default)] hover:bg-[var(--color-bg-hover)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-color-focus)] disabled:opacity-[var(--opacity-disabled)] disabled:cursor-not-allowed h-[var(--cal-cell)] w-[var(--cal-cell)] transition-none";
246
+
247
+ const dayButtonBase =
248
+ "rounded-full flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-color-focus)] text-[var(--text-xs)] text-[var(--color-text-default)] transition-none";
249
+ </script>
250
+
251
+ <div
252
+ class={cx(
253
+ wrapperClass,
254
+ "w-full h-full text-[var(--color-text-default)] [--cal-cell:clamp(16px,4vw,20px)] [--cal-gap:clamp(1px,0.6vw,3px)] [--cal-gap-lg:clamp(2px,0.9vw,6px)]"
255
+ )}
256
+ {...rest}
257
+ >
258
+ <div class="flex items-center justify-between mb-[var(--cal-gap-lg)]">
259
+ <div class="flex items-center gap-1">
260
+ <button
261
+ type="button"
262
+ class={cx(headerButtonBase, TEXT.sm)}
263
+ onclick={() => (viewMode = viewMode === "months" ? "days" : "months")}
264
+ disabled={disabled}
265
+ >
266
+ {monthLabel}
267
+ </button>
268
+ <button
269
+ type="button"
270
+ class={cx(headerButtonBase, TEXT.sm)}
271
+ onclick={() => (viewMode = viewMode === "years" ? "days" : "years")}
272
+ disabled={disabled}
273
+ >
274
+ {yearLabel}
275
+ </button>
276
+ </div>
277
+
278
+ <div class="flex items-center gap-1">
279
+ <button
280
+ type="button"
281
+ class={arrowButtonBase}
282
+ aria-label="Previous"
283
+ onclick={() => handleShift(-1)}
284
+ disabled={disabled}
285
+ >
286
+
287
+ </button>
288
+ <button
289
+ type="button"
290
+ class={arrowButtonBase}
291
+ aria-label="Next"
292
+ onclick={() => handleShift(1)}
293
+ disabled={disabled}
294
+ >
295
+
296
+ </button>
297
+ </div>
298
+ </div>
299
+
300
+ {#if viewMode === "days"}
301
+ <div class={cx("grid grid-cols-7 gap-[var(--cal-gap)] text-center", TEXT.xs)}>
302
+ {#each weekdayLabels as label, i (i)}
303
+ <div class="py-[var(--cal-gap)] [color:var(--color-text-muted)]">
304
+ {label}
305
+ </div>
306
+ {/each}
307
+ </div>
308
+
309
+ <div
310
+ class="grid grid-cols-7 gap-[var(--cal-gap)] mt-[var(--cal-gap)]"
311
+ onwheel={handleWheel}
312
+ >
313
+ {#each days as cell (cell.iso)}
314
+ <button
315
+ type="button"
316
+ class={cx(
317
+ dayButtonBase,
318
+ "text-[var(--color-text-default)]",
319
+ cell.isToday &&
320
+ "bg-[var(--color-bg-primary)] text-[var(--color-text-default)] hover:brightness-110",
321
+ cell.isSelected &&
322
+ "border border-[var(--border-color-primary)]",
323
+ !cell.inMonth && "opacity-60",
324
+ cell.isDisabled &&
325
+ "opacity-[var(--opacity-disabled)] cursor-not-allowed",
326
+ !cell.isDisabled && "hover:bg-[var(--color-bg-hover)]",
327
+ "w-[var(--cal-cell)] h-[var(--cal-cell)] justify-self-center"
328
+ )}
329
+ aria-pressed={cell.isSelected}
330
+ aria-current={cell.isToday ? "date" : undefined}
331
+ aria-label={dayLabelFormatter.format(cell.date)}
332
+ disabled={cell.isDisabled}
333
+ onclick={() => selectDay(cell)}
334
+ >
335
+ {#if cell.inMonth || showOutsideDays}
336
+ {cell.date.getDate()}
337
+ {/if}
338
+ </button>
339
+ {/each}
340
+ </div>
341
+ {:else if viewMode === "months"}
342
+ <div class="grid grid-cols-4 gap-[var(--cal-gap-lg)]">
343
+ {#each monthOptions as month (month.index)}
344
+ <button
345
+ type="button"
346
+ class={cx(
347
+ "rounded-[var(--radius-md)] text-center text-[var(--color-text-default)] w-[var(--cal-cell)] h-[var(--cal-cell)]",
348
+ TEXT.xs,
349
+ month.index === viewDate.getMonth() &&
350
+ "bg-[var(--color-bg-primary)]",
351
+ month.index !== viewDate.getMonth() &&
352
+ "hover:bg-[var(--color-bg-hover)]",
353
+ disabled && "opacity-[var(--opacity-disabled)] cursor-not-allowed"
354
+ )}
355
+ onclick={() => selectMonth(month.index)}
356
+ disabled={disabled}
357
+ >
358
+ {month.label}
359
+ </button>
360
+ {/each}
361
+ </div>
362
+ {:else}
363
+ <div class="grid grid-cols-4 gap-[var(--cal-gap-lg)]">
364
+ {#each yearOptions as year (year)}
365
+ <button
366
+ type="button"
367
+ class={cx(
368
+ "rounded-[var(--radius-md)] text-center text-[var(--color-text-default)] w-[var(--cal-cell)] h-[var(--cal-cell)] transition-none",
369
+ TEXT.xs,
370
+ year === viewDate.getFullYear() &&
371
+ "bg-[var(--color-bg-primary)]",
372
+ year !== viewDate.getFullYear() &&
373
+ "hover:bg-[var(--color-bg-hover)]",
374
+ disabled && "opacity-[var(--opacity-disabled)] cursor-not-allowed"
375
+ )}
376
+ onclick={() => selectYear(year)}
377
+ disabled={disabled}
378
+ >
379
+ {year}
380
+ </button>
381
+ {/each}
382
+ </div>
383
+ {/if}
384
+ </div>