@nomideusz/svelte-calendar 0.5.2 → 0.6.0
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/README.md +408 -178
- package/dist/adapters/memory.d.ts +7 -9
- package/dist/adapters/memory.js +8 -23
- package/dist/adapters/recurring.d.ts +3 -8
- package/dist/adapters/recurring.js +27 -30
- package/dist/calendar/Calendar.svelte +457 -46
- package/dist/calendar/Calendar.svelte.d.ts +55 -4
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.js +1 -1
- package/dist/core/locale.js +2 -2
- package/dist/core/palette.d.ts +5 -0
- package/dist/core/palette.js +8 -0
- package/dist/core/types.d.ts +14 -0
- package/dist/engine/event-store.svelte.d.ts +0 -17
- package/dist/engine/event-store.svelte.js +30 -17
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/view-state.svelte.d.ts +10 -5
- package/dist/engine/view-state.svelte.js +32 -16
- package/dist/index.d.ts +7 -7
- package/dist/index.js +3 -4
- package/dist/theme/auto.d.ts +53 -0
- package/dist/theme/auto.js +534 -0
- package/dist/theme/index.d.ts +3 -1
- package/dist/theme/index.js +3 -1
- package/dist/theme/presets.d.ts +20 -6
- package/dist/theme/presets.js +19 -6
- package/dist/views/agenda/AgendaDay.svelte +116 -37
- package/dist/views/agenda/AgendaWeek.svelte +219 -70
- package/dist/views/agenda/index.d.ts +0 -2
- package/dist/views/agenda/index.js +0 -2
- package/dist/views/index.d.ts +3 -2
- package/dist/views/index.js +3 -2
- package/dist/views/mobile/Mobile.svelte +17 -0
- package/dist/views/mobile/Mobile.svelte.d.ts +7 -0
- package/dist/views/mobile/MobileDay.svelte +665 -0
- package/dist/views/mobile/MobileDay.svelte.d.ts +20 -0
- package/dist/views/mobile/MobileWeek.svelte +456 -0
- package/dist/views/mobile/MobileWeek.svelte.d.ts +20 -0
- package/dist/views/mobile/index.d.ts +1 -0
- package/dist/views/mobile/index.js +1 -0
- package/dist/views/planner/PlannerDay.svelte +144 -10
- package/dist/views/planner/PlannerWeek.svelte +145 -40
- package/dist/widget/CalendarWidget.svelte +2 -17
- package/package.json +2 -1
|
@@ -4,16 +4,14 @@
|
|
|
4
4
|
Brings together: adapter → event store → view state + selection → active view.
|
|
5
5
|
Provides context so any descendant view can read the store/state via getContext().
|
|
6
6
|
|
|
7
|
-
Usage:
|
|
7
|
+
Usage (minimal):
|
|
8
|
+
<Calendar adapter={myAdapter} theme={neutral} />
|
|
9
|
+
|
|
10
|
+
Usage (full control):
|
|
8
11
|
<Calendar
|
|
9
12
|
adapter={myAdapter}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
{ id: 'week-agenda', label: 'Agenda', component: Agenda },
|
|
13
|
-
{ id: 'day-planner', label: 'Planner', component: Planner, props: { mode: 'day' } },
|
|
14
|
-
]}
|
|
15
|
-
defaultView="week-planner"
|
|
16
|
-
theme={presets.midnight}
|
|
13
|
+
view="week-planner"
|
|
14
|
+
theme={midnight}
|
|
17
15
|
height={600}
|
|
18
16
|
oneventclick={handleClick}
|
|
19
17
|
oneventcreate={handleCreate}
|
|
@@ -27,15 +25,25 @@
|
|
|
27
25
|
import { createViewState, type ViewState } from '../engine/view-state.svelte.js';
|
|
28
26
|
import { createSelection, type Selection } from '../engine/selection.svelte.js';
|
|
29
27
|
import { createDragState, type DragState } from '../engine/drag.svelte.js';
|
|
30
|
-
import
|
|
28
|
+
import { onMount } from 'svelte';
|
|
29
|
+
import type { TimelineEvent, BlockedSlot } from '../core/types.js';
|
|
31
30
|
import { getLabels } from '../core/locale.js';
|
|
31
|
+
import { auto } from '../theme/presets.js';
|
|
32
|
+
import { probeHostTheme, observeHostTheme } from '../theme/auto.js';
|
|
33
|
+
import type { AutoThemeOptions } from '../theme/auto.js';
|
|
34
|
+
import Planner from '../views/planner/Planner.svelte';
|
|
35
|
+
import Agenda from '../views/agenda/Agenda.svelte';
|
|
36
|
+
import Mobile from '../views/mobile/Mobile.svelte';
|
|
37
|
+
|
|
38
|
+
/** Breakpoint (px) at which auto-mobile activates */
|
|
39
|
+
const MOBILE_BREAKPOINT = 768;
|
|
32
40
|
|
|
33
41
|
/** One view registration */
|
|
34
42
|
export interface CalendarView {
|
|
35
43
|
id: CalendarViewId;
|
|
36
44
|
label: string;
|
|
37
45
|
/** day or week */
|
|
38
|
-
|
|
46
|
+
mode: 'day' | 'week';
|
|
39
47
|
/** The Svelte component to render */
|
|
40
48
|
component: Component<Record<string, unknown>>;
|
|
41
49
|
/** Extra props to pass through (e.g. hourHeight, specialized settings) */
|
|
@@ -47,14 +55,25 @@
|
|
|
47
55
|
adapter: CalendarAdapter;
|
|
48
56
|
/** Registered views */
|
|
49
57
|
views?: CalendarView[];
|
|
50
|
-
/**
|
|
51
|
-
|
|
58
|
+
/** Active view ID (defaults to first registered view) */
|
|
59
|
+
view?: CalendarViewId;
|
|
52
60
|
/** CSS theme string (--dt-* inline style) */
|
|
53
61
|
theme?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Options for the smart auto-theme.
|
|
64
|
+
* When theme is `auto` (empty string), the calendar probes the host page
|
|
65
|
+
* and generates matching --dt-* vars automatically.
|
|
66
|
+
*
|
|
67
|
+
* Pass `{ mode, accent, font }` to override individual aspects.
|
|
68
|
+
* Set `autoTheme: false` to disable probing entirely.
|
|
69
|
+
*/
|
|
70
|
+
autoTheme?: AutoThemeOptions | false;
|
|
54
71
|
/** Start week on Monday */
|
|
55
72
|
mondayStart?: boolean;
|
|
56
73
|
/** Total height */
|
|
57
74
|
height?: number;
|
|
75
|
+
/** Border radius in px (default: 12). Set to 0 for no rounding. */
|
|
76
|
+
borderRadius?: number;
|
|
58
77
|
/** Text direction: 'ltr' (default), 'rtl', or 'auto' */
|
|
59
78
|
dir?: 'ltr' | 'rtl' | 'auto';
|
|
60
79
|
/** BCP 47 locale tag (e.g. 'en-US', 'ar-SA') — sets lang and locale for formatting */
|
|
@@ -67,52 +86,150 @@
|
|
|
67
86
|
initialDate?: Date;
|
|
68
87
|
/** Drag snap interval in minutes (default: 15) */
|
|
69
88
|
snapInterval?: number;
|
|
89
|
+
/** Show the Day/Week mode pills (default: true) */
|
|
90
|
+
showModePills?: boolean;
|
|
91
|
+
/** Show prev/next/today navigation controls (default: true) */
|
|
92
|
+
showNavigation?: boolean;
|
|
93
|
+
/** Treat all days equally — no past-day dimming or collapsing (default: false) */
|
|
94
|
+
equalDays?: boolean;
|
|
95
|
+
/** Hide date numbers — headers show only day names (Mon, Tue, …). Useful for template/recurring schedules. */
|
|
96
|
+
showDates?: boolean;
|
|
97
|
+
/** ISO weekdays to hide (1=Mon … 7=Sun). E.g. [6, 7] hides weekends. */
|
|
98
|
+
hideDays?: number[];
|
|
99
|
+
/** Controlled current date — drives which date the calendar focuses on. */
|
|
100
|
+
currentDate?: Date;
|
|
101
|
+
/** Blocked/unavailable time slots — rendered as hatched regions, prevent event creation. */
|
|
102
|
+
blockedSlots?: BlockedSlot[];
|
|
103
|
+
/** Number of days shown in week mode (default: 7). E.g. 3 for a 3-day view, 5 for workweek. */
|
|
104
|
+
days?: number;
|
|
105
|
+
/** Minimum event duration in minutes (enforced during drag-create and click-to-create). */
|
|
106
|
+
minDuration?: number;
|
|
107
|
+
/** Maximum event duration in minutes (enforced during drag-create). */
|
|
108
|
+
maxDuration?: number;
|
|
109
|
+
/** Specific dates to disable (greyed-out, no event creation). */
|
|
110
|
+
disabledDates?: Date[];
|
|
111
|
+
/**
|
|
112
|
+
* Mobile mode.
|
|
113
|
+
* - `'auto'` (default): detect via viewport width (< 768 px)
|
|
114
|
+
* - `true`: always use mobile views
|
|
115
|
+
* - `false`: never use mobile views
|
|
116
|
+
*/
|
|
117
|
+
mobile?: 'auto' | boolean;
|
|
70
118
|
|
|
71
119
|
// ── Snippets ──
|
|
72
120
|
/** Custom event rendering snippet */
|
|
73
121
|
event?: Snippet<[TimelineEvent]>;
|
|
74
122
|
/** Content to show when no events are loaded */
|
|
75
123
|
empty?: Snippet;
|
|
124
|
+
/** Custom day header snippet. Receives { date, isToday, dayName }. */
|
|
125
|
+
dayHeader?: Snippet<[{ date: Date; isToday: boolean; dayName: string }]>;
|
|
76
126
|
|
|
77
127
|
// ── Callbacks ──
|
|
78
128
|
oneventclick?: (event: TimelineEvent) => void;
|
|
79
129
|
oneventcreate?: (range: { start: Date; end: Date }) => void;
|
|
80
130
|
oneventmove?: (event: TimelineEvent, newStart: Date, newEnd: Date) => void;
|
|
81
131
|
onviewchange?: (viewId: CalendarViewId) => void;
|
|
132
|
+
/** Called when the focused date changes (navigation, drag-scroll, etc.) */
|
|
133
|
+
ondatechange?: (date: Date) => void;
|
|
134
|
+
/** Called when the pointer enters an event (hover). */
|
|
135
|
+
oneventhover?: (event: TimelineEvent) => void;
|
|
82
136
|
}
|
|
83
137
|
|
|
138
|
+
// ── Built-in views (used when no custom views are provided) ──
|
|
139
|
+
const DEFAULT_VIEWS: CalendarView[] = [
|
|
140
|
+
{ id: 'day-planner', label: 'Planner', mode: 'day', component: Planner },
|
|
141
|
+
{ id: 'week-planner', label: 'Planner', mode: 'week', component: Planner },
|
|
142
|
+
{ id: 'day-agenda', label: 'Agenda', mode: 'day', component: Agenda },
|
|
143
|
+
{ id: 'week-agenda', label: 'Agenda', mode: 'week', component: Agenda },
|
|
144
|
+
{ id: 'day-mobile', label: 'Mobile', mode: 'day', component: Mobile },
|
|
145
|
+
{ id: 'week-mobile', label: 'Mobile', mode: 'week', component: Mobile },
|
|
146
|
+
];
|
|
147
|
+
|
|
84
148
|
let {
|
|
85
149
|
adapter,
|
|
86
|
-
views =
|
|
87
|
-
|
|
88
|
-
theme =
|
|
150
|
+
views = DEFAULT_VIEWS,
|
|
151
|
+
view: activeViewId,
|
|
152
|
+
theme = auto,
|
|
153
|
+
autoTheme,
|
|
89
154
|
mondayStart = true,
|
|
90
155
|
height = 600,
|
|
156
|
+
borderRadius = 12,
|
|
91
157
|
dir,
|
|
92
158
|
locale,
|
|
93
159
|
readOnly = false,
|
|
94
160
|
visibleHours,
|
|
95
161
|
initialDate,
|
|
96
162
|
snapInterval = 15,
|
|
163
|
+
showModePills = true,
|
|
164
|
+
showNavigation = true,
|
|
165
|
+
equalDays = false,
|
|
166
|
+
showDates = true,
|
|
167
|
+
hideDays,
|
|
168
|
+
currentDate,
|
|
169
|
+
blockedSlots,
|
|
170
|
+
days,
|
|
171
|
+
minDuration,
|
|
172
|
+
maxDuration,
|
|
173
|
+
disabledDates,
|
|
174
|
+
mobile: mobileProp = 'auto',
|
|
97
175
|
event: eventSnippet,
|
|
98
176
|
empty: emptySnippet,
|
|
177
|
+
dayHeader: dayHeaderSnippet,
|
|
99
178
|
oneventclick,
|
|
100
179
|
oneventcreate,
|
|
101
180
|
oneventmove,
|
|
102
181
|
onviewchange,
|
|
182
|
+
ondatechange,
|
|
183
|
+
oneventhover,
|
|
103
184
|
}: Props = $props();
|
|
104
185
|
|
|
105
186
|
// In readOnly mode, suppress mutation callbacks
|
|
106
187
|
const effectiveCreate = $derived(readOnly ? undefined : oneventcreate);
|
|
107
188
|
const effectiveMove = $derived(readOnly ? undefined : oneventmove);
|
|
108
189
|
|
|
190
|
+
// ── Mobile detection ──
|
|
191
|
+
let isMobileViewport = $state(false);
|
|
192
|
+
|
|
193
|
+
onMount(() => {
|
|
194
|
+
if (typeof window === 'undefined') return;
|
|
195
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
196
|
+
isMobileViewport = mql.matches;
|
|
197
|
+
function onChange(e: MediaQueryListEvent) { isMobileViewport = e.matches; }
|
|
198
|
+
mql.addEventListener('change', onChange);
|
|
199
|
+
return () => mql.removeEventListener('change', onChange);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const useMobile = $derived(
|
|
203
|
+
mobileProp === 'auto' ? isMobileViewport : Boolean(mobileProp)
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// ── Smart auto-theme ──
|
|
207
|
+
// When theme is empty (auto preset) and autoTheme is not false,
|
|
208
|
+
// probe the host page on mount and reactively watch for host theme changes.
|
|
209
|
+
let calEl: HTMLElement | undefined = $state();
|
|
210
|
+
let probedTheme = $state('');
|
|
211
|
+
|
|
212
|
+
onMount(() => {
|
|
213
|
+
if (!calEl) return;
|
|
214
|
+
// Only probe when using the auto preset (empty string)
|
|
215
|
+
const isAuto = theme === auto && autoTheme !== false;
|
|
216
|
+
if (!isAuto) return;
|
|
217
|
+
|
|
218
|
+
const opts: AutoThemeOptions = typeof autoTheme === 'object' ? autoTheme : {};
|
|
219
|
+
return observeHostTheme(calEl, (vars) => { probedTheme = vars; }, opts);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
/** Effective theme: user-provided takes priority, otherwise probed auto. */
|
|
223
|
+
const effectiveTheme = $derived(theme === auto && autoTheme !== false ? probedTheme : theme);
|
|
224
|
+
|
|
109
225
|
// ── Create reactive state ──
|
|
110
226
|
const store: EventStore = $derived(createEventStore(adapter));
|
|
111
227
|
const viewState: ViewState = createViewState(untrack(() => ({
|
|
112
|
-
|
|
228
|
+
view: activeViewId ?? views[0]?.id,
|
|
113
229
|
mondayStart,
|
|
114
230
|
initialDate,
|
|
115
|
-
|
|
231
|
+
dayCount: days,
|
|
232
|
+
modeForView: (viewId) => views.find((v) => v.id === viewId)?.mode,
|
|
116
233
|
})));
|
|
117
234
|
const selection: Selection = createSelection();
|
|
118
235
|
const drag: DragState = createDragState();
|
|
@@ -125,12 +242,57 @@
|
|
|
125
242
|
const payload = drag.commit();
|
|
126
243
|
if (!payload) return;
|
|
127
244
|
|
|
245
|
+
let { start, end } = payload;
|
|
246
|
+
|
|
247
|
+
// Enforce min/max duration for create and resize
|
|
248
|
+
if (mode === 'create' || mode === 'resize-start' || mode === 'resize-end') {
|
|
249
|
+
const durationMs = end.getTime() - start.getTime();
|
|
250
|
+
const durationMin = durationMs / 60_000;
|
|
251
|
+
if (minDuration && durationMin < minDuration) {
|
|
252
|
+
if (mode === 'resize-start') {
|
|
253
|
+
start = new Date(end.getTime() - minDuration * 60_000);
|
|
254
|
+
} else {
|
|
255
|
+
end = new Date(start.getTime() + minDuration * 60_000);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (maxDuration && durationMin > maxDuration) {
|
|
259
|
+
if (mode === 'resize-start') {
|
|
260
|
+
start = new Date(end.getTime() - maxDuration * 60_000);
|
|
261
|
+
} else {
|
|
262
|
+
end = new Date(start.getTime() + maxDuration * 60_000);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Reject if target lands on a disabled date
|
|
268
|
+
if (disabledDates?.length) {
|
|
269
|
+
const startDay = new Date(start); startDay.setHours(0, 0, 0, 0);
|
|
270
|
+
const endDay = new Date(end.getTime() - 1); endDay.setHours(0, 0, 0, 0);
|
|
271
|
+
for (const dd of disabledDates) {
|
|
272
|
+
const dt = new Date(dd); dt.setHours(0, 0, 0, 0);
|
|
273
|
+
const ts = dt.getTime();
|
|
274
|
+
if (ts >= startDay.getTime() && ts <= endDay.getTime()) return;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Reject if target overlaps a blocked slot
|
|
279
|
+
if (blockedSlots?.length) {
|
|
280
|
+
const startH = start.getHours() + start.getMinutes() / 60;
|
|
281
|
+
const endH = end.getHours() + end.getMinutes() / 60 + (end.getDate() !== start.getDate() ? 24 : 0);
|
|
282
|
+
const jsDay = start.getDay();
|
|
283
|
+
const isoDay = jsDay === 0 ? 7 : jsDay;
|
|
284
|
+
for (const slot of blockedSlots) {
|
|
285
|
+
if (slot.day && slot.day !== isoDay) continue;
|
|
286
|
+
if (startH < slot.end && endH > slot.start) return;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
128
290
|
if ((mode === 'move' || mode === 'resize-start' || mode === 'resize-end') && payload.eventId) {
|
|
129
|
-
store.move(payload.eventId,
|
|
291
|
+
store.move(payload.eventId, start, end);
|
|
130
292
|
const ev = store.byId(payload.eventId);
|
|
131
|
-
if (ev) effectiveMove?.(ev,
|
|
293
|
+
if (ev) effectiveMove?.(ev, start, end);
|
|
132
294
|
} else if (mode === 'create') {
|
|
133
|
-
effectiveCreate?.({ start
|
|
295
|
+
effectiveCreate?.({ start, end });
|
|
134
296
|
}
|
|
135
297
|
}
|
|
136
298
|
|
|
@@ -148,22 +310,59 @@
|
|
|
148
310
|
get oneventclick() { return oneventclick; },
|
|
149
311
|
get oneventcreate() { return effectiveCreate; },
|
|
150
312
|
get oneventmove() { return effectiveMove; },
|
|
313
|
+
get oneventhover() { return oneventhover; },
|
|
151
314
|
});
|
|
152
315
|
setContext('calendar:readOnly', { get current() { return readOnly; } });
|
|
153
316
|
setContext('calendar:visibleHours', { get current() { return visibleHours; } });
|
|
154
317
|
setContext('calendar:snapInterval', { get current() { return snapInterval; } });
|
|
155
318
|
setContext('calendar:eventSnippet', { get current() { return eventSnippet; } });
|
|
156
319
|
setContext('calendar:emptySnippet', { get current() { return emptySnippet; } });
|
|
320
|
+
setContext('calendar:showNavigation', { get current() { return showNavigation; } });
|
|
321
|
+
setContext('calendar:equalDays', { get current() { return equalDays; } });
|
|
322
|
+
setContext('calendar:showDates', { get current() { return showDates; } });
|
|
323
|
+
setContext('calendar:hideDays', { get current() { return hideDays; } });
|
|
324
|
+
setContext('calendar:blockedSlots', { get current() { return blockedSlots; } });
|
|
325
|
+
setContext('calendar:dayHeaderSnippet', { get current() { return dayHeaderSnippet; } });
|
|
326
|
+
setContext('calendar:minDuration', { get current() { return minDuration; } });
|
|
327
|
+
setContext('calendar:maxDuration', { get current() { return maxDuration; } });
|
|
328
|
+
setContext('calendar:disabledDates', { get current() { return disabledDates; } });
|
|
329
|
+
setContext('calendar:mobile', { get current() { return useMobile; } });
|
|
330
|
+
|
|
331
|
+
// ── Load range signal ──
|
|
332
|
+
// Views can write a wider range here to override the default viewState.range.
|
|
333
|
+
// This lets infinite-scroll views (PlannerWeek, PlannerDay) declare their
|
|
334
|
+
// buffer needs without directly calling store.load().
|
|
335
|
+
let viewLoadRange = $state<{ start: Date; end: Date } | null>(null);
|
|
336
|
+
setContext('calendar:loadRange', {
|
|
337
|
+
get current() { return viewLoadRange; },
|
|
338
|
+
set(range: { start: Date; end: Date } | null) { viewLoadRange = range; },
|
|
339
|
+
});
|
|
157
340
|
|
|
158
|
-
// ── Load events when range changes ──
|
|
341
|
+
// ── Load events when effective range changes ──
|
|
159
342
|
$effect(() => {
|
|
160
|
-
const
|
|
161
|
-
store.load({ start, end });
|
|
343
|
+
const range = viewLoadRange ?? viewState.range;
|
|
344
|
+
store.load({ start: range.start, end: range.end });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Keep active view in sync when external view prop changes after mount.
|
|
348
|
+
$effect(() => {
|
|
349
|
+
if (activeViewId) viewState.setView(activeViewId);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Sync controlled currentDate prop → viewState
|
|
353
|
+
$effect(() => {
|
|
354
|
+
if (currentDate) viewState.setFocusDate(currentDate);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Sync days prop → viewState.dayCount
|
|
358
|
+
$effect(() => {
|
|
359
|
+
if (days !== undefined && viewState.dayCount !== days) viewState.setDayCount(days);
|
|
162
360
|
});
|
|
163
361
|
|
|
164
|
-
//
|
|
362
|
+
// Notify host when focusDate changes
|
|
165
363
|
$effect(() => {
|
|
166
|
-
viewState.
|
|
364
|
+
const d = viewState.focusDate;
|
|
365
|
+
ondatechange?.(d);
|
|
167
366
|
});
|
|
168
367
|
|
|
169
368
|
// Keep view state's week-start rule in sync with incoming prop changes.
|
|
@@ -173,17 +372,45 @@
|
|
|
173
372
|
}
|
|
174
373
|
});
|
|
175
374
|
|
|
176
|
-
// Notify host when active view changes (e.g. via
|
|
375
|
+
// Notify host when active view changes (e.g. via mode toggles).
|
|
177
376
|
$effect(() => {
|
|
178
377
|
onviewchange?.(viewState.view);
|
|
179
378
|
});
|
|
180
379
|
|
|
181
380
|
// ── Resolve active view ──
|
|
182
|
-
|
|
381
|
+
// When mobile is active, Planner views get remapped to Mobile variants.
|
|
382
|
+
// Agenda views stay as Agenda — they're already list-based and will adapt
|
|
383
|
+
// via the 'calendar:mobile' context flag.
|
|
384
|
+
const resolvedView = $derived.by(() => {
|
|
385
|
+
const requested = views.find((v) => v.id === viewState.view) ?? views[0];
|
|
386
|
+
if (!useMobile || !requested) return requested;
|
|
387
|
+
// Already a mobile view? Keep it.
|
|
388
|
+
if (requested.id.endsWith('-mobile')) return requested;
|
|
389
|
+
// Agenda views: keep as-is (they adapt internally via mobile context).
|
|
390
|
+
if (requested.label === 'Agenda') return requested;
|
|
391
|
+
// Planner / other views: remap to mobile variant with the same mode.
|
|
392
|
+
const mobileVariant = views.find(
|
|
393
|
+
(v) => v.id === `${requested.mode}-mobile`
|
|
394
|
+
);
|
|
395
|
+
return mobileVariant ?? requested;
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Backward-compat alias used in the template.
|
|
399
|
+
const activeView = $derived(resolvedView);
|
|
400
|
+
|
|
401
|
+
// Non-mobile views for mode pills (exclude mobile entries).
|
|
402
|
+
const desktopViews = $derived(views.filter((v) => !v.id.endsWith('-mobile')));
|
|
183
403
|
|
|
184
404
|
// ── Date label (always visible, centered over views) ──
|
|
185
405
|
const dateLabel = $derived.by(() => {
|
|
186
|
-
if (
|
|
406
|
+
if (!showDates) {
|
|
407
|
+
// Template week mode: just show weekday name (day) or nothing (week)
|
|
408
|
+
if (viewState.mode === 'day') {
|
|
409
|
+
return viewState.focusDate.toLocaleDateString(locale, { weekday: 'long' });
|
|
410
|
+
}
|
|
411
|
+
return ''; // week views have their own day headers
|
|
412
|
+
}
|
|
413
|
+
if (viewState.mode === 'day') {
|
|
187
414
|
return viewState.focusDate.toLocaleDateString(locale, {
|
|
188
415
|
weekday: 'long',
|
|
189
416
|
month: 'short',
|
|
@@ -196,38 +423,97 @@
|
|
|
196
423
|
});
|
|
197
424
|
});
|
|
198
425
|
|
|
199
|
-
// Which
|
|
200
|
-
const
|
|
201
|
-
const g = new Set(
|
|
426
|
+
// Which modes are available?
|
|
427
|
+
const modes = $derived.by(() => {
|
|
428
|
+
const g = new Set(desktopViews.map((v) => v.mode));
|
|
202
429
|
return (['day', 'week'] as const).filter((key) => g.has(key));
|
|
203
430
|
});
|
|
204
431
|
|
|
205
432
|
const L = $derived(getLabels());
|
|
433
|
+
|
|
434
|
+
/** Switch to a different mode (day/week), preserving the current view label. */
|
|
435
|
+
function switchMode(g: 'day' | 'week') {
|
|
436
|
+
const currentLabel = desktopViews.find((v) => v.id === viewState.view)?.label
|
|
437
|
+
?? activeView?.label;
|
|
438
|
+
const match = desktopViews.find((v) => v.mode === g && v.label === currentLabel);
|
|
439
|
+
const fallback = desktopViews.find((v) => v.mode === g);
|
|
440
|
+
const target = match ?? fallback;
|
|
441
|
+
if (target) viewState.setView(target.id);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** True when the current view range already includes today. */
|
|
445
|
+
const viewIncludesToday = $derived.by(() => {
|
|
446
|
+
const now = Date.now();
|
|
447
|
+
const { start, end } = viewState.range;
|
|
448
|
+
return now >= start.getTime() && now < end.getTime();
|
|
449
|
+
});
|
|
206
450
|
</script>
|
|
207
451
|
|
|
208
452
|
<div
|
|
209
453
|
class="cal"
|
|
210
|
-
|
|
454
|
+
bind:this={calEl}
|
|
455
|
+
style="{effectiveTheme}; --cal-h: {height}px; --cal-r: {borderRadius}px"
|
|
211
456
|
role="region"
|
|
212
457
|
aria-label={L.calendar}
|
|
213
458
|
dir={dir}
|
|
214
459
|
lang={locale}
|
|
215
460
|
>
|
|
216
|
-
<!--
|
|
217
|
-
{#if
|
|
461
|
+
<!-- ─── Mobile header (flow layout, no absolute) ─── -->
|
|
462
|
+
{#if useMobile}
|
|
463
|
+
<div class="cal-m-hd">
|
|
464
|
+
<div class="cal-m-left">
|
|
465
|
+
{#if showNavigation}
|
|
466
|
+
<button class="cal-m-nav" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : L.previousWeek}>
|
|
467
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true"><path d="M10 3 5 8l5 5"/></svg>
|
|
468
|
+
</button>
|
|
469
|
+
{/if}
|
|
470
|
+
|
|
471
|
+
{#if showModePills && modes.length > 1}
|
|
472
|
+
<div class="cal-m-pills" role="group" aria-label={L.viewMode}>
|
|
473
|
+
{#each modes as g}
|
|
474
|
+
<button
|
|
475
|
+
class="cal-m-pill"
|
|
476
|
+
class:cal-m-pill--active={viewState.mode === g}
|
|
477
|
+
aria-pressed={viewState.mode === g}
|
|
478
|
+
onclick={() => switchMode(g)}
|
|
479
|
+
>
|
|
480
|
+
{g === 'day' ? L.day : L.week}
|
|
481
|
+
</button>
|
|
482
|
+
{/each}
|
|
483
|
+
</div>
|
|
484
|
+
{/if}
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
<span class="cal-m-title">{dateLabel}</span>
|
|
488
|
+
|
|
489
|
+
<div class="cal-m-right">
|
|
490
|
+
{#if showNavigation}
|
|
491
|
+
<button class="cal-m-nav" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : L.nextWeek}>
|
|
492
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
|
|
493
|
+
</button>
|
|
494
|
+
{/if}
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
|
|
498
|
+
<!-- Today pill — floats below header, doesn't affect header flow -->
|
|
499
|
+
{#if showNavigation && !viewIncludesToday}
|
|
500
|
+
<div class="cal-m-today-bar">
|
|
501
|
+
<button class="cal-m-today" onclick={() => viewState.goToday()}>
|
|
502
|
+
{L.today}
|
|
503
|
+
</button>
|
|
504
|
+
</div>
|
|
505
|
+
{/if}
|
|
506
|
+
{/if}
|
|
507
|
+
|
|
508
|
+
<!-- Floating mode pills — desktop only (hidden for Agenda views) -->
|
|
509
|
+
{#if !useMobile && showModePills && modes.length > 1 && activeView?.label !== 'Agenda'}
|
|
218
510
|
<div class="cal-pills" role="group" aria-label={L.viewMode}>
|
|
219
|
-
{#each
|
|
511
|
+
{#each modes as g}
|
|
220
512
|
<button
|
|
221
513
|
class="cal-pill"
|
|
222
|
-
class:cal-pill--active={viewState.
|
|
223
|
-
aria-pressed={viewState.
|
|
224
|
-
onclick={() =>
|
|
225
|
-
const currentLabel = views.find((v) => v.id === viewState.view)?.label;
|
|
226
|
-
const match = views.find((v) => v.granularity === g && v.label === currentLabel);
|
|
227
|
-
const fallback = views.find((v) => v.granularity === g);
|
|
228
|
-
const target = match ?? fallback;
|
|
229
|
-
if (target) viewState.setView(target.id);
|
|
230
|
-
}}
|
|
514
|
+
class:cal-pill--active={viewState.mode === g}
|
|
515
|
+
aria-pressed={viewState.mode === g}
|
|
516
|
+
onclick={() => switchMode(g)}
|
|
231
517
|
>
|
|
232
518
|
{g === 'day' ? L.day : L.week}
|
|
233
519
|
</button>
|
|
@@ -240,8 +526,9 @@
|
|
|
240
526
|
{@const Comp = activeView.component}
|
|
241
527
|
<Comp
|
|
242
528
|
events={store.events}
|
|
243
|
-
style={
|
|
529
|
+
style={effectiveTheme}
|
|
244
530
|
height={null}
|
|
531
|
+
mode={activeView.mode}
|
|
245
532
|
mondayStart={viewState.mondayStart}
|
|
246
533
|
{locale}
|
|
247
534
|
focusDate={viewState.focusDate}
|
|
@@ -267,7 +554,7 @@
|
|
|
267
554
|
position: relative;
|
|
268
555
|
height: var(--cal-h, 600px);
|
|
269
556
|
background: var(--dt-bg, #0b0e14);
|
|
270
|
-
border-radius: 12px;
|
|
557
|
+
border-radius: var(--cal-r, 12px);
|
|
271
558
|
overflow: clip;
|
|
272
559
|
display: flex;
|
|
273
560
|
flex-direction: column;
|
|
@@ -353,4 +640,128 @@
|
|
|
353
640
|
0% { transform: translateX(-100%); }
|
|
354
641
|
100% { transform: translateX(100%); }
|
|
355
642
|
}
|
|
643
|
+
|
|
644
|
+
/* ── Mobile header (flow layout) ── */
|
|
645
|
+
.cal-m-hd {
|
|
646
|
+
display: flex;
|
|
647
|
+
align-items: center;
|
|
648
|
+
gap: 4px;
|
|
649
|
+
padding: 8px 8px 6px;
|
|
650
|
+
border-bottom: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
|
|
651
|
+
flex-shrink: 0;
|
|
652
|
+
min-height: 44px;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
.cal-m-left,
|
|
656
|
+
.cal-m-right {
|
|
657
|
+
display: flex;
|
|
658
|
+
align-items: center;
|
|
659
|
+
gap: 2px;
|
|
660
|
+
flex-shrink: 0;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
.cal-m-right {
|
|
664
|
+
justify-content: flex-end;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
.cal-m-nav {
|
|
668
|
+
display: flex;
|
|
669
|
+
align-items: center;
|
|
670
|
+
justify-content: center;
|
|
671
|
+
width: 32px;
|
|
672
|
+
height: 32px;
|
|
673
|
+
border: none;
|
|
674
|
+
background: transparent;
|
|
675
|
+
color: var(--dt-text-2, rgba(148, 163, 184, 0.55));
|
|
676
|
+
border-radius: 50%;
|
|
677
|
+
cursor: pointer;
|
|
678
|
+
transition: background 120ms, color 120ms;
|
|
679
|
+
-webkit-tap-highlight-color: transparent;
|
|
680
|
+
flex-shrink: 0;
|
|
681
|
+
}
|
|
682
|
+
.cal-m-nav:hover {
|
|
683
|
+
color: var(--dt-text, rgba(226, 232, 240, 0.85));
|
|
684
|
+
background: color-mix(in srgb, var(--dt-text, rgba(226, 232, 240, 0.85)) 8%, transparent);
|
|
685
|
+
}
|
|
686
|
+
.cal-m-nav:active {
|
|
687
|
+
background: var(--dt-accent-dim, rgba(239, 68, 68, 0.12));
|
|
688
|
+
}
|
|
689
|
+
.cal-m-nav:focus-visible {
|
|
690
|
+
outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
|
|
691
|
+
outline-offset: 2px;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
.cal-m-pills {
|
|
695
|
+
display: flex;
|
|
696
|
+
gap: 2px;
|
|
697
|
+
background: color-mix(in srgb, var(--dt-surface, #10141c) 85%, transparent);
|
|
698
|
+
border-radius: 8px;
|
|
699
|
+
padding: 2px;
|
|
700
|
+
border: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
|
|
701
|
+
flex-shrink: 0;
|
|
702
|
+
}
|
|
703
|
+
.cal-m-pill {
|
|
704
|
+
border: none;
|
|
705
|
+
background: transparent;
|
|
706
|
+
color: var(--dt-text-2, rgba(148, 163, 184, 0.55));
|
|
707
|
+
cursor: pointer;
|
|
708
|
+
font: 600 11px / 1 var(--dt-sans, system-ui, sans-serif);
|
|
709
|
+
padding: 5px 10px;
|
|
710
|
+
border-radius: 6px;
|
|
711
|
+
letter-spacing: 0.04em;
|
|
712
|
+
text-transform: uppercase;
|
|
713
|
+
transition: background 100ms, color 100ms;
|
|
714
|
+
-webkit-tap-highlight-color: transparent;
|
|
715
|
+
}
|
|
716
|
+
.cal-m-pill:hover {
|
|
717
|
+
color: var(--dt-text, rgba(226, 232, 240, 0.85));
|
|
718
|
+
}
|
|
719
|
+
.cal-m-pill--active {
|
|
720
|
+
background: var(--dt-accent, #ef4444);
|
|
721
|
+
color: var(--dt-btn-text, #fff);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.cal-m-title {
|
|
725
|
+
flex: 1;
|
|
726
|
+
text-align: center;
|
|
727
|
+
font: 600 14px / 1.2 var(--dt-sans, system-ui, sans-serif);
|
|
728
|
+
color: var(--dt-text, rgba(226, 232, 240, 0.85));
|
|
729
|
+
white-space: nowrap;
|
|
730
|
+
overflow: hidden;
|
|
731
|
+
text-overflow: ellipsis;
|
|
732
|
+
min-width: 0;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
.cal-m-today-bar {
|
|
736
|
+
display: flex;
|
|
737
|
+
justify-content: center;
|
|
738
|
+
padding: 12px 8px 6px;
|
|
739
|
+
flex-shrink: 0;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
.cal-m-today {
|
|
743
|
+
font: 600 11px / 1 var(--dt-sans, system-ui, sans-serif);
|
|
744
|
+
color: var(--dt-accent, #ef4444);
|
|
745
|
+
background: color-mix(in srgb, var(--dt-accent, #ef4444) 10%, transparent);
|
|
746
|
+
border: none;
|
|
747
|
+
padding: 5px 10px;
|
|
748
|
+
border-radius: 6px;
|
|
749
|
+
cursor: pointer;
|
|
750
|
+
white-space: nowrap;
|
|
751
|
+
letter-spacing: 0.04em;
|
|
752
|
+
text-transform: uppercase;
|
|
753
|
+
transition: background 120ms, color 120ms;
|
|
754
|
+
-webkit-tap-highlight-color: transparent;
|
|
755
|
+
flex-shrink: 0;
|
|
756
|
+
}
|
|
757
|
+
.cal-m-today:hover {
|
|
758
|
+
background: color-mix(in srgb, var(--dt-accent, #ef4444) 18%, transparent);
|
|
759
|
+
}
|
|
760
|
+
.cal-m-today:active {
|
|
761
|
+
background: color-mix(in srgb, var(--dt-accent, #ef4444) 25%, transparent);
|
|
762
|
+
}
|
|
763
|
+
.cal-m-today:focus-visible {
|
|
764
|
+
outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
|
|
765
|
+
outline-offset: 2px;
|
|
766
|
+
}
|
|
356
767
|
</style>
|