svelte-comp 1.3.3 → 1.3.6

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 (138) hide show
  1. package/LICENSE.md +21 -21
  2. package/README.md +101 -100
  3. package/dist/App.svelte +507 -507
  4. package/dist/Container.svelte +59 -59
  5. package/dist/app.css +234 -235
  6. package/dist/app.d.ts +10 -0
  7. package/dist/lib/Accordion.svelte +155 -155
  8. package/dist/lib/Badge.svelte +44 -44
  9. package/dist/lib/Button.svelte +185 -170
  10. package/dist/lib/Calendar.svelte +384 -384
  11. package/dist/lib/Card.svelte +103 -103
  12. package/dist/lib/Carousel.svelte +293 -293
  13. package/dist/lib/Carousel.svelte.d.ts +1 -1
  14. package/dist/lib/CheckBox.svelte +210 -210
  15. package/dist/lib/CodeView.svelte +308 -307
  16. package/dist/lib/ColorPicker.svelte +159 -159
  17. package/dist/lib/ContextMenu.svelte +328 -322
  18. package/dist/lib/DatePicker.svelte +246 -246
  19. package/dist/lib/Dialog.svelte +233 -233
  20. package/dist/lib/Field.svelte +299 -299
  21. package/dist/lib/FilePicker.svelte +295 -240
  22. package/dist/lib/FilePicker.svelte.d.ts +6 -1
  23. package/dist/lib/Form.svelte +438 -438
  24. package/dist/lib/Hamburger.svelte +217 -217
  25. package/dist/lib/InstallPWA.svelte +94 -94
  26. package/dist/lib/Menu.svelte +623 -623
  27. package/dist/lib/NoticeBase.svelte +140 -140
  28. package/dist/lib/PaginatedCard.svelte +73 -73
  29. package/dist/lib/Pagination.svelte +119 -119
  30. package/dist/lib/PrimaryColorSelect.svelte +111 -111
  31. package/dist/lib/ProgressBar.svelte +141 -141
  32. package/dist/lib/ProgressCircle.svelte +190 -190
  33. package/dist/lib/Radio.svelte +189 -189
  34. package/dist/lib/SearchInput.svelte +104 -104
  35. package/dist/lib/Select.svelte +524 -524
  36. package/dist/lib/Slider.svelte +253 -253
  37. package/dist/lib/Splitter.svelte +159 -150
  38. package/dist/lib/Switch.svelte +168 -167
  39. package/dist/lib/Table.svelte +299 -299
  40. package/dist/lib/Tabs.svelte +213 -213
  41. package/dist/lib/ThemeToggle.svelte +128 -127
  42. package/dist/lib/TimePicker.svelte +312 -312
  43. package/dist/lib/TimePickerNew.svelte +634 -0
  44. package/dist/lib/TimePickerNew.svelte.d.ts +49 -0
  45. package/dist/lib/Toast.svelte +123 -123
  46. package/dist/lib/Tooltip.svelte +110 -110
  47. package/dist/lib/Topbar.svelte +107 -107
  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 +1 -0
  133. package/dist/lib/index.js +1 -0
  134. package/dist/lib/lang.d.ts +4 -0
  135. package/dist/lib/lang.js +4 -0
  136. package/dist/styles.css +234 -232
  137. package/dist/utils/index.js +15 -4
  138. package/package.json +52 -52
@@ -1,384 +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 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>
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>