svelte-comp 1.3.5 → 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.
- package/LICENSE.md +21 -21
- package/README.md +101 -101
- package/dist/App.svelte +1046 -1046
- package/dist/Container.svelte +59 -59
- package/dist/app.css +234 -234
- package/dist/app.d.ts +10 -10
- package/dist/lib/Accordion.svelte +155 -155
- package/dist/lib/Badge.svelte +44 -44
- package/dist/lib/Button.svelte +185 -185
- package/dist/lib/Calendar.svelte +384 -384
- package/dist/lib/Card.svelte +103 -103
- package/dist/lib/Carousel.svelte +293 -293
- package/dist/lib/CheckBox.svelte +210 -210
- package/dist/lib/CodeView.svelte +308 -308
- package/dist/lib/ColorPicker.svelte +159 -159
- package/dist/lib/ContextMenu.svelte +328 -328
- package/dist/lib/DatePicker.svelte +246 -246
- package/dist/lib/Dialog.svelte +233 -233
- package/dist/lib/Field.svelte +299 -299
- package/dist/lib/FilePicker.svelte +295 -295
- package/dist/lib/Form.svelte +438 -438
- package/dist/lib/Hamburger.svelte +217 -217
- package/dist/lib/InstallPWA.svelte +94 -94
- package/dist/lib/Menu.svelte +623 -623
- package/dist/lib/NoticeBase.svelte +140 -140
- package/dist/lib/PaginatedCard.svelte +73 -73
- package/dist/lib/Pagination.svelte +119 -119
- package/dist/lib/PrimaryColorSelect.svelte +111 -111
- package/dist/lib/ProgressBar.svelte +141 -141
- package/dist/lib/ProgressCircle.svelte +190 -190
- package/dist/lib/Radio.svelte +189 -189
- package/dist/lib/SearchInput.svelte +104 -104
- package/dist/lib/Select.svelte +524 -524
- package/dist/lib/Slider.svelte +253 -253
- package/dist/lib/Splitter.svelte +159 -159
- package/dist/lib/Switch.svelte +168 -168
- package/dist/lib/Table.svelte +299 -299
- package/dist/lib/Tabs.svelte +213 -213
- package/dist/lib/ThemeToggle.svelte +128 -128
- package/dist/lib/TimePicker.svelte +312 -312
- package/dist/lib/TimePickerNew.svelte +634 -634
- package/dist/lib/Toast.svelte +123 -123
- package/dist/lib/Tooltip.svelte +110 -110
- package/dist/lib/Topbar.svelte +112 -112
- package/dist/styles.css +234 -234
- package/package.json +52 -52
package/dist/lib/Calendar.svelte
CHANGED
|
@@ -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>
|