@intinyagroup/calendar 0.1.1-alpha.11
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 +102 -0
- package/dist/Calendar.svelte.d.ts +16 -0
- package/dist/DatePicker.svelte +73 -0
- package/dist/DatePicker.svelte.d.ts +11 -0
- package/dist/DateRangePicker.svelte +310 -0
- package/dist/DateRangePicker.svelte.d.ts +28 -0
- package/dist/EventCalendar.svelte +568 -0
- package/dist/EventCalendar.svelte.d.ts +26 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/package.json +43 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
|
|
3
|
+
import { Button } from '@intinyagroup/ui';
|
|
4
|
+
import { cn } from '@intinyagroup/ui/utils';
|
|
5
|
+
|
|
6
|
+
export type CalendarDate = { year: number; month: number; day: number };
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
value = $bindable(null),
|
|
10
|
+
min = null,
|
|
11
|
+
max = null,
|
|
12
|
+
disabled = false,
|
|
13
|
+
class: className,
|
|
14
|
+
onSelect,
|
|
15
|
+
}: {
|
|
16
|
+
value?: CalendarDate | null;
|
|
17
|
+
min?: CalendarDate | null;
|
|
18
|
+
max?: CalendarDate | null;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
class?: string;
|
|
21
|
+
onSelect?: (date: CalendarDate) => void;
|
|
22
|
+
} = $props();
|
|
23
|
+
|
|
24
|
+
const today = new Date();
|
|
25
|
+
let viewYear = $state(value?.year ?? today.getFullYear());
|
|
26
|
+
let viewMonth = $state(value?.month ?? today.getMonth());
|
|
27
|
+
|
|
28
|
+
const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
|
29
|
+
const dayNames = ['Su','Mo','Tu','We','Th','Fr','Sa'];
|
|
30
|
+
const daysInMonth = $derived(new Date(viewYear, viewMonth + 1, 0).getDate());
|
|
31
|
+
const firstDayOfMonth = $derived(new Date(viewYear, viewMonth, 1).getDay());
|
|
32
|
+
|
|
33
|
+
const calendarDays = $derived.by(() => {
|
|
34
|
+
const days: { day: number; currentMonth: boolean; date: CalendarDate }[] = [];
|
|
35
|
+
const prevMonthDays = new Date(viewYear, viewMonth, 0).getDate();
|
|
36
|
+
for (let i = firstDayOfMonth - 1; i >= 0; i--) {
|
|
37
|
+
const day = prevMonthDays - i;
|
|
38
|
+
const month = viewMonth === 0 ? 11 : viewMonth - 1;
|
|
39
|
+
const year = viewMonth === 0 ? viewYear - 1 : viewYear;
|
|
40
|
+
days.push({ day, currentMonth: false, date: { year, month, day } });
|
|
41
|
+
}
|
|
42
|
+
for (let day = 1; day <= daysInMonth; day++) {
|
|
43
|
+
days.push({ day, currentMonth: true, date: { year: viewYear, month: viewMonth, day } });
|
|
44
|
+
}
|
|
45
|
+
const remaining = 42 - days.length;
|
|
46
|
+
for (let day = 1; day <= remaining; day++) {
|
|
47
|
+
const month = viewMonth === 11 ? 0 : viewMonth + 1;
|
|
48
|
+
const year = viewMonth === 11 ? viewYear + 1 : viewYear;
|
|
49
|
+
days.push({ day, currentMonth: false, date: { year, month, day } });
|
|
50
|
+
}
|
|
51
|
+
return days;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function isSameDate(a: CalendarDate | null, b: CalendarDate) {
|
|
55
|
+
return a?.year === b.year && a?.month === b.month && a?.day === b.day;
|
|
56
|
+
}
|
|
57
|
+
function isToday(date: CalendarDate) {
|
|
58
|
+
return date.year === today.getFullYear() && date.month === today.getMonth() && date.day === today.getDate();
|
|
59
|
+
}
|
|
60
|
+
function isDisabled(date: CalendarDate) {
|
|
61
|
+
if (min && (date.year < min.year || (date.year === min.year && date.month < min.month) || (date.year === min.year && date.month === min.month && date.day < min.day))) return true;
|
|
62
|
+
if (max && (date.year > max.year || (date.year === max.year && date.month > max.month) || (date.year === max.year && date.month === max.month && date.day > max.day))) return true;
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
function prevMonth() { if (viewMonth === 0) { viewMonth = 11; viewYear--; } else { viewMonth--; } }
|
|
66
|
+
function nextMonth() { if (viewMonth === 11) { viewMonth = 0; viewYear++; } else { viewMonth++; } }
|
|
67
|
+
function selectDate(date: CalendarDate) { if (disabled || isDisabled(date)) return; value = date; onSelect?.(date); }
|
|
68
|
+
function goToday() { viewYear = today.getFullYear(); viewMonth = today.getMonth(); selectDate({ year: today.getFullYear(), month: today.getMonth(), day: today.getDate() }); }
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<div class={cn('inline-flex flex-col rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] p-4 shadow-sm', className)}>
|
|
72
|
+
<div class="flex items-center justify-between mb-4">
|
|
73
|
+
<Button variant="ghost" size="sm" onclick={prevMonth} {disabled}><ChevronLeft class="size-4" /></Button>
|
|
74
|
+
<div class="flex items-center gap-2">
|
|
75
|
+
<span class="text-sm font-semibold text-[var(--ui-foreground)]">{monthNames[viewMonth]} {viewYear}</span>
|
|
76
|
+
<Button variant="ghost" size="sm" onclick={goToday} {disabled} class="text-xs h-6 px-2">Today</Button>
|
|
77
|
+
</div>
|
|
78
|
+
<Button variant="ghost" size="sm" onclick={nextMonth} {disabled}><ChevronRight class="size-4" /></Button>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div class="grid grid-cols-7 gap-1 mb-2">
|
|
82
|
+
{#each dayNames as d}<div class="text-center text-[11px] font-medium text-[var(--ui-muted-foreground)] py-1">{d}</div>{/each}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div class="grid grid-cols-7 gap-1">
|
|
86
|
+
{#each calendarDays as { day, currentMonth, date } (date.year + '-' + date.month + '-' + date.day)}
|
|
87
|
+
{@const isSelected = isSameDate(value, date)}
|
|
88
|
+
{@const isTodayDate = isToday(date)}
|
|
89
|
+
{@const isDisabledDate = isDisabled(date)}
|
|
90
|
+
<button
|
|
91
|
+
onclick={() => selectDate(date)}
|
|
92
|
+
disabled={disabled || isDisabledDate}
|
|
93
|
+
class="flex items-center justify-center size-9 rounded-lg text-sm transition-colors cursor-pointer
|
|
94
|
+
{isSelected ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-semibold' : ''}
|
|
95
|
+
{!isSelected && currentMonth ? 'text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]' : ''}
|
|
96
|
+
{!isSelected && !currentMonth ? 'text-[var(--ui-muted-foreground)]/40' : ''}
|
|
97
|
+
{isTodayDate && !isSelected ? 'ring-2 ring-[var(--ui-primary)]/30 font-medium' : ''}
|
|
98
|
+
{isDisabledDate || disabled ? 'opacity-40 cursor-not-allowed' : ''}"
|
|
99
|
+
>{day}</button>
|
|
100
|
+
{/each}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type CalendarDate = {
|
|
2
|
+
year: number;
|
|
3
|
+
month: number;
|
|
4
|
+
day: number;
|
|
5
|
+
};
|
|
6
|
+
type $$ComponentProps = {
|
|
7
|
+
value?: CalendarDate | null;
|
|
8
|
+
min?: CalendarDate | null;
|
|
9
|
+
max?: CalendarDate | null;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
class?: string;
|
|
12
|
+
onSelect?: (date: CalendarDate) => void;
|
|
13
|
+
};
|
|
14
|
+
declare const Calendar: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
15
|
+
type Calendar = ReturnType<typeof Calendar>;
|
|
16
|
+
export default Calendar;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import * as Popover from '@intinyagroup/ui';
|
|
3
|
+
import { Button, Input } from '@intinyagroup/ui';
|
|
4
|
+
import { Calendar, type CalendarDate } from '@intinyagroup/calendar';
|
|
5
|
+
import { Calendar as CalendarIcon, X } from 'lucide-svelte';
|
|
6
|
+
import { cn } from '@intinyagroup/grid-core/utils';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
value = $bindable(null),
|
|
10
|
+
placeholder = 'Select date...',
|
|
11
|
+
disabled = false,
|
|
12
|
+
class: className,
|
|
13
|
+
onSelect,
|
|
14
|
+
}: {
|
|
15
|
+
value?: CalendarDate | null;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
class?: string;
|
|
19
|
+
onSelect?: (date: CalendarDate) => void;
|
|
20
|
+
} = $props();
|
|
21
|
+
|
|
22
|
+
let open = $state(false);
|
|
23
|
+
|
|
24
|
+
function formatDateDisplay(date: CalendarDate | null): string {
|
|
25
|
+
if (!date) return '';
|
|
26
|
+
const d = new Date(date.year, date.month, date.day);
|
|
27
|
+
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function handleSelect(date: CalendarDate) {
|
|
31
|
+
value = date;
|
|
32
|
+
onSelect?.(date);
|
|
33
|
+
open = false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clear() {
|
|
37
|
+
value = null;
|
|
38
|
+
onSelect?.(null as any);
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<Popover.Root bind:open>
|
|
43
|
+
<Popover.Trigger>
|
|
44
|
+
{#snippet child({ props })}
|
|
45
|
+
<button
|
|
46
|
+
{...props}
|
|
47
|
+
class={cn(
|
|
48
|
+
'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',
|
|
49
|
+
'hover:border-[var(--ui-primary)]/50 focus:border-[var(--ui-primary)] focus:ring-2 focus:ring-[var(--ui-ring)]/20',
|
|
50
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
51
|
+
className
|
|
52
|
+
)}
|
|
53
|
+
{disabled}
|
|
54
|
+
>
|
|
55
|
+
<span class={cn(!value && 'text-[var(--ui-muted-foreground)]')}>
|
|
56
|
+
{value ? formatDateDisplay(value) : placeholder}
|
|
57
|
+
</span>
|
|
58
|
+
<CalendarIcon class="size-4 text-[var(--ui-muted-foreground)]" />
|
|
59
|
+
</button>
|
|
60
|
+
{/snippet}
|
|
61
|
+
</Popover.Trigger>
|
|
62
|
+
|
|
63
|
+
<Popover.Content align="start" class="w-auto p-0">
|
|
64
|
+
<Calendar {value} onSelect={handleSelect} />
|
|
65
|
+
{#if value}
|
|
66
|
+
<div class="flex justify-end p-2 border-t border-[var(--ui-border)]">
|
|
67
|
+
<Button variant="ghost" size="sm" onclick={clear}>
|
|
68
|
+
<X class="size-3.5 mr-1" /> Clear
|
|
69
|
+
</Button>
|
|
70
|
+
</div>
|
|
71
|
+
{/if}
|
|
72
|
+
</Popover.Content>
|
|
73
|
+
</Popover.Root>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type CalendarDate } from '@intinyagroup/calendar';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
value?: CalendarDate | null;
|
|
4
|
+
placeholder?: string;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
class?: string;
|
|
7
|
+
onSelect?: (date: CalendarDate) => void;
|
|
8
|
+
};
|
|
9
|
+
declare const DatePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
10
|
+
type DatePicker = ReturnType<typeof DatePicker>;
|
|
11
|
+
export default DatePicker;
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, X } from 'lucide-svelte';
|
|
3
|
+
import { Button } from '@intinyagroup/ui';
|
|
4
|
+
import { cn } from '@intinyagroup/ui/utils';
|
|
5
|
+
import {
|
|
6
|
+
addMonths,
|
|
7
|
+
subMonths,
|
|
8
|
+
addDays,
|
|
9
|
+
subDays,
|
|
10
|
+
startOfMonth,
|
|
11
|
+
endOfMonth,
|
|
12
|
+
startOfWeek,
|
|
13
|
+
endOfWeek,
|
|
14
|
+
eachDayOfInterval,
|
|
15
|
+
isSameDay,
|
|
16
|
+
isSameMonth,
|
|
17
|
+
isWithinInterval,
|
|
18
|
+
isBefore,
|
|
19
|
+
format
|
|
20
|
+
} from 'date-fns';
|
|
21
|
+
import { id as localeId, enUS as localeEn } from 'date-fns/locale';
|
|
22
|
+
|
|
23
|
+
export type CalendarDate = { year: number; month: number; day: number };
|
|
24
|
+
export type DateRange = {
|
|
25
|
+
start: CalendarDate | null;
|
|
26
|
+
end: CalendarDate | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type DateRangePreset = {
|
|
30
|
+
label: string;
|
|
31
|
+
range: DateRange;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
let {
|
|
35
|
+
value = $bindable({ start: null, end: null }),
|
|
36
|
+
min = null,
|
|
37
|
+
max = null,
|
|
38
|
+
disabled = false,
|
|
39
|
+
placeholder = 'Select date range...',
|
|
40
|
+
locale = 'en-US',
|
|
41
|
+
firstDayOfWeek = 0, // 0 = Sunday, 1 = Monday
|
|
42
|
+
presets,
|
|
43
|
+
class: className,
|
|
44
|
+
onSelect,
|
|
45
|
+
}: {
|
|
46
|
+
value?: DateRange;
|
|
47
|
+
min?: CalendarDate | null;
|
|
48
|
+
max?: CalendarDate | null;
|
|
49
|
+
disabled?: boolean;
|
|
50
|
+
placeholder?: string;
|
|
51
|
+
locale?: string;
|
|
52
|
+
firstDayOfWeek?: number;
|
|
53
|
+
presets?: DateRangePreset[];
|
|
54
|
+
class?: string;
|
|
55
|
+
onSelect?: (range: DateRange) => void;
|
|
56
|
+
} = $props();
|
|
57
|
+
|
|
58
|
+
let isOpen = $state(false);
|
|
59
|
+
const today = new Date();
|
|
60
|
+
|
|
61
|
+
let viewDate = $state(
|
|
62
|
+
value?.start ? new Date(value.start.year, value.start.month, value.start.day) : today
|
|
63
|
+
);
|
|
64
|
+
let hoverDate = $state<Date | null>(null);
|
|
65
|
+
|
|
66
|
+
const activeDateFnsLocale = $derived(locale.startsWith('id') ? localeId : localeEn);
|
|
67
|
+
const weekStartsOn = $derived(firstDayOfWeek as 0 | 1 | 2 | 3 | 4 | 5 | 6);
|
|
68
|
+
|
|
69
|
+
function toDate(d: CalendarDate | null): Date | null {
|
|
70
|
+
if (!d) return null;
|
|
71
|
+
return new Date(d.year, d.month, d.day);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toCalendarDate(date: Date): CalendarDate {
|
|
75
|
+
return {
|
|
76
|
+
year: date.getFullYear(),
|
|
77
|
+
month: date.getMonth(),
|
|
78
|
+
day: date.getDate()
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Generate day names for header using date-fns
|
|
83
|
+
const dayNames = $derived.by(() => {
|
|
84
|
+
const start = startOfWeek(viewDate, { weekStartsOn });
|
|
85
|
+
return Array.from({ length: 7 }, (_, i) => {
|
|
86
|
+
return format(addDays(start, i), 'EEE', { locale: activeDateFnsLocale });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Generate full calendar grid (days from start of week of month-start to end of week of month-end)
|
|
91
|
+
const calendarDays = $derived.by(() => {
|
|
92
|
+
const monthStart = startOfMonth(viewDate);
|
|
93
|
+
const monthEnd = endOfMonth(viewDate);
|
|
94
|
+
const gridStart = startOfWeek(monthStart, { weekStartsOn });
|
|
95
|
+
const gridEnd = endOfWeek(monthEnd, { weekStartsOn });
|
|
96
|
+
|
|
97
|
+
const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
|
|
98
|
+
return days.map((d) => ({
|
|
99
|
+
date: d,
|
|
100
|
+
calendarDate: toCalendarDate(d),
|
|
101
|
+
currentMonth: isSameMonth(d, viewDate)
|
|
102
|
+
}));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const startDateObj = $derived(toDate(value.start));
|
|
106
|
+
const endDateObj = $derived(toDate(value.end));
|
|
107
|
+
|
|
108
|
+
function handleDateClick(date: Date) {
|
|
109
|
+
if (disabled) return;
|
|
110
|
+
|
|
111
|
+
if (!startDateObj || (startDateObj && endDateObj)) {
|
|
112
|
+
value = { start: toCalendarDate(date), end: null };
|
|
113
|
+
} else if (startDateObj && !endDateObj) {
|
|
114
|
+
if (isBefore(date, startDateObj)) {
|
|
115
|
+
value = { start: toCalendarDate(date), end: toCalendarDate(startDateObj) };
|
|
116
|
+
} else {
|
|
117
|
+
value = { start: toCalendarDate(startDateObj), end: toCalendarDate(date) };
|
|
118
|
+
}
|
|
119
|
+
onSelect?.(value);
|
|
120
|
+
isOpen = false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function prevMonth() {
|
|
125
|
+
viewDate = subMonths(viewDate, 1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function nextMonth() {
|
|
129
|
+
viewDate = addMonths(viewDate, 1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function formatDisplayDate(date: CalendarDate | null): string {
|
|
133
|
+
const d = toDate(date);
|
|
134
|
+
if (!d) return '';
|
|
135
|
+
return format(d, 'dd/MM/yyyy', { locale: activeDateFnsLocale });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const label = $derived.by(() => {
|
|
139
|
+
if (value.start && value.end) {
|
|
140
|
+
return `${formatDisplayDate(value.start)} - ${formatDisplayDate(value.end)}`;
|
|
141
|
+
}
|
|
142
|
+
if (value.start) {
|
|
143
|
+
return `${formatDisplayDate(value.start)} - ...`;
|
|
144
|
+
}
|
|
145
|
+
return placeholder;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
function clearRange(e: MouseEvent) {
|
|
149
|
+
e.stopPropagation();
|
|
150
|
+
value = { start: null, end: null };
|
|
151
|
+
onSelect?.(value);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Rich presets using date-fns intervals
|
|
155
|
+
const activePresets = $derived.by<DateRangePreset[]>(() => {
|
|
156
|
+
if (presets) return presets;
|
|
157
|
+
|
|
158
|
+
const y = today.getFullYear();
|
|
159
|
+
|
|
160
|
+
return [
|
|
161
|
+
{
|
|
162
|
+
label: 'Today',
|
|
163
|
+
range: { start: toCalendarDate(today), end: toCalendarDate(today) }
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
label: 'Next 7D',
|
|
167
|
+
range: { start: toCalendarDate(today), end: toCalendarDate(addDays(today, 7)) }
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
label: 'This Month',
|
|
171
|
+
range: { start: toCalendarDate(startOfMonth(today)), end: toCalendarDate(endOfMonth(today)) }
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
label: 'Last Month',
|
|
175
|
+
range: {
|
|
176
|
+
start: toCalendarDate(startOfMonth(subMonths(today, 1))),
|
|
177
|
+
end: toCalendarDate(endOfMonth(subMonths(today, 1)))
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
label: 'YTD',
|
|
182
|
+
range: { start: { year: y, month: 0, day: 1 }, end: toCalendarDate(today) }
|
|
183
|
+
}
|
|
184
|
+
];
|
|
185
|
+
});
|
|
186
|
+
</script>
|
|
187
|
+
|
|
188
|
+
<div class={cn('relative inline-block w-full max-w-sm', className)}>
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
{disabled}
|
|
192
|
+
onclick={() => (isOpen = !isOpen)}
|
|
193
|
+
class={cn(
|
|
194
|
+
'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',
|
|
195
|
+
'hover:border-[var(--ui-border)] focus:outline-none focus:ring-2 focus:ring-[var(--ui-ring)]/20 focus:border-[var(--ui-primary)]',
|
|
196
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
197
|
+
value.start ? 'text-[var(--ui-foreground)]' : 'text-[var(--ui-muted-foreground)]'
|
|
198
|
+
)}
|
|
199
|
+
>
|
|
200
|
+
<div class="flex items-center gap-2 truncate">
|
|
201
|
+
<CalendarIcon class="size-4 shrink-0 text-[var(--ui-muted-foreground)]" />
|
|
202
|
+
<span class="truncate">{label}</span>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{#if value.start && !disabled}
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
onclick={clearRange}
|
|
209
|
+
class="p-1 rounded-full hover:bg-[var(--ui-secondary)] text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]"
|
|
210
|
+
aria-label="Clear date range"
|
|
211
|
+
>
|
|
212
|
+
<X class="size-3.5" />
|
|
213
|
+
</button>
|
|
214
|
+
{/if}
|
|
215
|
+
</button>
|
|
216
|
+
|
|
217
|
+
{#if isOpen}
|
|
218
|
+
<!-- Backdrop for click outside -->
|
|
219
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
220
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
221
|
+
<div class="fixed inset-0 z-40" onclick={() => (isOpen = false)}></div>
|
|
222
|
+
|
|
223
|
+
<div
|
|
224
|
+
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"
|
|
225
|
+
>
|
|
226
|
+
<!-- Header controls -->
|
|
227
|
+
<div class="flex items-center justify-between mb-3">
|
|
228
|
+
<Button variant="ghost" size="sm" class="size-7 p-0" onclick={prevMonth}>
|
|
229
|
+
<ChevronLeft class="size-4" />
|
|
230
|
+
</Button>
|
|
231
|
+
<span class="text-xs font-semibold text-[var(--ui-foreground)] capitalize">
|
|
232
|
+
{format(viewDate, 'MMMM yyyy', { locale: activeDateFnsLocale })}
|
|
233
|
+
</span>
|
|
234
|
+
<Button variant="ghost" size="sm" class="size-7 p-0" onclick={nextMonth}>
|
|
235
|
+
<ChevronRight class="size-4" />
|
|
236
|
+
</Button>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<!-- Day names -->
|
|
240
|
+
<div class="grid grid-cols-7 mb-1 text-center">
|
|
241
|
+
{#each dayNames as day}
|
|
242
|
+
<span class="text-[11px] font-semibold text-[var(--ui-muted-foreground)] py-1 capitalize">
|
|
243
|
+
{day}
|
|
244
|
+
</span>
|
|
245
|
+
{/each}
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<!-- Calendar grid -->
|
|
249
|
+
<div class="grid grid-cols-7 gap-y-1">
|
|
250
|
+
{#each calendarDays as { date, calendarDate, currentMonth }}
|
|
251
|
+
{@const isStart = startDateObj ? isSameDay(startDateObj, date) : false}
|
|
252
|
+
{@const isEnd = endDateObj ? isSameDay(endDateObj, date) : false}
|
|
253
|
+
{@const activeRangeEnd = endDateObj || hoverDate}
|
|
254
|
+
{@const inRange =
|
|
255
|
+
startDateObj && activeRangeEnd
|
|
256
|
+
? isWithinInterval(date, {
|
|
257
|
+
start: isBefore(startDateObj, activeRangeEnd) ? startDateObj : activeRangeEnd,
|
|
258
|
+
end: isBefore(startDateObj, activeRangeEnd) ? activeRangeEnd : startDateObj
|
|
259
|
+
})
|
|
260
|
+
: false}
|
|
261
|
+
{@const isEdge = isStart || isEnd}
|
|
262
|
+
|
|
263
|
+
<div
|
|
264
|
+
class={cn(
|
|
265
|
+
'relative py-0.5 flex items-center justify-center',
|
|
266
|
+
inRange && !isEdge && 'bg-[var(--ui-primary)]/10',
|
|
267
|
+
isStart && activeRangeEnd && !isSameDay(startDateObj, activeRangeEnd) && 'rounded-l-md bg-[var(--ui-primary)]/10',
|
|
268
|
+
isEnd && startDateObj && !isSameDay(startDateObj, endDateObj) && 'rounded-r-md bg-[var(--ui-primary)]/10'
|
|
269
|
+
)}
|
|
270
|
+
onmouseenter={() => {
|
|
271
|
+
if (startDateObj && !endDateObj) hoverDate = date;
|
|
272
|
+
}}
|
|
273
|
+
>
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
disabled={!currentMonth}
|
|
277
|
+
onclick={() => handleDateClick(date)}
|
|
278
|
+
class={cn(
|
|
279
|
+
'size-8 rounded-md text-xs font-medium transition-colors flex items-center justify-center cursor-pointer',
|
|
280
|
+
!currentMonth && 'opacity-20 cursor-default',
|
|
281
|
+
currentMonth && !isEdge && !inRange && 'hover:bg-[var(--ui-secondary)] text-[var(--ui-foreground)]',
|
|
282
|
+
inRange && !isEdge && 'text-[var(--ui-primary)] font-semibold',
|
|
283
|
+
isEdge && 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
|
|
284
|
+
)}
|
|
285
|
+
>
|
|
286
|
+
{date.getDate()}
|
|
287
|
+
</button>
|
|
288
|
+
</div>
|
|
289
|
+
{/each}
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
<!-- Presets quick buttons bar -->
|
|
293
|
+
<div class="flex flex-wrap items-center gap-1.5 border-t border-[var(--ui-border)] mt-3 pt-2.5">
|
|
294
|
+
{#each activePresets as preset}
|
|
295
|
+
<button
|
|
296
|
+
type="button"
|
|
297
|
+
onclick={() => {
|
|
298
|
+
value = preset.range;
|
|
299
|
+
onSelect?.(value);
|
|
300
|
+
isOpen = false;
|
|
301
|
+
}}
|
|
302
|
+
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"
|
|
303
|
+
>
|
|
304
|
+
{preset.label}
|
|
305
|
+
</button>
|
|
306
|
+
{/each}
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
{/if}
|
|
310
|
+
</div>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type CalendarDate = {
|
|
2
|
+
year: number;
|
|
3
|
+
month: number;
|
|
4
|
+
day: number;
|
|
5
|
+
};
|
|
6
|
+
export type DateRange = {
|
|
7
|
+
start: CalendarDate | null;
|
|
8
|
+
end: CalendarDate | null;
|
|
9
|
+
};
|
|
10
|
+
export type DateRangePreset = {
|
|
11
|
+
label: string;
|
|
12
|
+
range: DateRange;
|
|
13
|
+
};
|
|
14
|
+
type $$ComponentProps = {
|
|
15
|
+
value?: DateRange;
|
|
16
|
+
min?: CalendarDate | null;
|
|
17
|
+
max?: CalendarDate | null;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
locale?: string;
|
|
21
|
+
firstDayOfWeek?: number;
|
|
22
|
+
presets?: DateRangePreset[];
|
|
23
|
+
class?: string;
|
|
24
|
+
onSelect?: (range: DateRange) => void;
|
|
25
|
+
};
|
|
26
|
+
declare const DateRangePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
27
|
+
type DateRangePicker = ReturnType<typeof DateRangePicker>;
|
|
28
|
+
export default DateRangePicker;
|
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Clock, Plus, X } from 'lucide-svelte';
|
|
3
|
+
import { Button } from '@intinyagroup/ui';
|
|
4
|
+
import { cn } from '@intinyagroup/ui/utils';
|
|
5
|
+
import {
|
|
6
|
+
addMonths,
|
|
7
|
+
subMonths,
|
|
8
|
+
addWeeks,
|
|
9
|
+
subWeeks,
|
|
10
|
+
addDays,
|
|
11
|
+
subDays,
|
|
12
|
+
startOfMonth,
|
|
13
|
+
endOfMonth,
|
|
14
|
+
startOfWeek,
|
|
15
|
+
endOfWeek,
|
|
16
|
+
eachDayOfInterval,
|
|
17
|
+
isSameDay,
|
|
18
|
+
isSameMonth,
|
|
19
|
+
format
|
|
20
|
+
} from 'date-fns';
|
|
21
|
+
import { id as localeId, enUS as localeEn } from 'date-fns/locale';
|
|
22
|
+
|
|
23
|
+
export type CalendarView = 'month' | 'week' | 'day';
|
|
24
|
+
|
|
25
|
+
export type CalendarEvent = {
|
|
26
|
+
id: string;
|
|
27
|
+
title: string;
|
|
28
|
+
start: Date;
|
|
29
|
+
end: Date;
|
|
30
|
+
color?: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
allDay?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let {
|
|
36
|
+
events = $bindable([]),
|
|
37
|
+
view = $bindable('month' as CalendarView),
|
|
38
|
+
currentDate = $bindable(new Date()),
|
|
39
|
+
locale = 'en-US',
|
|
40
|
+
timeZone,
|
|
41
|
+
firstDayOfWeek = 0, // 0 = Sunday, 1 = Monday
|
|
42
|
+
enableEventModal = true,
|
|
43
|
+
class: className,
|
|
44
|
+
onEventClick,
|
|
45
|
+
onDateClick,
|
|
46
|
+
onAddEvent,
|
|
47
|
+
}: {
|
|
48
|
+
events?: CalendarEvent[];
|
|
49
|
+
view?: CalendarView;
|
|
50
|
+
currentDate?: Date;
|
|
51
|
+
locale?: string;
|
|
52
|
+
timeZone?: string;
|
|
53
|
+
firstDayOfWeek?: number;
|
|
54
|
+
enableEventModal?: boolean;
|
|
55
|
+
class?: string;
|
|
56
|
+
onEventClick?: (event: CalendarEvent) => void;
|
|
57
|
+
onDateClick?: (date: Date) => void;
|
|
58
|
+
onAddEvent?: (newEvent: CalendarEvent) => void;
|
|
59
|
+
} = $props();
|
|
60
|
+
|
|
61
|
+
const activeDateFnsLocale = $derived(locale.startsWith('id') ? localeId : localeEn);
|
|
62
|
+
const weekStartsOn = $derived(firstDayOfWeek as 0 | 1 | 2 | 3 | 4 | 5 | 6);
|
|
63
|
+
|
|
64
|
+
const activeTimeZone = $derived(
|
|
65
|
+
timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const hours = Array.from({ length: 24 }, (_, i) => i);
|
|
69
|
+
|
|
70
|
+
// Day names localized and rotated according to firstDayOfWeek via date-fns
|
|
71
|
+
const dayNames = $derived.by(() => {
|
|
72
|
+
const start = startOfWeek(currentDate, { weekStartsOn });
|
|
73
|
+
return Array.from({ length: 7 }, (_, i) => {
|
|
74
|
+
return format(addDays(start, i), 'EEE', { locale: activeDateFnsLocale });
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function isToday(d: Date): boolean {
|
|
79
|
+
return isSameDay(d, new Date());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function goPrev() {
|
|
83
|
+
if (view === 'month') currentDate = subMonths(currentDate, 1);
|
|
84
|
+
else if (view === 'week') currentDate = subWeeks(currentDate, 1);
|
|
85
|
+
else currentDate = subDays(currentDate, 1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function goNext() {
|
|
89
|
+
if (view === 'month') currentDate = addMonths(currentDate, 1);
|
|
90
|
+
else if (view === 'week') currentDate = addWeeks(currentDate, 1);
|
|
91
|
+
else currentDate = addDays(currentDate, 1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function goToday() {
|
|
95
|
+
currentDate = new Date();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Month view calendar matrix using date-fns intervals
|
|
99
|
+
const monthDays = $derived.by(() => {
|
|
100
|
+
const monthStart = startOfMonth(currentDate);
|
|
101
|
+
const monthEnd = endOfMonth(currentDate);
|
|
102
|
+
const gridStart = startOfWeek(monthStart, { weekStartsOn });
|
|
103
|
+
const gridEnd = endOfWeek(monthEnd, { weekStartsOn });
|
|
104
|
+
|
|
105
|
+
const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
|
|
106
|
+
return days.map((d) => ({
|
|
107
|
+
date: d,
|
|
108
|
+
currentMonth: isSameMonth(d, currentDate)
|
|
109
|
+
}));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Week view 7 days using date-fns
|
|
113
|
+
const weekDays = $derived.by(() => {
|
|
114
|
+
const start = startOfWeek(currentDate, { weekStartsOn });
|
|
115
|
+
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
function getEventsForDay(d: Date): CalendarEvent[] {
|
|
119
|
+
return events.filter((ev) => isSameDay(new Date(ev.start), d));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getTimeInZone(date: Date): { hours: number; minutes: number } {
|
|
123
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
124
|
+
timeZone: activeTimeZone,
|
|
125
|
+
hour: 'numeric',
|
|
126
|
+
minute: 'numeric',
|
|
127
|
+
hour12: false
|
|
128
|
+
}).formatToParts(date);
|
|
129
|
+
|
|
130
|
+
let hours = 0;
|
|
131
|
+
let minutes = 0;
|
|
132
|
+
for (const p of parts) {
|
|
133
|
+
if (p.type === 'hour') hours = Number(p.value) % 24;
|
|
134
|
+
if (p.type === 'minute') minutes = Number(p.value);
|
|
135
|
+
}
|
|
136
|
+
return { hours, minutes };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatTime(date: Date): string {
|
|
140
|
+
return new Intl.DateTimeFormat(locale, {
|
|
141
|
+
timeZone: activeTimeZone,
|
|
142
|
+
hour: '2-digit',
|
|
143
|
+
minute: '2-digit',
|
|
144
|
+
hour12: false
|
|
145
|
+
}).format(date);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const titleHeader = $derived.by(() => {
|
|
149
|
+
if (view === 'month') {
|
|
150
|
+
return format(currentDate, 'MMMM yyyy', { locale: activeDateFnsLocale });
|
|
151
|
+
}
|
|
152
|
+
if (view === 'week') {
|
|
153
|
+
const start = weekDays[0];
|
|
154
|
+
const end = weekDays[6];
|
|
155
|
+
const sStr = format(start, 'dd MMM', { locale: activeDateFnsLocale });
|
|
156
|
+
const eStr = format(end, 'dd MMM yyyy', { locale: activeDateFnsLocale });
|
|
157
|
+
return `${sStr} - ${eStr}`;
|
|
158
|
+
}
|
|
159
|
+
return format(currentDate, 'dd MMMM yyyy', { locale: activeDateFnsLocale });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Event modal state
|
|
163
|
+
let modalOpen = $state(false);
|
|
164
|
+
let newEventTitle = $state('');
|
|
165
|
+
let newEventDate = $state(new Date());
|
|
166
|
+
let newEventStartTime = $state('09:00');
|
|
167
|
+
let newEventEndTime = $state('10:00');
|
|
168
|
+
let newEventColor = $state('#2563eb');
|
|
169
|
+
let newEventDescription = $state('');
|
|
170
|
+
|
|
171
|
+
const presetColors = ['#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2', '#4b5563'];
|
|
172
|
+
|
|
173
|
+
function openCreateModal(date: Date) {
|
|
174
|
+
if (!enableEventModal) return;
|
|
175
|
+
newEventDate = new Date(date);
|
|
176
|
+
newEventTitle = '';
|
|
177
|
+
newEventStartTime = '09:00';
|
|
178
|
+
newEventEndTime = '10:00';
|
|
179
|
+
newEventColor = '#2563eb';
|
|
180
|
+
newEventDescription = '';
|
|
181
|
+
modalOpen = true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function handleSaveEvent() {
|
|
185
|
+
if (!newEventTitle.trim()) return;
|
|
186
|
+
|
|
187
|
+
const [startH, startM] = newEventStartTime.split(':').map(Number);
|
|
188
|
+
const [endH, endM] = newEventEndTime.split(':').map(Number);
|
|
189
|
+
|
|
190
|
+
const startDate = new Date(newEventDate);
|
|
191
|
+
startDate.setHours(startH || 0, startM || 0, 0, 0);
|
|
192
|
+
|
|
193
|
+
const endDate = new Date(newEventDate);
|
|
194
|
+
endDate.setHours(endH || 0, endM || 0, 0, 0);
|
|
195
|
+
|
|
196
|
+
const newEv: CalendarEvent = {
|
|
197
|
+
id: `ev-${Date.now()}`,
|
|
198
|
+
title: newEventTitle.trim(),
|
|
199
|
+
start: startDate,
|
|
200
|
+
end: endDate,
|
|
201
|
+
color: newEventColor,
|
|
202
|
+
description: newEventDescription.trim() || undefined
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
events = [...events, newEv];
|
|
206
|
+
onAddEvent?.(newEv);
|
|
207
|
+
modalOpen = false;
|
|
208
|
+
}
|
|
209
|
+
</script>
|
|
210
|
+
|
|
211
|
+
<div class={cn('relative flex flex-col rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] shadow-xs overflow-hidden', className)}>
|
|
212
|
+
<!-- Toolbar Header -->
|
|
213
|
+
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--ui-border)] p-4 sm:px-6">
|
|
214
|
+
<div class="flex items-center gap-2">
|
|
215
|
+
<h2 class="text-base font-bold text-[var(--ui-foreground)] capitalize">{titleHeader}</h2>
|
|
216
|
+
<div class="flex items-center gap-1 ml-2">
|
|
217
|
+
<Button variant="outline" size="sm" class="size-8 p-0" onclick={goPrev}>
|
|
218
|
+
<ChevronLeft class="size-4" />
|
|
219
|
+
</Button>
|
|
220
|
+
<Button variant="outline" size="sm" class="px-2.5 h-8 text-xs font-semibold" onclick={goToday}>
|
|
221
|
+
Today
|
|
222
|
+
</Button>
|
|
223
|
+
<Button variant="outline" size="sm" class="size-8 p-0" onclick={goNext}>
|
|
224
|
+
<ChevronRight class="size-4" />
|
|
225
|
+
</Button>
|
|
226
|
+
</div>
|
|
227
|
+
<span class="inline-flex items-center rounded-md bg-[var(--ui-secondary)]/50 px-2 py-0.5 text-[10px] font-medium text-[var(--ui-muted-foreground)]">
|
|
228
|
+
{activeTimeZone}
|
|
229
|
+
</span>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<div class="flex items-center gap-2">
|
|
233
|
+
<!-- View selector tabs -->
|
|
234
|
+
<div class="inline-flex rounded-lg border border-[var(--ui-border)] bg-[var(--ui-secondary)]/30 p-1">
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
onclick={() => (view = 'month')}
|
|
238
|
+
class={cn(
|
|
239
|
+
'rounded-md px-3 py-1 text-xs font-semibold transition-colors cursor-pointer',
|
|
240
|
+
view === 'month' ? 'bg-[var(--ui-card)] text-[var(--ui-foreground)] shadow-xs' : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]'
|
|
241
|
+
)}
|
|
242
|
+
>
|
|
243
|
+
Month
|
|
244
|
+
</button>
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onclick={() => (view = 'week')}
|
|
248
|
+
class={cn(
|
|
249
|
+
'rounded-md px-3 py-1 text-xs font-semibold transition-colors cursor-pointer',
|
|
250
|
+
view === 'week' ? 'bg-[var(--ui-card)] text-[var(--ui-foreground)] shadow-xs' : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]'
|
|
251
|
+
)}
|
|
252
|
+
>
|
|
253
|
+
Week
|
|
254
|
+
</button>
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
onclick={() => (view = 'day')}
|
|
258
|
+
class={cn(
|
|
259
|
+
'rounded-md px-3 py-1 text-xs font-semibold transition-colors cursor-pointer',
|
|
260
|
+
view === 'day' ? 'bg-[var(--ui-card)] text-[var(--ui-foreground)] shadow-xs' : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]'
|
|
261
|
+
)}
|
|
262
|
+
>
|
|
263
|
+
Day
|
|
264
|
+
</button>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
{#if enableEventModal}
|
|
268
|
+
<Button size="sm" class="gap-1.5 h-8 text-xs" onclick={() => openCreateModal(currentDate)}>
|
|
269
|
+
<Plus class="size-3.5" /> Add Event
|
|
270
|
+
</Button>
|
|
271
|
+
{/if}
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<!-- Calendar Content -->
|
|
276
|
+
{#if view === 'month'}
|
|
277
|
+
<!-- MONTH VIEW -->
|
|
278
|
+
<div class="grid grid-cols-7 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/20 text-center text-xs font-semibold text-[var(--ui-muted-foreground)] py-2">
|
|
279
|
+
{#each dayNames as day}
|
|
280
|
+
<div class="capitalize">{day}</div>
|
|
281
|
+
{/each}
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<div class="grid grid-cols-7 divide-x divide-y divide-[var(--ui-border)]">
|
|
285
|
+
{#each monthDays as { date, currentMonth }}
|
|
286
|
+
{@const dayEvents = getEventsForDay(date)}
|
|
287
|
+
{@const todayActive = isToday(date)}
|
|
288
|
+
|
|
289
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
290
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
291
|
+
<div
|
|
292
|
+
onclick={() => {
|
|
293
|
+
onDateClick?.(date);
|
|
294
|
+
openCreateModal(date);
|
|
295
|
+
}}
|
|
296
|
+
class={cn(
|
|
297
|
+
'min-h-[100px] p-2 flex flex-col gap-1 transition-colors hover:bg-[var(--ui-secondary)]/25 cursor-pointer',
|
|
298
|
+
!currentMonth && 'bg-[var(--ui-muted)]/15 opacity-50'
|
|
299
|
+
)}
|
|
300
|
+
>
|
|
301
|
+
<div class="flex items-center justify-between mb-1">
|
|
302
|
+
<span
|
|
303
|
+
class={cn(
|
|
304
|
+
'text-xs font-semibold size-6 flex items-center justify-center rounded-full',
|
|
305
|
+
todayActive ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)]' : 'text-[var(--ui-foreground)]'
|
|
306
|
+
)}
|
|
307
|
+
>
|
|
308
|
+
{date.getDate()}
|
|
309
|
+
</span>
|
|
310
|
+
{#if dayEvents.length > 0}
|
|
311
|
+
<span class="text-[10px] text-[var(--ui-muted-foreground)] font-medium">
|
|
312
|
+
{dayEvents.length} ev
|
|
313
|
+
</span>
|
|
314
|
+
{/if}
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
<!-- Event pills -->
|
|
318
|
+
<div class="flex flex-col gap-1 overflow-hidden">
|
|
319
|
+
{#each dayEvents.slice(0, 3) as ev (ev.id)}
|
|
320
|
+
<button
|
|
321
|
+
type="button"
|
|
322
|
+
onclick={(e) => {
|
|
323
|
+
e.stopPropagation();
|
|
324
|
+
onEventClick?.(ev);
|
|
325
|
+
}}
|
|
326
|
+
class={cn(
|
|
327
|
+
'truncate rounded px-1.5 py-0.5 text-left text-[11px] font-medium transition-opacity hover:opacity-85 text-white shadow-2xs',
|
|
328
|
+
ev.color ? '' : 'bg-[var(--ui-primary)]'
|
|
329
|
+
)}
|
|
330
|
+
style={ev.color ? `background-color: ${ev.color};` : undefined}
|
|
331
|
+
>
|
|
332
|
+
{formatTime(ev.start)} {ev.title}
|
|
333
|
+
</button>
|
|
334
|
+
{/each}
|
|
335
|
+
|
|
336
|
+
{#if dayEvents.length > 3}
|
|
337
|
+
<span class="text-[10px] font-semibold text-[var(--ui-muted-foreground)] pl-1">
|
|
338
|
+
+{dayEvents.length - 3} more
|
|
339
|
+
</span>
|
|
340
|
+
{/if}
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
{/each}
|
|
344
|
+
</div>
|
|
345
|
+
{:else if view === 'week'}
|
|
346
|
+
<!-- WEEK VIEW -->
|
|
347
|
+
<div class="grid grid-cols-8 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/20 text-center text-xs font-semibold text-[var(--ui-muted-foreground)] py-2">
|
|
348
|
+
<div class="col-span-1">Time</div>
|
|
349
|
+
{#each weekDays as d, idx}
|
|
350
|
+
<div class="col-span-1 flex flex-col items-center">
|
|
351
|
+
<span class="capitalize">{dayNames[idx]}</span>
|
|
352
|
+
<span class={cn('size-6 flex items-center justify-center rounded-full text-xs mt-0.5', isToday(d) ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)]' : '')}>
|
|
353
|
+
{d.getDate()}
|
|
354
|
+
</span>
|
|
355
|
+
</div>
|
|
356
|
+
{/each}
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<div class="grid grid-cols-8 divide-x divide-[var(--ui-border)] max-h-[600px] overflow-y-auto">
|
|
360
|
+
<!-- Hours Column -->
|
|
361
|
+
<div class="col-span-1 divide-y divide-[var(--ui-border)]/50 text-right pr-2 text-[11px] font-medium text-[var(--ui-muted-foreground)]">
|
|
362
|
+
{#each hours as hour}
|
|
363
|
+
<div class="h-14 pt-1">{String(hour).padStart(2, '0')}:00</div>
|
|
364
|
+
{/each}
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
<!-- 7 Days Grid Columns -->
|
|
368
|
+
{#each weekDays as d}
|
|
369
|
+
{@const dayEvents = getEventsForDay(d)}
|
|
370
|
+
{@const isCurrentDay = isToday(d)}
|
|
371
|
+
{@const nowTime = getTimeInZone(new Date())}
|
|
372
|
+
{@const nowMinutes = nowTime.hours * 60 + nowTime.minutes}
|
|
373
|
+
{@const currentTimeTop = (nowMinutes / 60) * 56}
|
|
374
|
+
|
|
375
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
376
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
377
|
+
<div
|
|
378
|
+
onclick={() => openCreateModal(d)}
|
|
379
|
+
class="col-span-1 divide-y divide-[var(--ui-border)]/50 relative hover:bg-[var(--ui-secondary)]/10 cursor-pointer"
|
|
380
|
+
>
|
|
381
|
+
{#each hours as _}
|
|
382
|
+
<div class="h-14"></div>
|
|
383
|
+
{/each}
|
|
384
|
+
|
|
385
|
+
<!-- Google Calendar style Current Time Red Line -->
|
|
386
|
+
{#if isCurrentDay}
|
|
387
|
+
<div
|
|
388
|
+
class="absolute inset-x-0 z-30 pointer-events-none flex items-center"
|
|
389
|
+
style="top: {currentTimeTop}px;"
|
|
390
|
+
>
|
|
391
|
+
<div class="size-2 rounded-full bg-red-500 -ml-1"></div>
|
|
392
|
+
<div class="h-0.5 w-full bg-red-500"></div>
|
|
393
|
+
</div>
|
|
394
|
+
{/if}
|
|
395
|
+
|
|
396
|
+
<!-- Render Event Boxes -->
|
|
397
|
+
{#each dayEvents as ev (ev.id)}
|
|
398
|
+
{@const evTime = getTimeInZone(new Date(ev.start))}
|
|
399
|
+
{@const topPos = (evTime.hours + evTime.minutes / 60) * 56}
|
|
400
|
+
<button
|
|
401
|
+
type="button"
|
|
402
|
+
onclick={(e) => {
|
|
403
|
+
e.stopPropagation();
|
|
404
|
+
onEventClick?.(ev);
|
|
405
|
+
}}
|
|
406
|
+
class="absolute inset-x-1 rounded p-1 text-left text-[11px] text-white shadow-xs transition-opacity hover:opacity-90 overflow-hidden z-10"
|
|
407
|
+
style="top: {topPos}px; height: 50px; background-color: {ev.color || 'var(--ui-primary)'};"
|
|
408
|
+
>
|
|
409
|
+
<div class="font-bold truncate">{ev.title}</div>
|
|
410
|
+
<div class="text-[9px] opacity-80">{formatTime(ev.start)} - {formatTime(ev.end)}</div>
|
|
411
|
+
</button>
|
|
412
|
+
{/each}
|
|
413
|
+
</div>
|
|
414
|
+
{/each}
|
|
415
|
+
</div>
|
|
416
|
+
{:else}
|
|
417
|
+
<!-- DAY VIEW -->
|
|
418
|
+
<div class="p-3 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/20 flex items-center justify-between">
|
|
419
|
+
<div class="flex items-center gap-2">
|
|
420
|
+
<Clock class="size-4 text-[var(--ui-muted-foreground)]" />
|
|
421
|
+
<span class="text-sm font-semibold capitalize">{titleHeader}</span>
|
|
422
|
+
</div>
|
|
423
|
+
<span class="text-xs text-[var(--ui-muted-foreground)] font-medium">
|
|
424
|
+
{getEventsForDay(currentDate).length} event(s)
|
|
425
|
+
</span>
|
|
426
|
+
</div>
|
|
427
|
+
|
|
428
|
+
<div class="grid grid-cols-12 divide-x divide-[var(--ui-border)] max-h-[600px] overflow-y-auto">
|
|
429
|
+
<div class="col-span-2 divide-y divide-[var(--ui-border)]/50 text-right pr-3 text-xs font-medium text-[var(--ui-muted-foreground)]">
|
|
430
|
+
{#each hours as hour}
|
|
431
|
+
<div class="h-16 pt-2">{String(hour).padStart(2, '0')}:00</div>
|
|
432
|
+
{/each}
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
{@const isCurrentDay = isToday(currentDate)}
|
|
436
|
+
{@const nowTime = getTimeInZone(new Date())}
|
|
437
|
+
{@const nowMinutes = nowTime.hours * 60 + nowTime.minutes}
|
|
438
|
+
{@const currentTimeTop = (nowMinutes / 60) * 64}
|
|
439
|
+
|
|
440
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
441
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
442
|
+
<div
|
|
443
|
+
onclick={() => openCreateModal(currentDate)}
|
|
444
|
+
class="col-span-10 divide-y divide-[var(--ui-border)]/50 relative p-1 hover:bg-[var(--ui-secondary)]/10 cursor-pointer"
|
|
445
|
+
>
|
|
446
|
+
{#each hours as _}
|
|
447
|
+
<div class="h-16"></div>
|
|
448
|
+
{/each}
|
|
449
|
+
|
|
450
|
+
<!-- Google Calendar style Current Time Red Line -->
|
|
451
|
+
{#if isCurrentDay}
|
|
452
|
+
<div
|
|
453
|
+
class="absolute inset-x-0 z-30 pointer-events-none flex items-center"
|
|
454
|
+
style="top: {currentTimeTop}px;"
|
|
455
|
+
>
|
|
456
|
+
<div class="size-2.5 rounded-full bg-red-500 -ml-1"></div>
|
|
457
|
+
<div class="h-0.5 w-full bg-red-500"></div>
|
|
458
|
+
</div>
|
|
459
|
+
{/if}
|
|
460
|
+
|
|
461
|
+
{#each getEventsForDay(currentDate) as ev (ev.id)}
|
|
462
|
+
{@const evTime = getTimeInZone(new Date(ev.start))}
|
|
463
|
+
{@const topPos = (evTime.hours + evTime.minutes / 60) * 64}
|
|
464
|
+
<button
|
|
465
|
+
type="button"
|
|
466
|
+
onclick={(e) => {
|
|
467
|
+
e.stopPropagation();
|
|
468
|
+
onEventClick?.(ev);
|
|
469
|
+
}}
|
|
470
|
+
class="absolute inset-x-4 rounded-lg p-2 text-left text-white shadow-sm transition-opacity hover:opacity-90 flex flex-col justify-center z-10"
|
|
471
|
+
style="top: {topPos}px; height: 56px; background-color: {ev.color || 'var(--ui-primary)'};"
|
|
472
|
+
>
|
|
473
|
+
<div class="font-bold text-xs">{ev.title}</div>
|
|
474
|
+
<div class="text-[10px] opacity-80">{formatTime(ev.start)} - {formatTime(ev.end)} {#if ev.description}• {ev.description}{/if}</div>
|
|
475
|
+
</button>
|
|
476
|
+
{/each}
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
{/if}
|
|
480
|
+
|
|
481
|
+
<!-- Quick Event Creation Modal -->
|
|
482
|
+
{#if modalOpen}
|
|
483
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
484
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
485
|
+
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs p-4">
|
|
486
|
+
<div
|
|
487
|
+
onclick={(e) => e.stopPropagation()}
|
|
488
|
+
class="w-full max-w-md rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] p-5 shadow-2xl animate-in fade-in-50 zoom-in-95"
|
|
489
|
+
>
|
|
490
|
+
<div class="flex items-center justify-between mb-4">
|
|
491
|
+
<h3 class="text-sm font-bold text-[var(--ui-foreground)]">Add New Event</h3>
|
|
492
|
+
<button
|
|
493
|
+
type="button"
|
|
494
|
+
onclick={() => (modalOpen = false)}
|
|
495
|
+
class="rounded-full p-1 text-[var(--ui-muted-foreground)] hover:bg-[var(--ui-secondary)] hover:text-[var(--ui-foreground)]"
|
|
496
|
+
>
|
|
497
|
+
<X class="size-4" />
|
|
498
|
+
</button>
|
|
499
|
+
</div>
|
|
500
|
+
|
|
501
|
+
<div class="space-y-3.5 text-xs">
|
|
502
|
+
<div>
|
|
503
|
+
<label class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">Title</label>
|
|
504
|
+
<input
|
|
505
|
+
type="text"
|
|
506
|
+
bind:value={newEventTitle}
|
|
507
|
+
placeholder="Event title (e.g. Sprint Review)..."
|
|
508
|
+
class="h-9 w-full rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] px-3 text-xs outline-none focus:border-[var(--ui-primary)]"
|
|
509
|
+
autofocus
|
|
510
|
+
/>
|
|
511
|
+
</div>
|
|
512
|
+
|
|
513
|
+
<div class="grid grid-cols-2 gap-3">
|
|
514
|
+
<div>
|
|
515
|
+
<label class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">Start Time</label>
|
|
516
|
+
<input
|
|
517
|
+
type="time"
|
|
518
|
+
bind:value={newEventStartTime}
|
|
519
|
+
class="h-9 w-full rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] px-2.5 text-xs outline-none focus:border-[var(--ui-primary)]"
|
|
520
|
+
/>
|
|
521
|
+
</div>
|
|
522
|
+
<div>
|
|
523
|
+
<label class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">End Time</label>
|
|
524
|
+
<input
|
|
525
|
+
type="time"
|
|
526
|
+
bind:value={newEventEndTime}
|
|
527
|
+
class="h-9 w-full rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] px-2.5 text-xs outline-none focus:border-[var(--ui-primary)]"
|
|
528
|
+
/>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
|
|
532
|
+
<div>
|
|
533
|
+
<label class="block mb-1.5 font-semibold text-[var(--ui-muted-foreground)]">Tag Color</label>
|
|
534
|
+
<div class="flex items-center gap-2">
|
|
535
|
+
{#each presetColors as col}
|
|
536
|
+
<button
|
|
537
|
+
type="button"
|
|
538
|
+
onclick={() => (newEventColor = col)}
|
|
539
|
+
class={cn(
|
|
540
|
+
'size-6 rounded-full transition-transform hover:scale-110 cursor-pointer',
|
|
541
|
+
newEventColor === col && 'ring-2 ring-offset-2 ring-[var(--ui-primary)]'
|
|
542
|
+
)}
|
|
543
|
+
style="background-color: {col};"
|
|
544
|
+
aria-label="Select color {col}"
|
|
545
|
+
></button>
|
|
546
|
+
{/each}
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
|
|
550
|
+
<div>
|
|
551
|
+
<label class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">Description (optional)</label>
|
|
552
|
+
<textarea
|
|
553
|
+
bind:value={newEventDescription}
|
|
554
|
+
rows={2}
|
|
555
|
+
placeholder="Notes, agenda, or link..."
|
|
556
|
+
class="w-full rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] p-2.5 text-xs outline-none focus:border-[var(--ui-primary)]"
|
|
557
|
+
></textarea>
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
|
|
561
|
+
<div class="mt-5 flex items-center justify-end gap-2 border-t border-[var(--ui-border)] pt-3">
|
|
562
|
+
<Button variant="outline" size="sm" onclick={() => (modalOpen = false)}>Cancel</Button>
|
|
563
|
+
<Button size="sm" onclick={handleSaveEvent}>Save Event</Button>
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
{/if}
|
|
568
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type CalendarView = 'month' | 'week' | 'day';
|
|
2
|
+
export type CalendarEvent = {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
start: Date;
|
|
6
|
+
end: Date;
|
|
7
|
+
color?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
allDay?: boolean;
|
|
10
|
+
};
|
|
11
|
+
type $$ComponentProps = {
|
|
12
|
+
events?: CalendarEvent[];
|
|
13
|
+
view?: CalendarView;
|
|
14
|
+
currentDate?: Date;
|
|
15
|
+
locale?: string;
|
|
16
|
+
timeZone?: string;
|
|
17
|
+
firstDayOfWeek?: number;
|
|
18
|
+
enableEventModal?: boolean;
|
|
19
|
+
class?: string;
|
|
20
|
+
onEventClick?: (event: CalendarEvent) => void;
|
|
21
|
+
onDateClick?: (date: Date) => void;
|
|
22
|
+
onAddEvent?: (newEvent: CalendarEvent) => void;
|
|
23
|
+
};
|
|
24
|
+
declare const EventCalendar: import("svelte").Component<$$ComponentProps, {}, "events" | "view" | "currentDate">;
|
|
25
|
+
type EventCalendar = ReturnType<typeof EventCalendar>;
|
|
26
|
+
export default EventCalendar;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { default as Calendar } from './Calendar.svelte';
|
|
2
|
+
export { default as DatePicker } from './DatePicker.svelte';
|
|
3
|
+
export { default as DateRangePicker } from './DateRangePicker.svelte';
|
|
4
|
+
export { default as EventCalendar } from './EventCalendar.svelte';
|
|
5
|
+
export type { CalendarDate } from './Calendar.svelte';
|
|
6
|
+
export type { DateRange } from './DateRangePicker.svelte';
|
|
7
|
+
export type { CalendarEvent, CalendarView } from './EventCalendar.svelte';
|
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@intinyagroup/calendar",
|
|
3
|
+
"version": "0.1.1-alpha.11",
|
|
4
|
+
"description": "Calendar and DatePicker components for intinyagroup UI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"svelte": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "svelte-package -o dist",
|
|
18
|
+
"check": "echo 'no check'",
|
|
19
|
+
"dev": "svelte-package -o dist -w"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@intinyagroup/ui": "0.1.1-alpha.9",
|
|
23
|
+
"date-fns": "^4.4.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@sveltejs/package": "^2.5.8",
|
|
27
|
+
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
|
28
|
+
"svelte": "^5.55.2",
|
|
29
|
+
"typescript": "^6.0.2"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"svelte": "^5.0.0"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/intinyagroup/ui.git",
|
|
38
|
+
"directory": "packages/calendar"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|