@nomideusz/svelte-calendar 0.5.2 → 0.6.3
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 +8 -6
- package/dist/adapters/recurring.js +28 -30
- package/dist/calendar/Calendar.svelte +776 -356
- package/dist/calendar/Calendar.svelte.d.ts +57 -6
- 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/primitives/DayHeader.svelte +103 -103
- package/dist/primitives/EmptySlot.svelte +105 -105
- package/dist/primitives/EventBlock.svelte +312 -312
- package/dist/primitives/NowIndicator.svelte +178 -178
- package/dist/primitives/TimeGutter.svelte +104 -104
- 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 +55 -42
- package/dist/views/agenda/AgendaDay.svelte +1073 -976
- package/dist/views/agenda/AgendaWeek.svelte +934 -775
- 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 +702 -0
- package/dist/views/mobile/MobileDay.svelte.d.ts +20 -0
- package/dist/views/mobile/MobileWeek.svelte +461 -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 +1129 -957
- package/dist/views/planner/PlannerWeek.svelte +949 -840
- package/dist/widget/CalendarWidget.svelte +142 -157
- package/package.json +2 -1
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
MobileDay — touch-first single-day view.
|
|
3
|
+
|
|
4
|
+
Vertical time grid (hours top → bottom). Scrolls vertically.
|
|
5
|
+
Large touch targets, swipe left/right to change day.
|
|
6
|
+
Events positioned absolutely within hour lanes.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import { getContext, onMount, tick, untrack } from 'svelte';
|
|
10
|
+
import { createClock } from '../../core/clock.svelte.js';
|
|
11
|
+
import type { TimelineEvent, BlockedSlot } from '../../core/types.js';
|
|
12
|
+
import type { DragState } from '../../engine/drag.svelte.js';
|
|
13
|
+
import type { ViewState } from '../../engine/view-state.svelte.js';
|
|
14
|
+
import { DAY_MS, HOUR_MS, sod, isAllDay, isMultiDay, segmentForDay } from '../../core/time.js';
|
|
15
|
+
import type { DaySegment } from '../../core/time.js';
|
|
16
|
+
import { fmtH, fmtTime, getLabels } from '../../core/locale.js';
|
|
17
|
+
|
|
18
|
+
const L = $derived(getLabels());
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
height?: number | null;
|
|
22
|
+
events?: TimelineEvent[];
|
|
23
|
+
style?: string;
|
|
24
|
+
focusDate?: Date;
|
|
25
|
+
locale?: string;
|
|
26
|
+
oneventclick?: (event: TimelineEvent) => void;
|
|
27
|
+
oneventcreate?: (range: { start: Date; end: Date }) => void;
|
|
28
|
+
selectedEventId?: string | null;
|
|
29
|
+
readOnly?: boolean;
|
|
30
|
+
visibleHours?: [number, number];
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let {
|
|
35
|
+
height = null,
|
|
36
|
+
events = [],
|
|
37
|
+
style = '',
|
|
38
|
+
locale,
|
|
39
|
+
focusDate,
|
|
40
|
+
oneventclick,
|
|
41
|
+
oneventcreate,
|
|
42
|
+
selectedEventId = null,
|
|
43
|
+
readOnly = false,
|
|
44
|
+
visibleHours,
|
|
45
|
+
}: Props = $props();
|
|
46
|
+
|
|
47
|
+
// ── Context ────────────────────────────────────────
|
|
48
|
+
const viewState = getContext<ViewState>('calendar:viewState') as ViewState | undefined;
|
|
49
|
+
const loadRangeCtx = getContext<{ current: { start: Date; end: Date } | null; set: (r: { start: Date; end: Date } | null) => void }>('calendar:loadRange') as { current: { start: Date; end: Date } | null; set: (r: { start: Date; end: Date } | null) => void } | undefined;
|
|
50
|
+
const blockedSlotsCtx = getContext<{ current: BlockedSlot[] | undefined }>('calendar:blockedSlots') as { current: BlockedSlot[] | undefined } | undefined;
|
|
51
|
+
const minDurationCtx = getContext<{ current: number | undefined }>('calendar:minDuration') as { current: number | undefined } | undefined;
|
|
52
|
+
const callbacksCtx = getContext<{ oneventhover?: (event: TimelineEvent) => void }>('calendar:callbacks') as { oneventhover?: (event: TimelineEvent) => void } | undefined;
|
|
53
|
+
const disabledDatesCtx = getContext<{ current: Date[] | undefined }>('calendar:disabledDates') as { current: Date[] | undefined } | undefined;
|
|
54
|
+
const autoHeightCtx = getContext<{ current: boolean }>('calendar:autoHeight') as { current: boolean } | undefined;
|
|
55
|
+
|
|
56
|
+
const blockedSlots = $derived(blockedSlotsCtx?.current);
|
|
57
|
+
const minDuration = $derived(minDurationCtx?.current);
|
|
58
|
+
const autoHeight = $derived(autoHeightCtx?.current ?? false);
|
|
59
|
+
const oneventhover = $derived(callbacksCtx?.oneventhover);
|
|
60
|
+
const disabledDates = $derived(disabledDatesCtx?.current);
|
|
61
|
+
const disabledSet = $derived(new Set(disabledDates?.map(d => sod(d.getTime())) ?? []));
|
|
62
|
+
|
|
63
|
+
const clock = createClock();
|
|
64
|
+
|
|
65
|
+
// ── Config ─────────────────────────────────────────
|
|
66
|
+
const HOUR_HEIGHT = 64;
|
|
67
|
+
const GUTTER_W = 40;
|
|
68
|
+
const startHour = $derived(visibleHours?.[0] ?? 0);
|
|
69
|
+
const endHour = $derived(visibleHours?.[1] ?? 24);
|
|
70
|
+
const hourCount = $derived(Math.max(1, endHour - startHour));
|
|
71
|
+
const gridHeight = $derived(hourCount * HOUR_HEIGHT);
|
|
72
|
+
|
|
73
|
+
// ── Day state ──────────────────────────────────────
|
|
74
|
+
const dayMs = $derived(focusDate ? sod(focusDate.getTime()) : clock.today);
|
|
75
|
+
const dayEnd = $derived(dayMs + DAY_MS);
|
|
76
|
+
const isToday = $derived(dayMs === clock.today);
|
|
77
|
+
const isPast = $derived(dayMs < clock.today);
|
|
78
|
+
const isDisabled = $derived(disabledSet.has(dayMs));
|
|
79
|
+
|
|
80
|
+
// ── Load range ─────────────────────────────────────
|
|
81
|
+
$effect(() => {
|
|
82
|
+
if (!loadRangeCtx) return;
|
|
83
|
+
const rangeStart = new Date(dayMs - 2 * DAY_MS);
|
|
84
|
+
const rangeEnd = new Date(dayMs + 3 * DAY_MS);
|
|
85
|
+
loadRangeCtx.set({ start: rangeStart, end: rangeEnd });
|
|
86
|
+
return () => loadRangeCtx.set(null);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── Events partition ───────────────────────────────
|
|
90
|
+
const timedEvents = $derived(
|
|
91
|
+
events
|
|
92
|
+
.filter(ev => !isAllDay(ev) && !isMultiDay(ev) && ev.start.getTime() < dayEnd && ev.end.getTime() > dayMs)
|
|
93
|
+
.sort((a, b) => a.start.getTime() - b.start.getTime())
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const allDayEvents = $derived.by(() => {
|
|
97
|
+
const segs: DaySegment[] = [];
|
|
98
|
+
for (const ev of events) {
|
|
99
|
+
if (!isAllDay(ev) && !isMultiDay(ev)) continue;
|
|
100
|
+
const seg = segmentForDay(ev, dayMs);
|
|
101
|
+
if (seg) segs.push(seg);
|
|
102
|
+
}
|
|
103
|
+
return segs;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── Positioned events ──────────────────────────────
|
|
107
|
+
interface PosEvent {
|
|
108
|
+
ev: TimelineEvent;
|
|
109
|
+
top: number;
|
|
110
|
+
height: number;
|
|
111
|
+
left: string;
|
|
112
|
+
width: string;
|
|
113
|
+
isCurrent: boolean;
|
|
114
|
+
isNext: boolean;
|
|
115
|
+
col: number;
|
|
116
|
+
totalCols: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const positionedEvents = $derived.by(() => {
|
|
120
|
+
const now = clock.tick;
|
|
121
|
+
const sorted = [...timedEvents];
|
|
122
|
+
|
|
123
|
+
// Find the next upcoming event today
|
|
124
|
+
let nextEventId: string | null = null;
|
|
125
|
+
if (isToday) {
|
|
126
|
+
for (const ev of [...sorted].sort((a, b) => a.start.getTime() - b.start.getTime())) {
|
|
127
|
+
const s = ev.start.getTime();
|
|
128
|
+
if (s > now) { nextEventId = ev.id; break; }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Overlap grouping
|
|
133
|
+
const infos = sorted.map(ev => {
|
|
134
|
+
const sMs = Math.max(ev.start.getTime(), dayMs + startHour * HOUR_MS);
|
|
135
|
+
const eMs = Math.min(ev.end.getTime(), dayMs + endHour * HOUR_MS);
|
|
136
|
+
const topH = (sMs - dayMs) / HOUR_MS - startHour;
|
|
137
|
+
const botH = (eMs - dayMs) / HOUR_MS - startHour;
|
|
138
|
+
return {
|
|
139
|
+
ev,
|
|
140
|
+
top: topH * HOUR_HEIGHT,
|
|
141
|
+
height: Math.max(24, (botH - topH) * HOUR_HEIGHT),
|
|
142
|
+
isCurrent: ev.start.getTime() <= now && ev.end.getTime() > now,
|
|
143
|
+
isNext: ev.id === nextEventId,
|
|
144
|
+
startMs: sMs,
|
|
145
|
+
endMs: eMs,
|
|
146
|
+
col: 0,
|
|
147
|
+
totalCols: 1,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Assign columns for overlapping events
|
|
152
|
+
const par = infos.map((_, i) => i);
|
|
153
|
+
function find(i: number): number {
|
|
154
|
+
while (par[i] !== i) { par[i] = par[par[i]]; i = par[i]; }
|
|
155
|
+
return i;
|
|
156
|
+
}
|
|
157
|
+
for (let i = 0; i < infos.length; i++) {
|
|
158
|
+
for (let j = i + 1; j < infos.length; j++) {
|
|
159
|
+
if (infos[j].startMs < infos[i].endMs) par[find(i)] = find(j);
|
|
160
|
+
else break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const groups = new Map<number, number[]>();
|
|
165
|
+
for (let i = 0; i < infos.length; i++) {
|
|
166
|
+
const root = find(i);
|
|
167
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
168
|
+
groups.get(root)!.push(i);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const [, indices] of groups) {
|
|
172
|
+
const rows: number[] = [];
|
|
173
|
+
for (const idx of indices) {
|
|
174
|
+
let row = 0;
|
|
175
|
+
for (let r = 0; r < rows.length; r++) {
|
|
176
|
+
if (rows[r] <= infos[idx].startMs) { row = r; rows[r] = infos[idx].endMs; break; }
|
|
177
|
+
row = r + 1;
|
|
178
|
+
}
|
|
179
|
+
if (row >= rows.length) rows.push(infos[idx].endMs);
|
|
180
|
+
infos[idx].col = row;
|
|
181
|
+
}
|
|
182
|
+
for (const idx of indices) infos[idx].totalCols = rows.length;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return infos.map(info => ({
|
|
186
|
+
ev: info.ev,
|
|
187
|
+
top: info.top,
|
|
188
|
+
height: info.height,
|
|
189
|
+
left: `calc(${GUTTER_W}px + ${(info.col / info.totalCols) * 100}% * (1 - ${GUTTER_W} / var(--mb-grid-w, 300)))`,
|
|
190
|
+
width: `calc((100% - ${GUTTER_W}px) / ${info.totalCols} - 2px)`,
|
|
191
|
+
isCurrent: info.isCurrent,
|
|
192
|
+
isNext: info.isNext,
|
|
193
|
+
col: info.col,
|
|
194
|
+
totalCols: info.totalCols,
|
|
195
|
+
})) as PosEvent[];
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── Now indicator ──────────────────────────────────
|
|
199
|
+
const nowOffset = $derived.by(() => {
|
|
200
|
+
if (!isToday) return -1;
|
|
201
|
+
const h = (clock.tick - dayMs) / HOUR_MS - startHour;
|
|
202
|
+
if (h < 0 || h > hourCount) return -1;
|
|
203
|
+
return h * HOUR_HEIGHT;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ── Blocked slot check ─────────────────────────────
|
|
207
|
+
function isBlockedAt(hour: number): boolean {
|
|
208
|
+
if (!blockedSlots?.length) return false;
|
|
209
|
+
const jsDay = new Date(dayMs).getDay();
|
|
210
|
+
const isoDay = jsDay === 0 ? 7 : jsDay;
|
|
211
|
+
return blockedSlots.some(slot => {
|
|
212
|
+
if (slot.day && slot.day !== isoDay) return false;
|
|
213
|
+
return hour >= slot.start && hour < slot.end;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Touch swipe navigation ─────────────────────────
|
|
218
|
+
let el: HTMLDivElement;
|
|
219
|
+
let touchStartX = 0;
|
|
220
|
+
let touchStartY = 0;
|
|
221
|
+
let swiping = false;
|
|
222
|
+
let swipeOffset = $state(0);
|
|
223
|
+
const SWIPE_THRESHOLD = 50;
|
|
224
|
+
|
|
225
|
+
function onTouchStart(e: TouchEvent) {
|
|
226
|
+
const t = e.touches[0];
|
|
227
|
+
touchStartX = t.clientX;
|
|
228
|
+
touchStartY = t.clientY;
|
|
229
|
+
swiping = true;
|
|
230
|
+
swipeOffset = 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function onTouchMove(e: TouchEvent) {
|
|
234
|
+
if (!swiping) return;
|
|
235
|
+
const t = e.touches[0];
|
|
236
|
+
const dx = t.clientX - touchStartX;
|
|
237
|
+
const dy = t.clientY - touchStartY;
|
|
238
|
+
// Only swipe if horizontal movement dominates
|
|
239
|
+
if (Math.abs(dy) > Math.abs(dx) * 0.8) { swiping = false; return; }
|
|
240
|
+
swipeOffset = dx;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function onTouchEnd() {
|
|
244
|
+
if (!swiping) { swipeOffset = 0; return; }
|
|
245
|
+
if (Math.abs(swipeOffset) > SWIPE_THRESHOLD) {
|
|
246
|
+
if (swipeOffset > 0) {
|
|
247
|
+
viewState?.prev();
|
|
248
|
+
} else {
|
|
249
|
+
viewState?.next();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
swipeOffset = 0;
|
|
253
|
+
swiping = false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Click-to-create ────────────────────────────────
|
|
257
|
+
function handleGridClick(e: MouseEvent) {
|
|
258
|
+
if (!oneventcreate || readOnly || isDisabled) return;
|
|
259
|
+
if ((e.target as HTMLElement).closest('.mb-event')) return;
|
|
260
|
+
const grid = (e.currentTarget as HTMLElement);
|
|
261
|
+
const rect = grid.getBoundingClientRect();
|
|
262
|
+
const y = e.clientY - rect.top + grid.scrollTop;
|
|
263
|
+
const hour = startHour + y / HOUR_HEIGHT;
|
|
264
|
+
if (isBlockedAt(hour)) return;
|
|
265
|
+
const snapHour = Math.floor(hour);
|
|
266
|
+
const durMin = minDuration ? Math.max(60, minDuration) : 60;
|
|
267
|
+
const start = new Date(dayMs + snapHour * HOUR_MS);
|
|
268
|
+
const end = new Date(start.getTime() + durMin * 60_000);
|
|
269
|
+
oneventcreate({ start, end });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Auto-scroll to now ─────────────────────────────
|
|
273
|
+
let gridEl: HTMLDivElement;
|
|
274
|
+
onMount(() => {
|
|
275
|
+
if (nowOffset > 0 && gridEl) {
|
|
276
|
+
const scrollTarget = Math.max(0, nowOffset - 120);
|
|
277
|
+
gridEl.scrollTop = scrollTarget;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
</script>
|
|
281
|
+
|
|
282
|
+
<div
|
|
283
|
+
class="mb"
|
|
284
|
+
class:mb--auto={autoHeight}
|
|
285
|
+
style={style || undefined}
|
|
286
|
+
style:height={autoHeight ? undefined : (height ? `${height}px` : '100%')}
|
|
287
|
+
role="region"
|
|
288
|
+
aria-label={L.dayPlanner}
|
|
289
|
+
ontouchstart={onTouchStart}
|
|
290
|
+
ontouchmove={onTouchMove}
|
|
291
|
+
ontouchend={onTouchEnd}
|
|
292
|
+
>
|
|
293
|
+
<!-- All-day events bar -->
|
|
294
|
+
{#if allDayEvents.length > 0}
|
|
295
|
+
<div class="mb-allday">
|
|
296
|
+
{#each allDayEvents.slice(0, 3) as seg (seg.ev.id)}
|
|
297
|
+
<button
|
|
298
|
+
class="mb-allday-chip"
|
|
299
|
+
class:mb-allday-chip--selected={selectedEventId === seg.ev.id}
|
|
300
|
+
style:--ev-color={seg.ev.color ?? 'var(--dt-accent)'}
|
|
301
|
+
onclick={() => oneventclick?.(seg.ev)}
|
|
302
|
+
>
|
|
303
|
+
<span class="mb-allday-dot"></span>
|
|
304
|
+
<span class="mb-allday-title">{seg.ev.title}</span>
|
|
305
|
+
{#if seg.totalDays > 1}
|
|
306
|
+
<span class="mb-allday-span">{seg.dayIndex}/{seg.totalDays}</span>
|
|
307
|
+
{/if}
|
|
308
|
+
</button>
|
|
309
|
+
{/each}
|
|
310
|
+
{#if allDayEvents.length > 3}
|
|
311
|
+
<span class="mb-allday-more">{L.nMore(allDayEvents.length - 3)}</span>
|
|
312
|
+
{/if}
|
|
313
|
+
</div>
|
|
314
|
+
{/if}
|
|
315
|
+
|
|
316
|
+
<!-- Scrollable time grid -->
|
|
317
|
+
<div
|
|
318
|
+
class="mb-grid"
|
|
319
|
+
bind:this={gridEl}
|
|
320
|
+
onclick={handleGridClick}
|
|
321
|
+
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleGridClick(e as unknown as MouseEvent); }}
|
|
322
|
+
role="grid"
|
|
323
|
+
tabindex="-1"
|
|
324
|
+
style:--mb-grid-w="300"
|
|
325
|
+
>
|
|
326
|
+
<div class="mb-grid-inner" style:height="{gridHeight}px">
|
|
327
|
+
<!-- Hour lanes -->
|
|
328
|
+
{#each { length: hourCount } as _, h}
|
|
329
|
+
{@const hour = startHour + h}
|
|
330
|
+
{@const blocked = isBlockedAt(hour)}
|
|
331
|
+
<div
|
|
332
|
+
class="mb-hour"
|
|
333
|
+
class:mb-hour--blocked={blocked}
|
|
334
|
+
style:top="{h * HOUR_HEIGHT}px"
|
|
335
|
+
style:height="{HOUR_HEIGHT}px"
|
|
336
|
+
>
|
|
337
|
+
<div class="mb-hour-label">{fmtH(hour, locale)}</div>
|
|
338
|
+
<div class="mb-hour-line"></div>
|
|
339
|
+
{#if blocked && blockedSlots}
|
|
340
|
+
{@const slot = blockedSlots.find(s => (!s.day || s.day === (new Date(dayMs).getDay() === 0 ? 7 : new Date(dayMs).getDay())) && hour >= s.start && hour < s.end)}
|
|
341
|
+
{#if slot?.label}
|
|
342
|
+
<span class="mb-blocked-label">{slot.label}</span>
|
|
343
|
+
{/if}
|
|
344
|
+
{/if}
|
|
345
|
+
</div>
|
|
346
|
+
{/each}
|
|
347
|
+
|
|
348
|
+
<!-- Now line -->
|
|
349
|
+
{#if nowOffset >= 0}
|
|
350
|
+
<div class="mb-now" style:top="{nowOffset}px">
|
|
351
|
+
<span class="mb-now-label">{clock.hm}</span>
|
|
352
|
+
<div class="mb-now-line"></div>
|
|
353
|
+
</div>
|
|
354
|
+
{/if}
|
|
355
|
+
|
|
356
|
+
<!-- Events -->
|
|
357
|
+
{#each positionedEvents as p (p.ev.id)}
|
|
358
|
+
<button
|
|
359
|
+
class="mb-event"
|
|
360
|
+
class:mb-event--selected={selectedEventId === p.ev.id}
|
|
361
|
+
class:mb-event--current={p.isCurrent}
|
|
362
|
+
class:mb-event--next={p.isNext}
|
|
363
|
+
style:top="{p.top}px"
|
|
364
|
+
style:height="{p.height}px"
|
|
365
|
+
style:left={p.left}
|
|
366
|
+
style:width={p.width}
|
|
367
|
+
style:--ev-color={p.ev.color ?? 'var(--dt-accent)'}
|
|
368
|
+
onclick={(e) => { e.stopPropagation(); oneventclick?.(p.ev); }}
|
|
369
|
+
onpointerenter={() => oneventhover?.(p.ev)}
|
|
370
|
+
aria-label="{p.ev.title}{p.isCurrent ? `, ${L.inProgress}` : ''}{p.isNext ? `, ${L.upNext}` : ''}"
|
|
371
|
+
>
|
|
372
|
+
<div class="mb-ev-stripe"></div>
|
|
373
|
+
<div class="mb-ev-body">
|
|
374
|
+
<span class="mb-ev-title">{p.ev.title}</span>
|
|
375
|
+
{#if p.height > 32}
|
|
376
|
+
<span class="mb-ev-time">{fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}</span>
|
|
377
|
+
{/if}
|
|
378
|
+
{#if p.ev.subtitle && p.height > 48}
|
|
379
|
+
<span class="mb-ev-sub">{p.ev.subtitle}</span>
|
|
380
|
+
{/if}
|
|
381
|
+
{#if p.ev.tags?.length && p.height > 56}
|
|
382
|
+
<div class="mb-ev-tags">
|
|
383
|
+
{#each p.ev.tags as tag}
|
|
384
|
+
<span class="mb-ev-tag">{tag}</span>
|
|
385
|
+
{/each}
|
|
386
|
+
</div>
|
|
387
|
+
{/if}
|
|
388
|
+
</div>
|
|
389
|
+
{#if p.isCurrent}
|
|
390
|
+
<span class="mb-ev-live"></span>
|
|
391
|
+
{:else if p.isNext}
|
|
392
|
+
<span class="mb-ev-next-badge">{L.upNext}</span>
|
|
393
|
+
{/if}
|
|
394
|
+
</button>
|
|
395
|
+
{/each}
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
|
|
400
|
+
<style>
|
|
401
|
+
/* ─── Container ──────────────────────────────────── */
|
|
402
|
+
.mb {
|
|
403
|
+
position: relative;
|
|
404
|
+
display: flex;
|
|
405
|
+
flex-direction: column;
|
|
406
|
+
user-select: none;
|
|
407
|
+
font-variant-numeric: tabular-nums;
|
|
408
|
+
overflow: hidden;
|
|
409
|
+
background: var(--dt-bg, #fff);
|
|
410
|
+
-webkit-tap-highlight-color: transparent;
|
|
411
|
+
}
|
|
412
|
+
.mb--auto { overflow: visible; }
|
|
413
|
+
|
|
414
|
+
/* ─── All-day bar ────────────────────────────────── */
|
|
415
|
+
.mb-allday {
|
|
416
|
+
display: flex;
|
|
417
|
+
gap: 4px;
|
|
418
|
+
padding: 4px 8px;
|
|
419
|
+
overflow-x: auto;
|
|
420
|
+
scrollbar-width: none;
|
|
421
|
+
border-bottom: 1px solid var(--dt-border, rgba(0, 0, 0, 0.06));
|
|
422
|
+
flex-shrink: 0;
|
|
423
|
+
align-items: center;
|
|
424
|
+
}
|
|
425
|
+
.mb-allday::-webkit-scrollbar { display: none; }
|
|
426
|
+
|
|
427
|
+
.mb-allday-chip {
|
|
428
|
+
display: flex;
|
|
429
|
+
align-items: center;
|
|
430
|
+
gap: 4px;
|
|
431
|
+
padding: 4px 8px;
|
|
432
|
+
border-radius: 5px;
|
|
433
|
+
background: color-mix(in srgb, var(--ev-color) 12%, var(--dt-surface, #f9fafb));
|
|
434
|
+
border: none;
|
|
435
|
+
cursor: pointer;
|
|
436
|
+
flex-shrink: 0;
|
|
437
|
+
transition: background 120ms;
|
|
438
|
+
-webkit-tap-highlight-color: transparent;
|
|
439
|
+
max-width: 160px;
|
|
440
|
+
}
|
|
441
|
+
.mb-allday-chip:active {
|
|
442
|
+
background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, #f9fafb));
|
|
443
|
+
}
|
|
444
|
+
.mb-allday-chip--selected {
|
|
445
|
+
box-shadow: 0 0 0 1.5px var(--ev-color);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.mb-allday-dot {
|
|
449
|
+
width: 6px;
|
|
450
|
+
height: 6px;
|
|
451
|
+
border-radius: 50%;
|
|
452
|
+
background: var(--ev-color);
|
|
453
|
+
flex-shrink: 0;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.mb-allday-title {
|
|
457
|
+
font: 500 11px/1 var(--dt-sans, system-ui, sans-serif);
|
|
458
|
+
color: var(--dt-text, rgba(0, 0, 0, 0.87));
|
|
459
|
+
white-space: nowrap;
|
|
460
|
+
max-width: 100px;
|
|
461
|
+
overflow: hidden;
|
|
462
|
+
text-overflow: ellipsis;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.mb-allday-span {
|
|
466
|
+
font: 400 10px/1 var(--dt-sans, system-ui, sans-serif);
|
|
467
|
+
color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
.mb-allday-more {
|
|
471
|
+
font: 500 11px/1 var(--dt-sans, system-ui, sans-serif);
|
|
472
|
+
color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
|
|
473
|
+
white-space: nowrap;
|
|
474
|
+
flex-shrink: 0;
|
|
475
|
+
padding: 0 4px;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* ─── Grid ───────────────────────────────────────── */
|
|
479
|
+
.mb-grid {
|
|
480
|
+
flex: 1;
|
|
481
|
+
overflow-y: auto;
|
|
482
|
+
overflow-x: hidden;
|
|
483
|
+
-webkit-overflow-scrolling: touch;
|
|
484
|
+
scrollbar-width: thin;
|
|
485
|
+
scrollbar-color: var(--dt-scrollbar, rgba(0, 0, 0, 0.08)) transparent;
|
|
486
|
+
position: relative;
|
|
487
|
+
padding-top: 8px;
|
|
488
|
+
}
|
|
489
|
+
.mb--auto .mb-grid { overflow-y: visible; }
|
|
490
|
+
|
|
491
|
+
.mb-grid-inner {
|
|
492
|
+
position: relative;
|
|
493
|
+
min-width: 100%;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/* ─── Hour row ───────────────────────────────────── */
|
|
497
|
+
.mb-hour {
|
|
498
|
+
position: absolute;
|
|
499
|
+
left: 0;
|
|
500
|
+
right: 0;
|
|
501
|
+
display: flex;
|
|
502
|
+
align-items: flex-start;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
.mb-hour-label {
|
|
506
|
+
width: 40px;
|
|
507
|
+
flex-shrink: 0;
|
|
508
|
+
font: 500 11px/1 var(--dt-mono, ui-monospace, monospace);
|
|
509
|
+
color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
|
|
510
|
+
text-align: right;
|
|
511
|
+
padding-right: 8px;
|
|
512
|
+
padding-top: 0;
|
|
513
|
+
position: relative;
|
|
514
|
+
top: -6px;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
.mb-hour-line {
|
|
518
|
+
flex: 1;
|
|
519
|
+
height: 1px;
|
|
520
|
+
background: var(--dt-border, rgba(0, 0, 0, 0.08));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
.mb-hour--blocked {
|
|
524
|
+
background: repeating-linear-gradient(
|
|
525
|
+
-45deg,
|
|
526
|
+
color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 3%, transparent),
|
|
527
|
+
color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 3%, transparent) 4px,
|
|
528
|
+
transparent 4px,
|
|
529
|
+
transparent 8px
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
.mb-blocked-label {
|
|
534
|
+
position: absolute;
|
|
535
|
+
left: 44px;
|
|
536
|
+
top: 50%;
|
|
537
|
+
transform: translateY(-50%);
|
|
538
|
+
font: 500 9px/1 var(--dt-sans, system-ui, sans-serif);
|
|
539
|
+
color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
|
|
540
|
+
text-transform: uppercase;
|
|
541
|
+
letter-spacing: 0.04em;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/* ─── Now line ───────────────────────────────────── */
|
|
545
|
+
.mb-now {
|
|
546
|
+
position: absolute;
|
|
547
|
+
left: 0;
|
|
548
|
+
right: 0;
|
|
549
|
+
z-index: 10;
|
|
550
|
+
display: flex;
|
|
551
|
+
align-items: center;
|
|
552
|
+
pointer-events: none;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
.mb-now-label {
|
|
556
|
+
width: 40px;
|
|
557
|
+
flex-shrink: 0;
|
|
558
|
+
text-align: right;
|
|
559
|
+
padding-right: 6px;
|
|
560
|
+
font: 700 10px/1 var(--dt-mono, ui-monospace, monospace);
|
|
561
|
+
color: var(--dt-accent, #2563eb);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
.mb-now-line {
|
|
565
|
+
flex: 1;
|
|
566
|
+
height: 2px;
|
|
567
|
+
background: var(--dt-accent, #2563eb);
|
|
568
|
+
box-shadow: 0 0 6px var(--dt-glow, rgba(37, 99, 235, 0.25));
|
|
569
|
+
position: relative;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
.mb-now-line::before {
|
|
573
|
+
content: '';
|
|
574
|
+
position: absolute;
|
|
575
|
+
left: -4px;
|
|
576
|
+
top: -4px;
|
|
577
|
+
width: 10px;
|
|
578
|
+
height: 10px;
|
|
579
|
+
border-radius: 50%;
|
|
580
|
+
background: var(--dt-accent, #2563eb);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* ─── Events ─────────────────────────────────────── */
|
|
584
|
+
.mb-event {
|
|
585
|
+
position: absolute;
|
|
586
|
+
z-index: 5;
|
|
587
|
+
border-radius: 8px;
|
|
588
|
+
cursor: pointer;
|
|
589
|
+
background: color-mix(in srgb, var(--ev-color) 12%, var(--dt-surface, #f9fafb));
|
|
590
|
+
border: none;
|
|
591
|
+
display: flex;
|
|
592
|
+
align-items: stretch;
|
|
593
|
+
overflow: hidden;
|
|
594
|
+
transition: box-shadow 120ms, background 120ms;
|
|
595
|
+
text-align: left;
|
|
596
|
+
padding: 0;
|
|
597
|
+
-webkit-tap-highlight-color: transparent;
|
|
598
|
+
min-height: 24px;
|
|
599
|
+
}
|
|
600
|
+
.mb-event:active {
|
|
601
|
+
background: color-mix(in srgb, var(--ev-color) 20%, var(--dt-surface, #f9fafb));
|
|
602
|
+
}
|
|
603
|
+
.mb-event--selected {
|
|
604
|
+
box-shadow: 0 0 0 2px var(--ev-color),
|
|
605
|
+
0 2px 12px color-mix(in srgb, var(--ev-color) 25%, transparent);
|
|
606
|
+
}
|
|
607
|
+
.mb-event--current {
|
|
608
|
+
background: color-mix(in srgb, var(--ev-color) 18%, var(--dt-surface, #f9fafb));
|
|
609
|
+
}
|
|
610
|
+
.mb-event--next {
|
|
611
|
+
background: color-mix(in srgb, var(--ev-color) 8%, var(--dt-surface, #f9fafb));
|
|
612
|
+
border: 1px dashed color-mix(in srgb, var(--ev-color) 35%, transparent);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.mb-ev-stripe {
|
|
616
|
+
width: 4px;
|
|
617
|
+
background: var(--ev-color);
|
|
618
|
+
flex-shrink: 0;
|
|
619
|
+
border-radius: 8px 0 0 8px;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
.mb-ev-body {
|
|
623
|
+
flex: 1;
|
|
624
|
+
min-width: 0;
|
|
625
|
+
padding: 4px 8px;
|
|
626
|
+
display: flex;
|
|
627
|
+
flex-direction: column;
|
|
628
|
+
gap: 1px;
|
|
629
|
+
justify-content: center;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
.mb-ev-title {
|
|
633
|
+
font: 600 13px/1.2 var(--dt-sans, system-ui, sans-serif);
|
|
634
|
+
color: var(--dt-text, rgba(0, 0, 0, 0.87));
|
|
635
|
+
white-space: nowrap;
|
|
636
|
+
overflow: hidden;
|
|
637
|
+
text-overflow: ellipsis;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.mb-ev-time {
|
|
641
|
+
font: 400 11px/1 var(--dt-mono, ui-monospace, monospace);
|
|
642
|
+
color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.mb-ev-sub {
|
|
646
|
+
font: 400 11px/1.1 var(--dt-sans, system-ui, sans-serif);
|
|
647
|
+
color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
|
|
648
|
+
white-space: nowrap;
|
|
649
|
+
overflow: hidden;
|
|
650
|
+
text-overflow: ellipsis;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.mb-ev-tags {
|
|
654
|
+
display: flex;
|
|
655
|
+
gap: 4px;
|
|
656
|
+
margin-top: 2px;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.mb-ev-tag {
|
|
660
|
+
font: 500 9px/1 var(--dt-sans, system-ui, sans-serif);
|
|
661
|
+
color: var(--ev-color, var(--dt-accent));
|
|
662
|
+
background: color-mix(in srgb, var(--ev-color, var(--dt-accent)) 15%, transparent);
|
|
663
|
+
padding: 2px 5px;
|
|
664
|
+
border-radius: 3px;
|
|
665
|
+
white-space: nowrap;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
.mb-ev-live {
|
|
669
|
+
position: absolute;
|
|
670
|
+
top: 6px;
|
|
671
|
+
right: 6px;
|
|
672
|
+
width: 7px;
|
|
673
|
+
height: 7px;
|
|
674
|
+
border-radius: 50%;
|
|
675
|
+
background: var(--ev-color, var(--dt-accent));
|
|
676
|
+
animation: mb-pulse 2s ease-in-out infinite;
|
|
677
|
+
}
|
|
678
|
+
.mb-ev-next-badge {
|
|
679
|
+
position: absolute;
|
|
680
|
+
top: 4px;
|
|
681
|
+
right: 4px;
|
|
682
|
+
font: 600 8px/1 var(--dt-sans, system-ui, sans-serif);
|
|
683
|
+
text-transform: uppercase;
|
|
684
|
+
letter-spacing: 0.06em;
|
|
685
|
+
color: var(--ev-color, var(--dt-accent));
|
|
686
|
+
background: color-mix(in srgb, var(--ev-color, var(--dt-accent)) 15%, transparent);
|
|
687
|
+
padding: 2px 5px;
|
|
688
|
+
border-radius: 3px;
|
|
689
|
+
white-space: nowrap;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
@keyframes mb-pulse {
|
|
693
|
+
0%, 100% { opacity: 1; }
|
|
694
|
+
50% { opacity: 0.4; }
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/* ─── Focus ──────────────────────────────────────── */
|
|
698
|
+
.mb-event:focus-visible {
|
|
699
|
+
outline: 2px solid var(--dt-accent, #2563eb);
|
|
700
|
+
outline-offset: 2px;
|
|
701
|
+
}
|
|
702
|
+
</style>
|