@intinyagroup/calendar 0.1.1-alpha.7 → 0.1.1
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/dist/Calendar.svelte +142 -30
- package/dist/DatePicker.svelte +27 -13
- package/dist/DatePicker.svelte.d.ts +1 -1
- package/dist/DateRangePicker.svelte +392 -0
- package/dist/DateRangePicker.svelte.d.ts +28 -0
- package/dist/EventCalendar.svelte +1418 -0
- package/dist/EventCalendar.svelte.d.ts +48 -0
- package/dist/helpers.test.d.ts +1 -0
- package/dist/helpers.test.js +129 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.js +5 -2
- package/dist/recurrence.d.ts +31 -0
- package/dist/recurrence.js +57 -0
- package/package.json +11 -6
package/dist/Calendar.svelte
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { ChevronLeft, ChevronRight } from
|
|
3
|
-
import { Button } from
|
|
4
|
-
import { cn } from
|
|
2
|
+
import { ChevronLeft, ChevronRight } from "lucide-svelte";
|
|
3
|
+
import { Button } from "@intinyagroup/ui";
|
|
4
|
+
import { cn } from "@intinyagroup/ui/utils";
|
|
5
5
|
|
|
6
6
|
export type CalendarDate = { year: number; month: number; day: number };
|
|
7
7
|
|
|
@@ -25,13 +25,27 @@
|
|
|
25
25
|
let viewYear = $state(value?.year ?? today.getFullYear());
|
|
26
26
|
let viewMonth = $state(value?.month ?? today.getMonth());
|
|
27
27
|
|
|
28
|
-
const monthNames = [
|
|
29
|
-
|
|
28
|
+
const monthNames = [
|
|
29
|
+
"January",
|
|
30
|
+
"February",
|
|
31
|
+
"March",
|
|
32
|
+
"April",
|
|
33
|
+
"May",
|
|
34
|
+
"June",
|
|
35
|
+
"July",
|
|
36
|
+
"August",
|
|
37
|
+
"September",
|
|
38
|
+
"October",
|
|
39
|
+
"November",
|
|
40
|
+
"December",
|
|
41
|
+
];
|
|
42
|
+
const dayNames = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
|
30
43
|
const daysInMonth = $derived(new Date(viewYear, viewMonth + 1, 0).getDate());
|
|
31
44
|
const firstDayOfMonth = $derived(new Date(viewYear, viewMonth, 1).getDay());
|
|
32
45
|
|
|
33
46
|
const calendarDays = $derived.by(() => {
|
|
34
|
-
const days: { day: number; currentMonth: boolean; date: CalendarDate }[] =
|
|
47
|
+
const days: { day: number; currentMonth: boolean; date: CalendarDate }[] =
|
|
48
|
+
[];
|
|
35
49
|
const prevMonthDays = new Date(viewYear, viewMonth, 0).getDate();
|
|
36
50
|
for (let i = firstDayOfMonth - 1; i >= 0; i--) {
|
|
37
51
|
const day = prevMonthDays - i;
|
|
@@ -40,7 +54,11 @@
|
|
|
40
54
|
days.push({ day, currentMonth: false, date: { year, month, day } });
|
|
41
55
|
}
|
|
42
56
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
43
|
-
days.push({
|
|
57
|
+
days.push({
|
|
58
|
+
day,
|
|
59
|
+
currentMonth: true,
|
|
60
|
+
date: { year: viewYear, month: viewMonth, day },
|
|
61
|
+
});
|
|
44
62
|
}
|
|
45
63
|
const remaining = 42 - days.length;
|
|
46
64
|
for (let day = 1; day <= remaining; day++) {
|
|
@@ -55,48 +73,142 @@
|
|
|
55
73
|
return a?.year === b.year && a?.month === b.month && a?.day === b.day;
|
|
56
74
|
}
|
|
57
75
|
function isToday(date: CalendarDate) {
|
|
58
|
-
return
|
|
76
|
+
return (
|
|
77
|
+
date.year === today.getFullYear() &&
|
|
78
|
+
date.month === today.getMonth() &&
|
|
79
|
+
date.day === today.getDate()
|
|
80
|
+
);
|
|
59
81
|
}
|
|
60
82
|
function isDisabled(date: CalendarDate) {
|
|
61
|
-
if (
|
|
62
|
-
|
|
83
|
+
if (
|
|
84
|
+
min &&
|
|
85
|
+
(date.year < min.year ||
|
|
86
|
+
(date.year === min.year && date.month < min.month) ||
|
|
87
|
+
(date.year === min.year &&
|
|
88
|
+
date.month === min.month &&
|
|
89
|
+
date.day < min.day))
|
|
90
|
+
)
|
|
91
|
+
return true;
|
|
92
|
+
if (
|
|
93
|
+
max &&
|
|
94
|
+
(date.year > max.year ||
|
|
95
|
+
(date.year === max.year && date.month > max.month) ||
|
|
96
|
+
(date.year === max.year &&
|
|
97
|
+
date.month === max.month &&
|
|
98
|
+
date.day > max.day))
|
|
99
|
+
)
|
|
100
|
+
return true;
|
|
63
101
|
return false;
|
|
64
102
|
}
|
|
65
|
-
function prevMonth() {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
103
|
+
function prevMonth() {
|
|
104
|
+
if (viewMonth === 0) {
|
|
105
|
+
viewMonth = 11;
|
|
106
|
+
viewYear--;
|
|
107
|
+
} else {
|
|
108
|
+
viewMonth--;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function nextMonth() {
|
|
112
|
+
if (viewMonth === 11) {
|
|
113
|
+
viewMonth = 0;
|
|
114
|
+
viewYear++;
|
|
115
|
+
} else {
|
|
116
|
+
viewMonth++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function selectDate(date: CalendarDate) {
|
|
120
|
+
if (disabled || isDisabled(date)) return;
|
|
121
|
+
value = date;
|
|
122
|
+
onSelect?.(date);
|
|
123
|
+
}
|
|
124
|
+
function goToday() {
|
|
125
|
+
viewYear = today.getFullYear();
|
|
126
|
+
viewMonth = today.getMonth();
|
|
127
|
+
selectDate({
|
|
128
|
+
year: today.getFullYear(),
|
|
129
|
+
month: today.getMonth(),
|
|
130
|
+
day: today.getDate(),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
69
133
|
</script>
|
|
70
134
|
|
|
71
|
-
<div
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
135
|
+
<div
|
|
136
|
+
class={cn(
|
|
137
|
+
"inline-flex flex-col rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] p-4 shadow-sm select-none",
|
|
138
|
+
className,
|
|
139
|
+
)}
|
|
140
|
+
style="min-width: 280px; max-width: 320px; box-sizing: border-box;"
|
|
141
|
+
>
|
|
142
|
+
<div
|
|
143
|
+
class="flex items-center justify-between mb-4"
|
|
144
|
+
style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;"
|
|
145
|
+
>
|
|
146
|
+
<Button variant="ghost" size="sm" onclick={prevMonth} {disabled}
|
|
147
|
+
><ChevronLeft class="size-4" /></Button
|
|
148
|
+
>
|
|
149
|
+
<div
|
|
150
|
+
class="flex items-center gap-2"
|
|
151
|
+
style="display: flex; align-items: center; gap: 0.5rem;"
|
|
152
|
+
>
|
|
153
|
+
<span class="text-sm font-semibold text-[var(--ui-foreground)]"
|
|
154
|
+
>{monthNames[viewMonth]} {viewYear}</span
|
|
155
|
+
>
|
|
156
|
+
<Button
|
|
157
|
+
variant="ghost"
|
|
158
|
+
size="sm"
|
|
159
|
+
onclick={goToday}
|
|
160
|
+
{disabled}
|
|
161
|
+
class="text-xs h-6 px-2">Today</Button
|
|
162
|
+
>
|
|
77
163
|
</div>
|
|
78
|
-
<Button variant="ghost" size="sm" onclick={nextMonth} {disabled}
|
|
164
|
+
<Button variant="ghost" size="sm" onclick={nextMonth} {disabled}
|
|
165
|
+
><ChevronRight class="size-4" /></Button
|
|
166
|
+
>
|
|
79
167
|
</div>
|
|
80
168
|
|
|
81
|
-
<div
|
|
82
|
-
|
|
169
|
+
<div
|
|
170
|
+
class="grid grid-cols-7 gap-1 mb-2 text-center"
|
|
171
|
+
style="display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 0.25rem; margin-bottom: 0.5rem; text-align: center;"
|
|
172
|
+
>
|
|
173
|
+
{#each dayNames as d}
|
|
174
|
+
<div
|
|
175
|
+
class="text-center text-[11px] font-medium text-[var(--ui-muted-foreground)] py-1"
|
|
176
|
+
style="text-align: center; font-size: 0.6875rem; padding: 0.25rem 0;"
|
|
177
|
+
>
|
|
178
|
+
{d}
|
|
179
|
+
</div>
|
|
180
|
+
{/each}
|
|
83
181
|
</div>
|
|
84
182
|
|
|
85
|
-
<div
|
|
86
|
-
|
|
183
|
+
<div
|
|
184
|
+
class="grid grid-cols-7 gap-1"
|
|
185
|
+
style="display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 0.25rem;"
|
|
186
|
+
>
|
|
187
|
+
{#each calendarDays as { day, currentMonth, date } (date.year + "-" + date.month + "-" + date.day)}
|
|
87
188
|
{@const isSelected = isSameDate(value, date)}
|
|
88
189
|
{@const isTodayDate = isToday(date)}
|
|
89
190
|
{@const isDisabledDate = isDisabled(date)}
|
|
90
191
|
<button
|
|
192
|
+
type="button"
|
|
91
193
|
onclick={() => selectDate(date)}
|
|
92
194
|
disabled={disabled || isDisabledDate}
|
|
195
|
+
style="width: 2.25rem; height: 2.25rem; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box;"
|
|
93
196
|
class="flex items-center justify-center size-9 rounded-lg text-sm transition-colors cursor-pointer
|
|
94
|
-
{isSelected
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
{
|
|
197
|
+
{isSelected
|
|
198
|
+
? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-semibold'
|
|
199
|
+
: ''}
|
|
200
|
+
{!isSelected && currentMonth
|
|
201
|
+
? 'text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
|
|
202
|
+
: ''}
|
|
203
|
+
{!isSelected && !currentMonth
|
|
204
|
+
? 'text-[var(--ui-muted-foreground)]/40'
|
|
205
|
+
: ''}
|
|
206
|
+
{isTodayDate && !isSelected
|
|
207
|
+
? 'ring-2 ring-[var(--ui-primary)]/30 font-medium'
|
|
208
|
+
: ''}
|
|
98
209
|
{isDisabledDate || disabled ? 'opacity-40 cursor-not-allowed' : ''}"
|
|
99
|
-
|
|
210
|
+
>{day}</button
|
|
211
|
+
>
|
|
100
212
|
{/each}
|
|
101
213
|
</div>
|
|
102
214
|
</div>
|
package/dist/DatePicker.svelte
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
import {
|
|
3
|
+
Popover as PopoverRoot,
|
|
4
|
+
PopoverTrigger,
|
|
5
|
+
PopoverContent,
|
|
6
|
+
Button,
|
|
7
|
+
Input,
|
|
8
|
+
} from "@intinyagroup/ui";
|
|
9
|
+
const Popover = {
|
|
10
|
+
Root: PopoverRoot,
|
|
11
|
+
Trigger: PopoverTrigger,
|
|
12
|
+
Content: PopoverContent,
|
|
13
|
+
};
|
|
14
|
+
import Calendar, { type CalendarDate } from "./Calendar.svelte";
|
|
15
|
+
import { Calendar as CalendarIcon, X } from "lucide-svelte";
|
|
16
|
+
import { cn } from "@intinyagroup/ui/utils";
|
|
7
17
|
|
|
8
18
|
let {
|
|
9
19
|
value = $bindable(null),
|
|
10
|
-
placeholder =
|
|
20
|
+
placeholder = "Select date...",
|
|
11
21
|
disabled = false,
|
|
12
22
|
class: className,
|
|
13
23
|
onSelect,
|
|
@@ -22,9 +32,13 @@
|
|
|
22
32
|
let open = $state(false);
|
|
23
33
|
|
|
24
34
|
function formatDateDisplay(date: CalendarDate | null): string {
|
|
25
|
-
if (!date) return
|
|
35
|
+
if (!date) return "";
|
|
26
36
|
const d = new Date(date.year, date.month, date.day);
|
|
27
|
-
return d.toLocaleDateString(
|
|
37
|
+
return d.toLocaleDateString("en-GB", {
|
|
38
|
+
day: "numeric",
|
|
39
|
+
month: "short",
|
|
40
|
+
year: "numeric",
|
|
41
|
+
});
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
function handleSelect(date: CalendarDate) {
|
|
@@ -45,14 +59,14 @@
|
|
|
45
59
|
<button
|
|
46
60
|
{...props}
|
|
47
61
|
class={cn(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
disabled &&
|
|
51
|
-
className
|
|
62
|
+
"flex h-10 w-full items-center justify-between rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] px-3 py-2 text-sm text-[var(--ui-foreground)] transition-colors cursor-pointer",
|
|
63
|
+
"hover:border-[var(--ui-primary)]/50 focus:border-[var(--ui-primary)] focus:ring-2 focus:ring-[var(--ui-ring)]/20",
|
|
64
|
+
disabled && "opacity-50 cursor-not-allowed",
|
|
65
|
+
className,
|
|
52
66
|
)}
|
|
53
67
|
{disabled}
|
|
54
68
|
>
|
|
55
|
-
<span class={cn(!value &&
|
|
69
|
+
<span class={cn(!value && "text-[var(--ui-muted-foreground)]")}>
|
|
56
70
|
{value ? formatDateDisplay(value) : placeholder}
|
|
57
71
|
</span>
|
|
58
72
|
<CalendarIcon class="size-4 text-[var(--ui-muted-foreground)]" />
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Calendar as CalendarIcon,
|
|
4
|
+
ChevronLeft,
|
|
5
|
+
ChevronRight,
|
|
6
|
+
X,
|
|
7
|
+
} from "lucide-svelte";
|
|
8
|
+
import { Button } from "@intinyagroup/ui";
|
|
9
|
+
import { cn } from "@intinyagroup/ui/utils";
|
|
10
|
+
import {
|
|
11
|
+
addMonths,
|
|
12
|
+
subMonths,
|
|
13
|
+
addDays,
|
|
14
|
+
subDays,
|
|
15
|
+
startOfMonth,
|
|
16
|
+
endOfMonth,
|
|
17
|
+
startOfWeek,
|
|
18
|
+
endOfWeek,
|
|
19
|
+
eachDayOfInterval,
|
|
20
|
+
isSameDay,
|
|
21
|
+
isSameMonth,
|
|
22
|
+
isWithinInterval,
|
|
23
|
+
isBefore,
|
|
24
|
+
format,
|
|
25
|
+
} from "date-fns";
|
|
26
|
+
import { id as localeId, enUS as localeEn } from "date-fns/locale";
|
|
27
|
+
|
|
28
|
+
export type CalendarDate = { year: number; month: number; day: number };
|
|
29
|
+
export type DateRange = {
|
|
30
|
+
start: CalendarDate | null;
|
|
31
|
+
end: CalendarDate | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type DateRangePreset = {
|
|
35
|
+
label: string;
|
|
36
|
+
range: DateRange;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let {
|
|
40
|
+
value = $bindable({ start: null, end: null }),
|
|
41
|
+
min = null,
|
|
42
|
+
max = null,
|
|
43
|
+
disabled = false,
|
|
44
|
+
placeholder = "Select date range...",
|
|
45
|
+
locale = "en-US",
|
|
46
|
+
firstDayOfWeek = 0, // 0 = Sunday, 1 = Monday
|
|
47
|
+
presets,
|
|
48
|
+
class: className,
|
|
49
|
+
onSelect,
|
|
50
|
+
}: {
|
|
51
|
+
value?: DateRange;
|
|
52
|
+
min?: CalendarDate | null;
|
|
53
|
+
max?: CalendarDate | null;
|
|
54
|
+
disabled?: boolean;
|
|
55
|
+
placeholder?: string;
|
|
56
|
+
locale?: string;
|
|
57
|
+
firstDayOfWeek?: number;
|
|
58
|
+
presets?: DateRangePreset[];
|
|
59
|
+
class?: string;
|
|
60
|
+
onSelect?: (range: DateRange) => void;
|
|
61
|
+
} = $props();
|
|
62
|
+
|
|
63
|
+
let isOpen = $state(false);
|
|
64
|
+
const today = new Date();
|
|
65
|
+
|
|
66
|
+
let viewDate = $state(
|
|
67
|
+
value?.start
|
|
68
|
+
? new Date(value.start.year, value.start.month, value.start.day)
|
|
69
|
+
: today,
|
|
70
|
+
);
|
|
71
|
+
let hoverDate = $state<Date | null>(null);
|
|
72
|
+
|
|
73
|
+
const activeDateFnsLocale = $derived(
|
|
74
|
+
locale.startsWith("id") ? localeId : localeEn,
|
|
75
|
+
);
|
|
76
|
+
const weekStartsOn = $derived(firstDayOfWeek as 0 | 1 | 2 | 3 | 4 | 5 | 6);
|
|
77
|
+
|
|
78
|
+
function toDate(d: CalendarDate | null): Date | null {
|
|
79
|
+
if (!d) return null;
|
|
80
|
+
return new Date(d.year, d.month, d.day);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toCalendarDate(date: Date): CalendarDate {
|
|
84
|
+
return {
|
|
85
|
+
year: date.getFullYear(),
|
|
86
|
+
month: date.getMonth(),
|
|
87
|
+
day: date.getDate(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Generate day names for header using date-fns
|
|
92
|
+
const dayNames = $derived.by(() => {
|
|
93
|
+
const start = startOfWeek(viewDate, { weekStartsOn });
|
|
94
|
+
return Array.from({ length: 7 }, (_, i) => {
|
|
95
|
+
return format(addDays(start, i), "EEE", { locale: activeDateFnsLocale });
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Generate full calendar grid (days from start of week of month-start to end of week of month-end)
|
|
100
|
+
const calendarDays = $derived.by(() => {
|
|
101
|
+
const monthStart = startOfMonth(viewDate);
|
|
102
|
+
const monthEnd = endOfMonth(viewDate);
|
|
103
|
+
const gridStart = startOfWeek(monthStart, { weekStartsOn });
|
|
104
|
+
const gridEnd = endOfWeek(monthEnd, { weekStartsOn });
|
|
105
|
+
|
|
106
|
+
const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
|
|
107
|
+
return days.map((d) => ({
|
|
108
|
+
date: d,
|
|
109
|
+
calendarDate: toCalendarDate(d),
|
|
110
|
+
currentMonth: isSameMonth(d, viewDate),
|
|
111
|
+
}));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const startDateObj = $derived(toDate(value.start));
|
|
115
|
+
const endDateObj = $derived(toDate(value.end));
|
|
116
|
+
|
|
117
|
+
function handleDateClick(date: Date) {
|
|
118
|
+
if (disabled) return;
|
|
119
|
+
|
|
120
|
+
if (!startDateObj || (startDateObj && endDateObj)) {
|
|
121
|
+
value = { start: toCalendarDate(date), end: null };
|
|
122
|
+
} else if (startDateObj && !endDateObj) {
|
|
123
|
+
if (isBefore(date, startDateObj)) {
|
|
124
|
+
value = {
|
|
125
|
+
start: toCalendarDate(date),
|
|
126
|
+
end: toCalendarDate(startDateObj),
|
|
127
|
+
};
|
|
128
|
+
} else {
|
|
129
|
+
value = {
|
|
130
|
+
start: toCalendarDate(startDateObj),
|
|
131
|
+
end: toCalendarDate(date),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
onSelect?.(value);
|
|
135
|
+
isOpen = false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function prevMonth() {
|
|
140
|
+
viewDate = subMonths(viewDate, 1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function nextMonth() {
|
|
144
|
+
viewDate = addMonths(viewDate, 1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function formatDisplayDate(date: CalendarDate | null): string {
|
|
148
|
+
const d = toDate(date);
|
|
149
|
+
if (!d) return "";
|
|
150
|
+
return format(d, "dd/MM/yyyy", { locale: activeDateFnsLocale });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const label = $derived.by(() => {
|
|
154
|
+
if (value.start && value.end) {
|
|
155
|
+
return `${formatDisplayDate(value.start)} - ${formatDisplayDate(value.end)}`;
|
|
156
|
+
}
|
|
157
|
+
if (value.start) {
|
|
158
|
+
return `${formatDisplayDate(value.start)} - ...`;
|
|
159
|
+
}
|
|
160
|
+
return placeholder;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
function clearRange(e: MouseEvent) {
|
|
164
|
+
e.stopPropagation();
|
|
165
|
+
value = { start: null, end: null };
|
|
166
|
+
onSelect?.(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Rich presets using date-fns intervals
|
|
170
|
+
const activePresets = $derived.by<DateRangePreset[]>(() => {
|
|
171
|
+
if (presets) return presets;
|
|
172
|
+
|
|
173
|
+
const y = today.getFullYear();
|
|
174
|
+
|
|
175
|
+
return [
|
|
176
|
+
{
|
|
177
|
+
label: "Today",
|
|
178
|
+
range: { start: toCalendarDate(today), end: toCalendarDate(today) },
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
label: "Next 7D",
|
|
182
|
+
range: {
|
|
183
|
+
start: toCalendarDate(today),
|
|
184
|
+
end: toCalendarDate(addDays(today, 7)),
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
label: "This Month",
|
|
189
|
+
range: {
|
|
190
|
+
start: toCalendarDate(startOfMonth(today)),
|
|
191
|
+
end: toCalendarDate(endOfMonth(today)),
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
label: "Last Month",
|
|
196
|
+
range: {
|
|
197
|
+
start: toCalendarDate(startOfMonth(subMonths(today, 1))),
|
|
198
|
+
end: toCalendarDate(endOfMonth(subMonths(today, 1))),
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
label: "YTD",
|
|
203
|
+
range: {
|
|
204
|
+
start: { year: y, month: 0, day: 1 },
|
|
205
|
+
end: toCalendarDate(today),
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
});
|
|
210
|
+
</script>
|
|
211
|
+
|
|
212
|
+
<div class={cn("relative inline-block w-full max-w-sm", className)}>
|
|
213
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
214
|
+
<div
|
|
215
|
+
role="button"
|
|
216
|
+
tabindex={disabled ? -1 : 0}
|
|
217
|
+
aria-disabled={disabled}
|
|
218
|
+
onclick={() => {
|
|
219
|
+
if (!disabled) isOpen = !isOpen;
|
|
220
|
+
}}
|
|
221
|
+
onkeydown={(e) => {
|
|
222
|
+
if (disabled) return;
|
|
223
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
isOpen = !isOpen;
|
|
226
|
+
}
|
|
227
|
+
}}
|
|
228
|
+
class={cn(
|
|
229
|
+
"flex h-10 w-full items-center justify-between rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] px-3 text-sm transition-colors text-left cursor-pointer select-none",
|
|
230
|
+
"hover:border-[var(--ui-border)] focus:outline-none focus:ring-2 focus:ring-[var(--ui-ring)]/20 focus:border-[var(--ui-primary)]",
|
|
231
|
+
disabled && "opacity-50 cursor-not-allowed pointer-events-none",
|
|
232
|
+
value.start
|
|
233
|
+
? "text-[var(--ui-foreground)]"
|
|
234
|
+
: "text-[var(--ui-muted-foreground)]",
|
|
235
|
+
)}
|
|
236
|
+
>
|
|
237
|
+
<div class="flex items-center gap-2 truncate">
|
|
238
|
+
<CalendarIcon class="size-4 shrink-0 text-[var(--ui-muted-foreground)]" />
|
|
239
|
+
<span class="truncate">{label}</span>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{#if value.start && !disabled}
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
onclick={(e) => {
|
|
246
|
+
e.stopPropagation();
|
|
247
|
+
clearRange();
|
|
248
|
+
}}
|
|
249
|
+
class="p-1 rounded-full hover:bg-[var(--ui-secondary)] text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]"
|
|
250
|
+
aria-label="Clear date range"
|
|
251
|
+
>
|
|
252
|
+
<X class="size-3.5" />
|
|
253
|
+
</button>
|
|
254
|
+
{/if}
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
{#if isOpen}
|
|
258
|
+
<!-- Backdrop for click outside -->
|
|
259
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
260
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
261
|
+
<div class="fixed inset-0 z-40" onclick={() => (isOpen = false)}></div>
|
|
262
|
+
|
|
263
|
+
<div
|
|
264
|
+
class="absolute left-0 top-full z-50 mt-1.5 w-80 rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] p-3.5 shadow-xl animate-in fade-in-50 zoom-in-95 select-none"
|
|
265
|
+
>
|
|
266
|
+
<!-- Header controls -->
|
|
267
|
+
<div class="flex items-center justify-between mb-3">
|
|
268
|
+
<Button
|
|
269
|
+
variant="ghost"
|
|
270
|
+
size="sm"
|
|
271
|
+
class="size-7 p-0"
|
|
272
|
+
onclick={prevMonth}
|
|
273
|
+
>
|
|
274
|
+
<ChevronLeft class="size-4" />
|
|
275
|
+
</Button>
|
|
276
|
+
<span
|
|
277
|
+
class="text-xs font-semibold text-[var(--ui-foreground)] capitalize"
|
|
278
|
+
>
|
|
279
|
+
{format(viewDate, "MMMM yyyy", { locale: activeDateFnsLocale })}
|
|
280
|
+
</span>
|
|
281
|
+
<Button
|
|
282
|
+
variant="ghost"
|
|
283
|
+
size="sm"
|
|
284
|
+
class="size-7 p-0"
|
|
285
|
+
onclick={nextMonth}
|
|
286
|
+
>
|
|
287
|
+
<ChevronRight class="size-4" />
|
|
288
|
+
</Button>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<!-- Day names -->
|
|
292
|
+
<div
|
|
293
|
+
class="grid grid-cols-7 mb-1 text-center"
|
|
294
|
+
style="display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); text-align: center; margin-bottom: 0.25rem;"
|
|
295
|
+
>
|
|
296
|
+
{#each dayNames as day}
|
|
297
|
+
<span
|
|
298
|
+
class="text-[11px] font-semibold text-[var(--ui-muted-foreground)] py-1 capitalize"
|
|
299
|
+
style="text-align: center; font-size: 0.6875rem; padding: 0.25rem 0;"
|
|
300
|
+
>
|
|
301
|
+
{day}
|
|
302
|
+
</span>
|
|
303
|
+
{/each}
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<!-- Calendar grid -->
|
|
307
|
+
<div
|
|
308
|
+
class="grid grid-cols-7 gap-y-1"
|
|
309
|
+
style="display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); row-gap: 0.25rem;"
|
|
310
|
+
>
|
|
311
|
+
{#each calendarDays as { date, calendarDate, currentMonth }}
|
|
312
|
+
{@const isStart = startDateObj
|
|
313
|
+
? isSameDay(startDateObj, date)
|
|
314
|
+
: false}
|
|
315
|
+
{@const isEnd = endDateObj ? isSameDay(endDateObj, date) : false}
|
|
316
|
+
{@const activeRangeEnd = endDateObj || hoverDate}
|
|
317
|
+
{@const inRange =
|
|
318
|
+
startDateObj && activeRangeEnd
|
|
319
|
+
? isWithinInterval(date, {
|
|
320
|
+
start: isBefore(startDateObj, activeRangeEnd)
|
|
321
|
+
? startDateObj
|
|
322
|
+
: activeRangeEnd,
|
|
323
|
+
end: isBefore(startDateObj, activeRangeEnd)
|
|
324
|
+
? activeRangeEnd
|
|
325
|
+
: startDateObj,
|
|
326
|
+
})
|
|
327
|
+
: false}
|
|
328
|
+
{@const isEdge = isStart || isEnd}
|
|
329
|
+
|
|
330
|
+
<div
|
|
331
|
+
role="gridcell"
|
|
332
|
+
tabindex="-1"
|
|
333
|
+
aria-selected={isEdge}
|
|
334
|
+
class={cn(
|
|
335
|
+
"relative py-0.5 flex items-center justify-center",
|
|
336
|
+
inRange && !isEdge && "bg-[var(--ui-primary)]/10",
|
|
337
|
+
isStart &&
|
|
338
|
+
activeRangeEnd &&
|
|
339
|
+
!isSameDay(startDateObj, activeRangeEnd) &&
|
|
340
|
+
"rounded-l-md bg-[var(--ui-primary)]/10",
|
|
341
|
+
isEnd &&
|
|
342
|
+
startDateObj &&
|
|
343
|
+
!isSameDay(startDateObj, endDateObj) &&
|
|
344
|
+
"rounded-r-md bg-[var(--ui-primary)]/10",
|
|
345
|
+
)}
|
|
346
|
+
onmouseenter={() => {
|
|
347
|
+
if (startDateObj && !endDateObj) hoverDate = date;
|
|
348
|
+
}}
|
|
349
|
+
>
|
|
350
|
+
<button
|
|
351
|
+
type="button"
|
|
352
|
+
disabled={!currentMonth}
|
|
353
|
+
onclick={() => handleDateClick(date)}
|
|
354
|
+
class={cn(
|
|
355
|
+
"size-8 rounded-md text-xs font-medium transition-colors flex items-center justify-center cursor-pointer",
|
|
356
|
+
!currentMonth && "opacity-20 cursor-default",
|
|
357
|
+
currentMonth &&
|
|
358
|
+
!isEdge &&
|
|
359
|
+
!inRange &&
|
|
360
|
+
"hover:bg-[var(--ui-secondary)] text-[var(--ui-foreground)]",
|
|
361
|
+
inRange && !isEdge && "text-[var(--ui-primary)] font-semibold",
|
|
362
|
+
isEdge &&
|
|
363
|
+
"bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs",
|
|
364
|
+
)}
|
|
365
|
+
>
|
|
366
|
+
{date.getDate()}
|
|
367
|
+
</button>
|
|
368
|
+
</div>
|
|
369
|
+
{/each}
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<!-- Presets quick buttons bar -->
|
|
373
|
+
<div
|
|
374
|
+
class="flex flex-wrap items-center gap-1.5 border-t border-[var(--ui-border)] mt-3 pt-2.5"
|
|
375
|
+
>
|
|
376
|
+
{#each activePresets as preset}
|
|
377
|
+
<button
|
|
378
|
+
type="button"
|
|
379
|
+
onclick={() => {
|
|
380
|
+
value = preset.range;
|
|
381
|
+
onSelect?.(value);
|
|
382
|
+
isOpen = false;
|
|
383
|
+
}}
|
|
384
|
+
class="rounded-md border border-[var(--ui-border)] bg-[var(--ui-secondary)]/30 px-2 py-1 text-[10px] font-medium text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)] transition-colors cursor-pointer"
|
|
385
|
+
>
|
|
386
|
+
{preset.label}
|
|
387
|
+
</button>
|
|
388
|
+
{/each}
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
{/if}
|
|
392
|
+
</div>
|