@nomideusz/svelte-calendar 0.20.1 → 1.0.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.
Files changed (69) hide show
  1. package/README.md +502 -267
  2. package/dist/adapters/composite.d.ts +12 -1
  3. package/dist/adapters/composite.js +109 -47
  4. package/dist/adapters/errors.d.ts +22 -0
  5. package/dist/adapters/errors.js +32 -0
  6. package/dist/adapters/index.d.ts +1 -0
  7. package/dist/adapters/index.js +1 -0
  8. package/dist/adapters/jmap.js +26 -17
  9. package/dist/adapters/mapped.d.ts +2 -2
  10. package/dist/adapters/mapped.js +18 -9
  11. package/dist/adapters/memory.d.ts +1 -1
  12. package/dist/adapters/memory.js +5 -3
  13. package/dist/adapters/recurring.d.ts +1 -1
  14. package/dist/adapters/recurring.js +43 -25
  15. package/dist/adapters/rest.d.ts +2 -2
  16. package/dist/adapters/rest.js +9 -4
  17. package/dist/calendar/Calendar.svelte +122 -78
  18. package/dist/calendar/Calendar.svelte.d.ts +69 -14
  19. package/dist/calendar/index.d.ts +2 -2
  20. package/dist/calendar/index.js +1 -1
  21. package/dist/core/clock.svelte.js +3 -2
  22. package/dist/core/locale.d.ts +11 -1
  23. package/dist/core/locale.js +16 -16
  24. package/dist/core/time.d.ts +9 -0
  25. package/dist/core/time.js +12 -5
  26. package/dist/core/timezone.d.ts +7 -3
  27. package/dist/core/timezone.js +10 -4
  28. package/dist/engine/event-store.svelte.js +94 -57
  29. package/dist/engine/view-state.svelte.d.ts +5 -1
  30. package/dist/engine/view-state.svelte.js +8 -6
  31. package/dist/headless/create-agenda.svelte.d.ts +8 -1
  32. package/dist/headless/create-agenda.svelte.js +36 -18
  33. package/dist/headless/create-calendar.svelte.js +57 -21
  34. package/dist/headless/create-range-agenda.svelte.d.ts +7 -0
  35. package/dist/headless/create-range-agenda.svelte.js +38 -17
  36. package/dist/headless/index.d.ts +1 -0
  37. package/dist/index.d.ts +10 -7
  38. package/dist/index.js +8 -5
  39. package/dist/primitives/DayHeader.svelte +3 -2
  40. package/dist/primitives/DayHeader.svelte.d.ts +1 -1
  41. package/dist/primitives/EmptySlot.svelte +5 -2
  42. package/dist/primitives/EventBlock.svelte +21 -14
  43. package/dist/primitives/FloatingPanel.svelte +110 -15
  44. package/dist/primitives/FloatingPanel.svelte.d.ts +7 -5
  45. package/dist/primitives/NowIndicator.svelte +11 -4
  46. package/dist/primitives/TimeGutter.svelte +10 -6
  47. package/dist/primitives/index.d.ts +1 -0
  48. package/dist/text-fit.d.ts +8 -2
  49. package/dist/views/agenda/AgendaDay.svelte +43 -35
  50. package/dist/views/agenda/AgendaWeek.svelte +75 -38
  51. package/dist/views/index.d.ts +2 -0
  52. package/dist/views/index.js +2 -0
  53. package/dist/views/mobile/MobileDay.svelte +99 -42
  54. package/dist/views/mobile/MobileWeek.svelte +31 -15
  55. package/dist/views/mobile/swipe.js +6 -1
  56. package/dist/views/month/MonthGrid.svelte +33 -21
  57. package/dist/views/month/MonthGrid.svelte.d.ts +1 -1
  58. package/dist/views/planner/PlannerScroll.svelte +438 -164
  59. package/dist/views/planner/PlannerWeek.svelte +150 -50
  60. package/dist/views/shared/context.svelte.d.ts +1 -1
  61. package/package.json +24 -9
  62. package/widget/widget.js +10000 -9520
  63. package/dist/assets/favicon.svg +0 -9
  64. package/dist/widget/CalendarWidget.svelte +0 -114
  65. package/dist/widget/CalendarWidget.svelte.d.ts +0 -37
  66. package/dist/widget/index.d.ts +0 -1
  67. package/dist/widget/index.js +0 -1
  68. package/dist/widget/widget.d.ts +0 -10
  69. package/dist/widget/widget.js +0 -96
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm](https://badgen.net/npm/v/@nomideusz/svelte-calendar)](https://www.npmjs.com/package/@nomideusz/svelte-calendar) [![license](https://badgen.net/badge/license/MIT/blue)](https://github.com/nomideusz/svelte-calendar/blob/main/LICENSE)
4
4
 
5
- A themeable **Svelte 5** calendar with **Day/Week Planner & Agenda** views, touch-first mobile views, and a smart auto-theme that adapts to any page.
5
+ A themeable **Svelte 5** calendar: day and week **Planner** time grids, a multi-week **Scroll** view, **Agenda** lists, a **Month** grid and touch-first **Mobile** layouts — with drag-and-drop, timezones, a headless API, an embeddable web component, and a smart auto-theme that adapts to any page.
6
6
 
7
7
  **[Live demo → svelte-calendar.xyz](https://svelte-calendar.xyz/)**
8
8
 
@@ -12,7 +12,11 @@ A themeable **Svelte 5** calendar with **Day/Week Planner & Agenda** views, touc
12
12
  pnpm add @nomideusz/svelte-calendar
13
13
  ```
14
14
 
15
- > Requires Svelte 5 (`^5.0.0`)
15
+ Requirements:
16
+
17
+ - **Svelte `^5.29.0`** (peer dependency — the package uses attachments, `{@attach}`).
18
+ - Runtime dependencies (installed for you): `date-fns`, `date-fns-tz`, `@chenglou/pretext`.
19
+ - Node ≥ 22 is only needed to develop the package itself, not to use it.
16
20
 
17
21
  ## Quick Start
18
22
 
@@ -29,65 +33,119 @@ pnpm add @nomideusz/svelte-calendar
29
33
  <Calendar {adapter} />
30
34
  ```
31
35
 
32
- That's it — 6 views (Day/Week × Planner, Agenda, Mobile), auto-coloring, drag-and-drop with live previews, all out of the box. The default `auto` theme probes your page's background, accent color, and fonts, so the calendar adapts to any design.
36
+ That's it — 8 built-in views, auto-coloring, drag-and-drop with live previews, all out of the box. The default `auto` theme probes your page's background, accent color and fonts, so the calendar adapts to any design.
33
37
 
34
38
  ## Views
35
39
 
36
- Switch between **Planner** (time grid), **Scroll** (weeks stacked in one
37
- vertical scroller — drag to move, drop onto a day, scrolls under a drag),
38
- **Agenda** (list) and the **Month** grid:
40
+ The default registry (`defaultViews`) holds eight views:
41
+
42
+ | View ID | Mode | What it is |
43
+ |---------|------|------------|
44
+ | `day-planner` | day | Vertical time grid, one column. **The default view** (first registered). |
45
+ | `week-planner` | week | The same time grid, one column per day (`days`, default 7). |
46
+ | `week-scroll` | week | Weeks stacked in one infinite vertical scroller (Hey-Calendar style): each chip sits at the height of its start time, month names run down the side. Drag to move, drop onto a day; it scrolls under a drag. |
47
+ | `day-agenda` | day | Live list for one day: now / up next / done. |
48
+ | `week-agenda` | week | List of the week's days (`columns` turns it into a timetable grid). |
49
+ | `day-mobile` | day | Touch-first time grid with swipe navigation. |
50
+ | `week-mobile` | week | Touch-first list of the week's days. |
51
+ | `month-grid` | month | Week-aligned month grid, chips per day with a "+N more" overflow. |
39
52
 
40
53
  ```svelte
41
- <Calendar {adapter} view="week-planner" /> <!-- default -->
54
+ <Calendar {adapter} view="week-planner" />
42
55
  <Calendar {adapter} view="week-scroll" />
43
- <Calendar {adapter} view="day-planner" />
44
- <Calendar {adapter} view="week-agenda" />
45
- <Calendar {adapter} view="day-agenda" />
46
56
  <Calendar {adapter} view="month-grid" />
47
57
  ```
48
58
 
49
- Users can also switch via the built-in Day/Week/Month pills.
59
+ Users switch with the built-in pills: **Day / Week / Month** for the mode, plus view-type pills (**Planner / Scroll / Agenda**) when a mode has more than one view. Hide them when your app controls the view (`view` stays reactive after mount):
60
+
61
+ ```svelte
62
+ <Calendar {adapter} view="day-planner" showModePills={false} />
63
+ ```
50
64
 
51
65
  Planner views are designed for direct manipulation:
52
66
 
53
67
  - **Move** — drag an event to another day or time; a ghost previews the target before the move commits.
54
- - **Resize** — drag an event's top/bottom edge handles to change its duration; `minDuration`/`maxDuration` clamp at commit.
55
- - **Drag-to-create** — press on empty canvas and sweep to draw a new event; a plain click still creates a default-length slot. Both fire `oneventcreate` with the final range, after blocked-slot and disabled-date validation.
68
+ - **Resize** — drag an event's top/bottom grip to change its duration; `minDuration`/`maxDuration` clamp the result.
69
+ - **Create** — press on empty canvas and sweep to draw a range, or click for a default-length slot. Both go through the same validation (blocked slots, disabled dates, `minDuration`/`maxDuration`) and then fire `oneventcreate`.
70
+ - **External drop** — an HTML5 drag from outside the calendar (a class chip, a template) dropped on the planner or `week-scroll` fires `onexternaldrop({ start, dataTransfer })`.
56
71
 
57
- The month grid lists events as chips per day with a "+N more" overflow; clicking a day fires `ondayclick(date)` — the natural month → day drill-down.
72
+ `week-scroll` supports move, click-to-create and external drops. In `week-agenda` with `columns` and an `oneventmove` handler, a card can be dragged to another day (it keeps its time of day); that move goes straight to `oneventmove` without writing the adapter.
58
73
 
59
- Hide the pills when your app controls the view externally:
74
+ The month grid shows up to three chips per day. Clicking a day or its "+N more" fires `ondayclick(date)`; without `ondayclick` it opens that date in a day view (the day view of the type you last used).
60
75
 
61
- ```svelte
62
- <Calendar {adapter} view="day-planner" showModePills={false} />
63
- ```
76
+ ### Keyboard
77
+
78
+ When focus is inside the calendar:
79
+
80
+ - `t` — go to today.
81
+ - `←` / `→` — previous / next period (mirrored in RTL). Ignored while typing in a field or inside a pill group, where arrows move between pills.
82
+ - In `month-grid`: arrow keys move between days, `Enter` / `Space` opens the focused day (`ondayclick`).
64
83
 
65
84
  ### Custom Views
66
85
 
67
- The `views` prop replaces the built-in registry. Each entry maps a view ID to a Svelte component; your component receives `events`, `mode`, `focusDate`, `locale`, and the callbacks, and can read the calendar engines via context:
86
+ The `views` prop replaces the registry. Spread `defaultViews` to add your own view and keep the built-ins:
68
87
 
69
88
  ```svelte
70
89
  <script lang="ts">
71
- import { Calendar, type CalendarView } from '@nomideusz/svelte-calendar';
90
+ import { Calendar, defaultViews, type CalendarView } from '@nomideusz/svelte-calendar';
72
91
  import KanbanDay from './KanbanDay.svelte';
73
92
 
74
93
  const views: CalendarView[] = [
75
- { id: 'day-kanban', label: 'Kanban', mode: 'day', component: KanbanDay },
76
- { id: 'week-kanban', label: 'Kanban', mode: 'week', component: KanbanDay },
94
+ ...defaultViews,
95
+ { id: 'day-kanban', label: 'Kanban', mode: 'day', component: KanbanDay, props: { columns: 3 } },
77
96
  ];
78
97
  </script>
79
98
 
80
99
  <Calendar {adapter} {views} view="day-kanban" />
81
100
  ```
82
101
 
83
- Building blocks for custom views are exported as primitives: `EventBlock` (a positioned event card), `TimeGutter` (hour labels), `DayHeader`, `NowIndicator`, and `EmptySlot` (click-to-create target).
102
+ A `CalendarView` is `{ id, label, mode: 'day' | 'week' | 'month', component, props? }`. `label` names the view-type pill; views sharing a label across modes are what the Day/Week/Month pills switch between.
103
+
104
+ The component receives `CalendarViewProps` (plus your `props`) — declare only the ones it uses:
105
+
106
+ | Prop | Type | Notes |
107
+ |------|------|-------|
108
+ | `events` | `TimelineEvent[]` | Everything loaded for the current range |
109
+ | `style` | `string` | The active `--dt-*` theme string |
110
+ | `height` | `number \| null` | Currently always `null` — size to the container |
111
+ | `mode` | `'day' \| 'week' \| 'month'` | |
112
+ | `mondayStart` | `boolean` | |
113
+ | `locale` | `string \| undefined` | |
114
+ | `focusDate` | `Date` | |
115
+ | `oneventclick` | `(event, anchor?: DOMRect) => void` | Selects the event and calls the host |
116
+ | `oneventcreate` | `((range) => void) \| undefined` | Already validated; `undefined` when `readOnly` or no host handler |
117
+ | `onexternaldrop` | `((info) => void) \| undefined` | |
118
+ | `readOnly` | `boolean` | |
119
+ | `visibleHours` | `[number, number] \| undefined` | |
120
+ | `selectedEventId` | `string \| null` | |
121
+
122
+ ```svelte
123
+ <!-- KanbanDay.svelte -->
124
+ <script lang="ts">
125
+ import { useCalendarContext, type CalendarViewProps } from '@nomideusz/svelte-calendar';
126
+
127
+ let { events, oneventclick, columns = 3 }:
128
+ Pick<CalendarViewProps, 'events' | 'oneventclick'> & { columns?: number } = $props();
129
+
130
+ const ctx = useCalendarContext(); // viewState, drag, labels, config…
131
+ </script>
132
+
133
+ <h3>{ctx.labels.today}</h3>
134
+ {#each events as ev (ev.id)}
135
+ <button onclick={() => oneventclick(ev)}>{ev.title}</button>
136
+ {/each}
137
+ ```
138
+
139
+ `useCalendarContext()` (type `CalendarContext`) gives a view the running Calendar's engines and config: `viewState`, `drag`, `commitDrag`, `labels`, `readOnly`, `snapInterval`, `minColumnWidth`, `blockedSlots`, `disabledDates` / `disabledSet`, `minDuration` / `maxDuration`, `hideDays`, `equalDays`, `showDates`, `compact`, `columns`, `isMobile`, `autoHeight`, `timezone`, `oneventmove`, `oneventhover`, `ondayclick`, `eventSnippet`, `emptySnippet`, `dayHeaderSnippet` and `loadRange` (a view can widen the range the store loads). Call it at component init, inside a `<Calendar>`.
140
+
141
+ The raw view components — `Planner`, `PlannerScroll`, `Agenda`, `Mobile`, `MonthGrid` — are exported so you can register them under your own ids. Building blocks for custom views are exported as primitives: `EventBlock` (an event card), `TimeGutter` (hour labels), `DayHeader`, `NowIndicator`, `EmptySlot` (click-to-create target) and `FloatingPanel` (see [Event details panel](#event-details-panel)).
84
142
 
85
143
  ## Mobile
86
144
 
87
- On narrow screens (`< 768px`), the calendar automatically remaps Planner views to touch-first Mobile views with swipe navigation, a centralized header with Day/Week pills, and a compact layout:
145
+ When the calendar's **container** is narrower than 768px, it swaps to touch-first Mobile views with swipe navigation and a compact header:
88
146
 
89
147
  ```svelte
90
- <!-- Auto-detect (default) — switches at 768px -->
148
+ <!-- Auto-detect (default) — switches at a 768px container width -->
91
149
  <Calendar {adapter} />
92
150
 
93
151
  <!-- Force mobile layout -->
@@ -97,40 +155,106 @@ On narrow screens (`< 768px`), the calendar automatically remaps Planner views t
97
155
  <Calendar {adapter} mobile={false} />
98
156
  ```
99
157
 
100
- Mobile views include:
101
- - **MobileDay** — vertical time grid with hour labels, swipe left/right to change days, all-day event chips at the top, tap-to-create, and stable columns for overlapping events
102
- - **MobileWeek** — vertical day list showing each day's events with relative labels (Today, Tomorrow, etc.) and accessible event buttons inside each row
158
+ - **MobileDay** — vertical time grid with hour labels, swipe left/right to change days, all-day chips at the top, tap-to-create, stable columns for overlapping events.
159
+ - **MobileWeek** — vertical day list with relative labels (Today, Tomorrow, …) and accessible event buttons in each row.
160
+
161
+ In mobile mode every view except `*-agenda` and `*-mobile` is swapped for the `{mode}-mobile` entry of the same mode when one is registered — this includes custom views. Agenda views keep their list layout; `month-grid` has no mobile entry and stays. The mobile header shows only the Day/Week/Month pills.
103
162
 
104
- Agenda views keep their list-based layout on mobile; navigation stays in the calendar header on all screen sizes.
163
+ With `'auto'`, the first render (and the server render) is the desktop layout; the container is measured on mount, so SSR hydrates cleanly.
105
164
 
106
165
  ## Callbacks
107
166
 
108
167
  ```svelte
109
168
  <Calendar
110
169
  {adapter}
111
- oneventclick={(event) => console.log('Clicked', event.title)}
170
+ oneventclick={(event, anchor) => console.log('Clicked', event.title, anchor)}
112
171
  oneventcreate={(range) => console.log('New slot', range.start, range.end)}
113
172
  oneventmove={(event, start, end) => console.log('Moved', event.title, start, end)}
173
+ onexternaldrop={({ start, dataTransfer }) => console.log('Dropped', start, dataTransfer.getData('text/plain'))}
114
174
  onviewchange={(viewId) => console.log('View', viewId)}
175
+ ondatechange={(date) => console.log('Date', date)}
115
176
  ondayclick={(date) => console.log('Day', date)}
116
177
  onerror={(error) => console.error('Calendar', error)}
117
178
  />
118
179
  ```
119
180
 
120
- `onerror` surfaces adapter load failures and rejected drag commits that would
121
- otherwise only reach the console. Clicking an event also selects it — the
122
- active event is highlighted across views.
181
+ | Callback | When it fires |
182
+ |----------|---------------|
183
+ | `oneventclick(event, anchor?)` | An event is clicked. The event is also selected (highlighted across views). `anchor` is the clicked block's viewport rect where the view has one (planner, `week-scroll`, `month-grid`) — for positioning a `FloatingPanel`. |
184
+ | `oneventcreate({ start, end })` | A drag-create or click-to-create passed validation. The calendar does **not** store anything: add the event to your data and hand the Calendar the updated adapter. |
185
+ | `oneventmove(event, start, end)` | A drag or resize committed — **after** the adapter stored it, or when the adapter refused with a read-only error (the host owns persistence then; the block stays where it was dropped). Other adapter failures revert the block and go to `onerror`. (The `week-agenda` columns day-drag is the exception: it only calls `oneventmove`.) |
186
+ | `onexternaldrop({ start, dataTransfer })` | An outside HTML5 drag was dropped on the grid; `start` is the pointer's time, snapped. |
187
+ | `onviewchange(viewId)` | Once on mount, then on every view change. |
188
+ | `ondatechange(date)` | Once on mount, then whenever the focused date changes (navigation, scrolling, a tapped day). |
189
+ | `ondayclick(date)` | A month-grid or agenda day is clicked (an instant, like `ondatechange`). Default: open it in a day view. |
190
+ | `oneventhover(event)` | The pointer enters an event (tooltips, previews). |
191
+ | `onerror(error)` | Adapter load failures and failed drag commits that would otherwise only reach the console. |
192
+
193
+ `oneventcreate`, `oneventmove` and `onexternaldrop` receive real instants, also with [`timezone`](#timezone-support). None of the write callbacks fire when `readOnly` is set.
123
194
 
124
- Set `readOnly` to disable drag, resize, and click-to-create:
195
+ To show a newly created or moved event, update your data and give the Calendar a new adapter — the store reloads on a new adapter identity without blanking the grid:
196
+
197
+ ```svelte
198
+ <script lang="ts">
199
+ import { Calendar, createMemoryAdapter, type TimelineEvent } from '@nomideusz/svelte-calendar';
200
+
201
+ let events = $state<TimelineEvent[]>([]);
202
+ const adapter = $derived(createMemoryAdapter(events));
203
+ </script>
204
+
205
+ <Calendar
206
+ {adapter}
207
+ oneventcreate={({ start, end }) => (events = [...events, { id: crypto.randomUUID(), title: 'New', start, end }])}
208
+ />
209
+ ```
210
+
211
+ ### Read-only
212
+
213
+ Set `readOnly` to disable drag, resize, click-to-create and external drops:
125
214
 
126
215
  ```svelte
127
216
  <Calendar {adapter} readOnly />
128
217
  ```
129
218
 
219
+ To lock single events, set `data.readOnly: true` on them: views refuse to move or resize those events, which can still be clicked. The recurring adapter uses this for its projected occurrences.
220
+
221
+ ### Event details panel
222
+
223
+ `FloatingPanel` is a non-modal panel that opens beside the clicked block's rect (to its right, or left near the viewport edge), can be dragged by its header, and becomes a bottom sheet below a 640px viewport. Escape, its close button or a click outside closes it; the outside click is spent on closing, so it doesn't also open the event under it.
224
+
225
+ ```svelte
226
+ <script lang="ts">
227
+ import { Calendar, FloatingPanel, type TimelineEvent } from '@nomideusz/svelte-calendar';
228
+
229
+ let open = $state<{ ev: TimelineEvent; anchor?: DOMRect } | null>(null);
230
+ </script>
231
+
232
+ <Calendar {adapter} oneventclick={(ev, anchor) => (open = { ev, anchor })} />
233
+
234
+ {#if open}
235
+ <FloatingPanel title={open.ev.title} anchor={open.anchor} onclose={() => (open = null)}>
236
+ <p>{open.ev.location}</p>
237
+ </FloatingPanel>
238
+ {/if}
239
+ ```
240
+
241
+ | Prop | Type | Default | Description |
242
+ |------|------|---------|-------------|
243
+ | `title` | `string` | `''` | Header text and dialog label |
244
+ | `anchor` | `FloatingPanelAnchor` | `{ x: 24, y: 24 }` | `{ x, y, width?, height? }` — a `DOMRect` works as is |
245
+ | `width` | `number` | `440` | Panel width in px |
246
+ | `theme` | `string` | `''` | A `--dt-*` theme string, as the Calendar takes |
247
+ | `closeLabel` | `string` | `'Close'` | Close button label |
248
+ | `closeOnOutside` | `boolean` | `true` | Close on a pointerdown outside |
249
+ | `onclose` | `() => void` | — | Called on Escape, the close button, or an outside click |
250
+ | `children` | `Snippet` | *required* | Panel body |
251
+
252
+ ### More options
253
+
130
254
  Hide nav controls (prev/next/today) and treat all days equally (no past-day dimming):
131
255
 
132
256
  ```svelte
133
- <!-- Yoga studio: fixed weekly schedule, no browsing, all days equal -->
257
+ <!-- Fixed weekly schedule, no browsing, all days equal -->
134
258
  <Calendar
135
259
  {adapter}
136
260
  view="week-agenda"
@@ -144,7 +268,6 @@ Hide nav controls (prev/next/today) and treat all days equally (no past-day dimm
144
268
  Hide weekends for a workweek view:
145
269
 
146
270
  ```svelte
147
- <!-- Office planner: Mon–Fri only -->
148
271
  <Calendar {adapter} view="week-planner" hideDays={[6, 7]} />
149
272
  ```
150
273
 
@@ -162,7 +285,7 @@ Control which date the calendar shows from your app:
162
285
  />
163
286
  ```
164
287
 
165
- Show a rolling 3-day view:
288
+ Show a rolling 3-day view (week views other than 7 days start at the focused date):
166
289
 
167
290
  ```svelte
168
291
  <Calendar {adapter} view="week-planner" days={3} />
@@ -175,8 +298,8 @@ Block lunch hours and enforce 30–120 min events:
175
298
  import type { BlockedSlot } from '@nomideusz/svelte-calendar';
176
299
 
177
300
  const blocked: BlockedSlot[] = [
178
- { start: 12, end: 13, label: 'Lunch' }, // every day 12–1 PM
179
- { day: 6, start: 0, end: 24, label: 'Saturday' }, // block all Saturday
301
+ { start: 12, end: 13, label: 'Lunch' }, // every day 12–1 PM
302
+ { day: 6, start: 0, end: 24, label: 'Saturday' }, // block all Saturday (ISO weekday)
180
303
  ];
181
304
  </script>
182
305
 
@@ -192,7 +315,7 @@ Disable specific dates:
192
315
  />
193
316
  ```
194
317
 
195
- Disabled dates prevent creating or moving events into those days. Existing events remain visible and clickable, which is useful for holidays, fully booked days, or read-only imported schedules.
318
+ Disabled dates refuse creating or moving events into those days. Existing events stay visible and clickable — useful for holidays or fully booked days.
196
319
 
197
320
  Custom day headers and hover previews:
198
321
 
@@ -216,11 +339,12 @@ Replace the built-in navigation or the entire header chrome with your own contro
216
339
  </Calendar>
217
340
 
218
341
  <Calendar {adapter}>
219
- {#snippet header({ dateLabel, mode, switchMode, prev, next, goToday })}
342
+ {#snippet header({ dateLabel, mode, modes, switchMode, prev, next, goToday })}
220
343
  <nav class="my-toolbar">
221
344
  <h2>{dateLabel}</h2>
222
- <button class:active={mode === 'day'} onclick={() => switchMode('day')}>Day</button>
223
- <button class:active={mode === 'week'} onclick={() => switchMode('week')}>Week</button>
345
+ {#each modes as m}
346
+ <button class:active={mode === m} onclick={() => switchMode(m)}>{m}</button>
347
+ {/each}
224
348
  <button onclick={prev}>←</button>
225
349
  <button onclick={goToday}>Today</button>
226
350
  <button onclick={next}>→</button>
@@ -229,7 +353,7 @@ Replace the built-in navigation or the entire header chrome with your own contro
229
353
  </Calendar>
230
354
  ```
231
355
 
232
- Let agenda content determine height instead of forcing a fixed box — useful when embedding inside a scrolling page:
356
+ Let agenda content determine height instead of forcing a fixed box — useful inside a scrolling page:
233
357
 
234
358
  ```svelte
235
359
  <Calendar {adapter} view="week-agenda" height="auto" compact />
@@ -241,8 +365,8 @@ Three built-in presets:
241
365
 
242
366
  | Preset | Description |
243
367
  |--------|-------------|
244
- | `auto` | **Default.** Probes the host page at mount — background, accent color, fonts, light/dark mode — and generates matching `--dt-*` tokens automatically. Reactively watches for host theme changes. |
245
- | `neutral` | Explicit light theme. White bg, blue accent, inherits host fonts. Use when embedding standalone. |
368
+ | `auto` | **Default.** Probes the host page at mount — background, accent color, fonts, light/dark mode — and writes matching `--dt-*` tokens. Re-probes when the host theme changes. |
369
+ | `neutral` | Explicit light theme. White bg, blue accent. Use when embedding standalone. |
246
370
  | `midnight` | Explicit dark theme. Charcoal bg, red accent. |
247
371
 
248
372
  ```svelte
@@ -255,21 +379,23 @@ Three built-in presets:
255
379
  <Calendar {adapter} theme={midnight} /> <!-- explicit dark mode -->
256
380
  ```
257
381
 
382
+ `presets` maps the names to the strings (`presets.neutral`); the type `PresetName` is `'auto' | 'neutral' | 'midnight'`.
383
+
258
384
  ### Smart Auto Theme
259
385
 
260
- The default `auto` preset probes the host page's design and generates a calendar that blends in — no configuration needed. It detects:
386
+ The default `auto` preset probes the page around the calendar and generates a theme that blends in. It detects:
261
387
 
262
- - **Background color** — from CSS variables, inline styles, or computed styles
388
+ - **Background color** — from common CSS variables on `:root` (`--bg`, `--background`, `--color-bg`, `--bs-body-bg`, …), inline styles, or computed styles
263
389
  - **Light/dark mode** — from background luminance
264
- - **Accent/brand color** — from CSS variables (`--accent`, `--primary`, `--bs-primary`, etc.), link colors, or button colors
390
+ - **Accent/brand color** — from common CSS variables (`--accent`, `--primary`, `--brand`, `--bs-primary`, …), link colors, or button colors
265
391
  - **Text color** — validated for contrast against the background
266
- - **Fonts** — inherited via CSS cascade
392
+ - **Fonts** — the host's computed font stack; the mono stack from `--font-mono` and similar variables
267
393
 
268
- It also watches for changes (e.g. dark mode toggle) and updates automatically.
394
+ It watches for changes (a dark-mode toggle on `<html>`/`<body>`, the system color scheme) and updates automatically.
269
395
 
270
396
  The probing engine is exported for standalone use: `probeHostTheme(element, options?)` returns a `--dt-*` CSS string for the page around `element`; `observeHostTheme(element, callback, options?)` re-probes on theme changes and returns a stop function.
271
397
 
272
- Fine-tune auto-detection with the `autoTheme` prop:
398
+ Fine-tune auto-detection with the `autoTheme` prop (`AutoThemeOptions`):
273
399
 
274
400
  ```svelte
275
401
  <!-- Force dark mode even if the page background is light -->
@@ -281,73 +407,63 @@ Fine-tune auto-detection with the `autoTheme` prop:
281
407
  <!-- Override the font stack -->
282
408
  <Calendar {adapter} autoTheme={{ font: '"Poppins", sans-serif' }} />
283
409
 
284
- <!-- Combine overrides -->
285
- <Calendar {adapter} autoTheme={{ mode: 'dark', accent: '#10b981' }} />
286
-
287
- <!-- Disable auto-probing entirely (passive CSS inheritance only) -->
410
+ <!-- Disable probing entirely: inherit --dt-* from ancestors -->
288
411
  <Calendar {adapter} autoTheme={false} />
289
412
  ```
290
413
 
291
- ### Manual CSS Variables
414
+ ### Your own tokens
292
415
 
293
- Override any design token by setting `--dt-*` custom properties on an ancestor:
416
+ The calendar writes its theme inline on its own root element, so it **wins over** `--dt-*` values set on an ancestor or `:root`. With the default `auto` theme it writes a full probed set. To use your own values:
294
417
 
295
- ```html
296
- <!-- Wrap in a div with your overrides -->
297
- <div style="--dt-accent: #e11d48; --dt-bg: #1a1a2e; --dt-text: rgba(255,255,255,0.87);">
298
- <Calendar {adapter} />
299
- </div>
300
- ```
418
+ - **Change a few tokens** — append them to a preset (later declarations win):
301
419
 
302
- Or set them at the page level:
303
-
304
- ```css
305
- :root {
306
- --dt-bg: #fafafa;
307
- --dt-accent: #2563eb;
308
- --dt-text: rgba(0, 0, 0, 0.87);
309
- --dt-border: rgba(0, 0, 0, 0.08);
310
- }
311
- ```
420
+ ```ts
421
+ import { neutral } from '@nomideusz/svelte-calendar';
312
422
 
313
- The auto-probe reads these first — if you set `--dt-bg` or `--accent` on `:root`, the calendar picks them up. Component fallbacks intentionally use system fonts and neutral blue accents so the package stays clean in apps that do not provide custom tokens.
423
+ const custom = `${neutral}; --dt-accent: #e11d48;`;
424
+ ```
314
425
 
315
- ### Extending Presets
426
+ ```svelte
427
+ <Calendar {adapter} theme={custom} />
428
+ ```
316
429
 
317
- Build on a preset by appending overrides:
430
+ - **Write your own theme string** — any `--dt-*` declarations; tokens you leave out are inherited from ancestors or fall back to neutral defaults.
431
+ - **Inherit from the page** — pass `autoTheme={false}` (keeping the default `auto` theme). The calendar then writes no tokens and reads whatever `--dt-*` its ancestors define:
318
432
 
319
- ```ts
320
- import { neutral } from '@nomideusz/svelte-calendar';
321
-
322
- // neutral base + custom accent + rounded feel
323
- const custom = `${neutral}; --dt-accent: #e11d48;`;
324
- ```
433
+ ```svelte
434
+ <div style="--dt-accent: #e11d48; --dt-bg: #1a1a2e; --dt-text: rgba(255,255,255,0.87);">
435
+ <Calendar {adapter} autoTheme={false} />
436
+ </div>
437
+ ```
325
438
 
326
- ```svelte
327
- <Calendar {adapter} theme={custom} />
328
- ```
439
+ Component fallbacks use system fonts and a neutral blue accent, so the package stays clean in apps that set no tokens.
329
440
 
330
441
  <details>
331
442
  <summary>All design tokens</summary>
332
443
 
333
444
  | Token | Purpose |
334
445
  |-------|---------|
335
- | `--dt-stage-bg` | Background behind the calendar (page area) |
336
- | `--dt-bg` | Calendar card background |
337
- | `--dt-surface` | Elevated surface (alternating rows, headers) |
446
+ | `--dt-bg` | Calendar background |
447
+ | `--dt-surface` | Elevated surface (headers, alternating rows, chips) |
338
448
  | `--dt-border` | Default border |
339
- | `--dt-border-day` | Day-column dividers |
449
+ | `--dt-border-day` | Day-column / day-cell dividers |
340
450
  | `--dt-text` | Primary text |
341
451
  | `--dt-text-2` | Secondary text |
342
452
  | `--dt-text-3` | Tertiary text |
343
453
  | `--dt-accent` | Accent color |
344
- | `--dt-accent-dim` | Accent at ~12% opacity |
454
+ | `--dt-accent-dim` | Accent at ~12% opacity (selection, highlights) |
455
+ | `--dt-accent-fg` | Text on the planner's today-number badge (default `#fff`; not set by the presets) |
456
+ | `--dt-btn-text` | Text on accent-filled buttons and badges |
345
457
  | `--dt-glow` | Accent glow / focus ring |
346
458
  | `--dt-today-bg` | Today column highlight |
347
- | `--dt-btn-text` | Button label color |
459
+ | `--dt-weekend-bg` | Weekend cell tint |
460
+ | `--dt-hover` | Hover background |
348
461
  | `--dt-scrollbar` | Scrollbar thumb |
349
462
  | `--dt-success` | Completed indicator |
350
- | `--dt-serif` / `--dt-sans` / `--dt-mono` | Font stacks |
463
+ | `--dt-sans` / `--dt-mono` | Font stacks |
464
+ | `--dt-radius` | `FloatingPanel` corner radius (the calendar's own radius is the `borderRadius` prop) |
465
+ | `--dt-shadow` | `FloatingPanel` shadow |
466
+ | `--dt-stage-bg` | The page background around the calendar. Set by the presets and the auto theme for your page to use; the components don't read it. |
351
467
 
352
468
  </details>
353
469
 
@@ -365,23 +481,23 @@ const custom = `${neutral}; --dt-accent: #e11d48;`;
365
481
  | `subtitle` | `string?` | Secondary text below the title |
366
482
  | `tags` | `string[]?` | Small accent-colored pills |
367
483
  | `allDay` | `boolean?` | Render as an all-day event |
368
- | `location` | `string?` | Room, venue, or address (universal across domains) |
484
+ | `location` | `string?` | Room, venue, or address |
369
485
  | `status` | `EventStatus?` | `'confirmed'` (default), `'cancelled'`, `'tentative'`, `'full'`, `'limited'` |
370
486
  | `externalId` | `string?` | ID from an upstream system (booking platform, CRM, LMS) |
371
- | `resourceId` | `string?` | Resource this event belongs to (room, instructor, court) for multi-resource views |
372
- | `data` | `Record?` | Arbitrary payload for your app |
487
+ | `resourceId` | `string?` | Resource this event belongs to (room, instructor, court) |
488
+ | `data` | `Record<string, unknown>?` | Arbitrary payload for your app. `data.readOnly: true` locks the event against drags. |
373
489
 
374
- Cancelled events render with a strikethrough on the grid but remain visible so the slot isn't confused for free time.
490
+ Cancelled events render with a strikethrough but stay visible, so the slot isn't mistaken for free time.
375
491
 
376
492
  ### Auto-Coloring
377
493
 
378
- Omit `color` and events are auto-assigned a vivid palette color, grouped by `category` (or `title`):
494
+ Omit `color` and the built-in adapters assign a vivid palette color, grouped by `category` (or `title`):
379
495
 
380
496
  ```ts
381
497
  const events = [
382
- { id: '1', title: 'Yoga', category: 'wellness', start: ..., end: ... },
383
- { id: '2', title: 'Pilates', category: 'wellness', start: ..., end: ... }, // same color
384
- { id: '3', title: 'Standup', start: ..., end: ... }, // different color
498
+ { id: '1', title: 'Yoga', category: 'wellness', start, end },
499
+ { id: '2', title: 'Pilates', category: 'wellness', start, end }, // same color
500
+ { id: '3', title: 'Standup', start, end }, // different color
385
501
  ];
386
502
  ```
387
503
 
@@ -390,29 +506,26 @@ Generate a theme-harmonious palette from any accent color:
390
506
  ```ts
391
507
  import { createMemoryAdapter, generatePalette } from '@nomideusz/svelte-calendar';
392
508
 
393
- // Colors that harmonize with your theme's accent
394
- const palette = generatePalette('#e11d48');
509
+ const palette = generatePalette('#e11d48'); // (accent?, count = 15)
395
510
  const adapter = createMemoryAdapter(events, { palette });
396
511
  ```
397
512
 
398
- Related exports: `VIVID_PALETTE` (the default palette) and `extractAccent(themeString)` (pull the accent color out of a `--dt-*` theme string).
513
+ Related exports: `VIVID_PALETTE` (the default palette) and `extractAccent(themeString)` (the accent color of a `--dt-*` theme string).
399
514
 
400
515
  ### Multi-day & All-day
401
516
 
402
- Events spanning multiple days or flagged `allDay: true` render in a dedicated strip above timed events:
517
+ Events spanning multiple days or flagged `allDay: true` render in a strip above timed events:
403
518
 
404
519
  ```ts
405
520
  const events = [
406
521
  { id: '1', title: 'Conference', start: new Date('2026-03-15'), end: new Date('2026-03-18'), allDay: true },
407
- { id: '2', title: 'Sprint', start: new Date('2026-03-15T00:00'), end: new Date('2026-03-17T00:00') },
522
+ { id: '2', title: 'Sprint', start: new Date('2026-03-15T00:00'), end: new Date('2026-03-17T00:00') },
408
523
  ];
409
524
  ```
410
525
 
411
526
  ### Custom Event Rendering
412
527
 
413
- Use the `event` snippet to replace the event *content* in every view (the
414
- interactive shell — click, keyboard, drag, selection — stays intact). The
415
- `empty` snippet replaces the day-agenda empty state:
528
+ The `event` snippet replaces the event *content* in every view (the interactive shell — click, keyboard, drag, selection — stays intact). The `empty` snippet replaces the empty state of the day agenda, the day on mobile, and an empty planner week:
416
529
 
417
530
  ```svelte
418
531
  <Calendar {adapter}>
@@ -422,6 +535,9 @@ interactive shell — click, keyboard, drag, selection — stays intact). The
422
535
  {#if ev.subtitle}<small>{ev.subtitle}</small>{/if}
423
536
  </div>
424
537
  {/snippet}
538
+ {#snippet empty()}
539
+ <p>Nothing booked — drag on the grid to add a class.</p>
540
+ {/snippet}
425
541
  </Calendar>
426
542
  ```
427
543
 
@@ -439,10 +555,11 @@ For fixed repeating events (class timetables, office hours):
439
555
  startDate: '2026-03-01', until: '2026-03-31' },
440
556
  { id: '3', title: 'Review', frequency: 'monthly', dayOfMonth: 15,
441
557
  startTime: '10:00', endTime: '11:00' },
558
+ { id: '4', title: 'Night shift', dayOfWeek: [5, 6], startTime: '22:00', endTime: '02:00' },
442
559
  ]);
443
560
  </script>
444
561
 
445
- <Calendar {adapter} readOnly />
562
+ <Calendar {adapter} />
446
563
  ```
447
564
 
448
565
  <details>
@@ -450,42 +567,52 @@ For fixed repeating events (class timetables, office hours):
450
567
 
451
568
  | Field | Type | Default | Description |
452
569
  |-------|------|---------|-------------|
453
- | `id` | `string` | *required* | Unique identifier |
570
+ | `id` | `string` | *required* | Rule identifier |
454
571
  | `title` | `string` | *required* | Event title |
455
- | `startTime` | `string` | *required* | Start time `"HH:MM"` |
456
- | `endTime` | `string` | *required* | End time `"HH:MM"` |
572
+ | `startTime` | `string` | *required* | Start time `"HH:MM"`, 24-hour |
573
+ | `endTime` | `string` | *required* | End time `"HH:MM"`. Earlier than `startTime` → ends the next day (overnight). |
457
574
  | `frequency` | `'daily' \| 'weekly' \| 'monthly'` | `'weekly'` | Recurrence frequency |
458
- | `interval` | `number` | `1` | Repeat every N periods (e.g. `2` = biweekly) |
575
+ | `interval` | `number` | `1` | Every N periods (`2` + weekly = biweekly). Needs `startDate` when > 1. |
459
576
  | `dayOfWeek` | `number \| number[]` | — | ISO weekday 1=Mon…7=Sun. Required for weekly. |
460
- | `dayOfMonth` | `number` | `1` | Day of month (1–31). For monthly. |
461
- | `startDate` | `string` | — | First occurrence `"YYYY-MM-DD"` |
462
- | `until` | `string` | — | Last occurrence `"YYYY-MM-DD"` |
463
- | `count` | `number` | — | Max occurrences from `startDate` |
464
- | `color` | `string?` | — | Accent color (auto-assigned if omitted) |
577
+ | `dayOfMonth` | `number` | `1` | Day of month (1–31) for monthly; clamped to shorter months. |
578
+ | `startDate` | `string` | — | First possible occurrence `"YYYY-MM-DD"` |
579
+ | `until` | `string` | — | Last possible occurrence `"YYYY-MM-DD"` |
580
+ | `count` | `number` | — | Max occurrences from `startDate` (requires it). With `until`, the stricter wins. |
581
+ | `excludeDates` | `string[]` | — | `"YYYY-MM-DD"` dates this rule skips |
582
+ | `color` | `string` | auto | Accent color |
583
+ | `subtitle` / `tags` / `category` / `location` / `resourceId` | | — | Copied to every occurrence |
584
+ | `data` | `Record<string, unknown>` | — | Copied to every occurrence, plus `recurringId` |
465
585
 
466
586
  </details>
467
587
 
588
+ Options (`RecurringAdapterOptions`): `mondayStart` (default `true`), `palette`, `movable` (default `false`).
589
+
590
+ Occurrences get the id `` `${rule.id}--YYYYMMDD` `` and `data.recurringId`. The adapter projects and stores nothing, so by default every occurrence carries `data.readOnly` and can't be dragged (it can still be clicked). With `movable: true` the views allow the drag and the adapter refuses the write with a read-only error, so `oneventmove` fires and the host stores the move: add the date to the rule's `excludeDates` and keep the moved occurrence with your one-off events (for example the memory side of a [composite adapter](#composite-adapter)).
591
+
468
592
  ## REST Adapter
469
593
 
470
594
  Connect to any REST API:
471
595
 
472
596
  ```ts
473
- import { Calendar, createRestAdapter } from '@nomideusz/svelte-calendar';
597
+ import { createRestAdapter } from '@nomideusz/svelte-calendar';
598
+
599
+ type ApiItem = { id: string; name: string; startAt: string; endAt: string };
474
600
 
475
601
  const adapter = createRestAdapter({
476
602
  baseUrl: 'https://api.example.com/v1',
477
603
  headers: { Authorization: 'Bearer TOKEN' },
478
- // Optional: map your API shape to TimelineEvent[]
479
- mapEvents: (data) => data.items.map(item => ({
480
- id: item.id,
481
- title: item.name,
482
- start: new Date(item.startAt),
483
- end: new Date(item.endAt),
484
- })),
604
+ // Optional: map your API's list shape to TimelineEvent[]
605
+ mapEvents: (data) =>
606
+ (data as { items: ApiItem[] }).items.map((item) => ({
607
+ id: item.id,
608
+ title: item.name,
609
+ start: new Date(item.startAt),
610
+ end: new Date(item.endAt),
611
+ })),
485
612
  });
486
613
  ```
487
614
 
488
- The adapter calls `GET /events?start=...&end=...`, `POST /events`, `PATCH /events/:id`, and `DELETE /events/:id`.
615
+ The adapter calls `GET {baseUrl}/events?start=…&end=…` (ISO strings), `POST /events`, `PATCH /events/:id` and `DELETE /events/:id`, sending JSON with your `headers`. By default a list response must be an array of events and a single response one event; `start`/`end` are parsed into `Date`s. Override with `mapEvents(data)` for lists and `mapEvent(data)` for single events. A non-2xx response or invalid JSON throws.
489
616
 
490
617
  ## JMAP Adapter
491
618
 
@@ -510,11 +637,11 @@ const adapter = createJmapAdapter(client, {
510
637
  });
511
638
  ```
512
639
 
513
- Handles all-day events, ISO-duration ends, and per-calendar colors (pass `calendars` to map them).
640
+ Read-only. Expands recurrences server-side and handles all-day events, ISO-duration ends and per-calendar colors (pass `calendars: [{ id, name, color }]`, or a function returning them).
514
641
 
515
642
  ## Mapped Adapter
516
643
 
517
- Wrap any static array of external records (yoga classes, gym schedules, appointments, timetables) without writing a custom adapter. Supply a declarative field mapping — or a `mapEvent` function for full control — and the adapter handles parsing, color assignment, tag extraction, and status coercion:
644
+ Wrap any static array of external records (class schedules, appointments, timetables) without writing a custom adapter. Supply a declarative field mapping — or a `mapEvent` function for full control — and the adapter handles parsing, colors, tags and status:
518
645
 
519
646
  ```ts
520
647
  import { createMappedAdapter } from '@nomideusz/svelte-calendar';
@@ -526,7 +653,6 @@ const adapter = createMappedAdapter(rawClasses, {
526
653
  end: 'ends_at_iso',
527
654
  subtitle: 'teacher',
528
655
  location: 'room',
529
- color: 'color',
530
656
  externalId: 'reference_id',
531
657
  status: 'is_cancelled', // boolean → 'cancelled' / 'confirmed'
532
658
  tags: ['is_free', 'is_bookable_online'],
@@ -534,9 +660,12 @@ const adapter = createMappedAdapter(rawClasses, {
534
660
  });
535
661
  ```
536
662
 
537
- `start`/`end` accept ISO strings, `Date` objects, or Unix timestamps. When source records split date and time (`date: "2026-03-03"`, `startTime: "07:00"`), use `date` + `startTime` / `endTime` instead.
663
+ - Works without options too: it looks for common keys (`id`, `title`/`name`, `start`/`end`, `starts_at_iso`/`ends_at_iso`, `date` + `start_time`/`end_time`, `room`/`location`, `is_cancelled`). A missing end defaults to one hour after the start.
664
+ - `start`/`end` accept ISO strings, `Date`s or millisecond timestamps. When records split date and time (`date: "2026-03-03"`, `startTime: "07:00"`), map `date` + `startTime` / `endTime`; times may be `7:00`, `07:00` or `07:00:00`.
665
+ - `autoColor` defaults to **`true`**: source colors are ignored and events are colored from `palette` by category/title. Pass `autoColor: false` to keep the colors from your data.
666
+ - `includeData` (default `'*'`) copies unmapped source fields into `event.data`; pass a list of keys to limit it.
538
667
 
539
- For full control, pass a `mapEvent` transform and skip `fields` entirely:
668
+ For full control, pass `mapEvent(raw, index)` and skip `fields`:
540
669
 
541
670
  ```ts
542
671
  const adapter = createMappedAdapter(rawData, {
@@ -551,53 +680,80 @@ const adapter = createMappedAdapter(rawData, {
551
680
  });
552
681
  ```
553
682
 
554
- Mapped adapters are read-only by default. Set `readOnly: false` and supply `onMutate: { onCreate, onUpdate, onDelete }` handlers to enable writes.
683
+ Mapped adapters are read-only by default (writes throw a read-only error, so drags reach `oneventmove`). Set `readOnly: false` to allow writes; supply `onMutate: { onCreate, onUpdate, onDelete }` to persist them (each returns the stored source record, which is mapped again), or omit it to keep changes in memory.
555
684
 
556
685
  ## Composite Adapter
557
686
 
558
- Merge multiple adapters into one — e.g. a recurring weekly schedule combined with one-off events:
687
+ Merge several adapters into one — e.g. a recurring weekly schedule plus one-off events:
559
688
 
560
689
  ```ts
561
- import {
562
- Calendar,
563
- createMemoryAdapter,
564
- createRecurringAdapter,
565
- createCompositeAdapter,
566
- } from '@nomideusz/svelte-calendar';
690
+ import { createMemoryAdapter, createRecurringAdapter, createCompositeAdapter } from '@nomideusz/svelte-calendar';
567
691
 
568
692
  const memory = createMemoryAdapter(oneOffEvents);
569
693
  const recurring = createRecurringAdapter(weeklySchedule);
570
694
 
571
695
  const adapter = createCompositeAdapter([memory, recurring]);
572
- // Reads from both; writes go to memory (the primary — first adapter by default)
573
696
  ```
574
697
 
575
- Change which adapter handles mutations with `primaryIndex`:
698
+ - **Reads** query every child in parallel and merge the results (the first event with a given id wins).
699
+ - **Creates** go to the primary child — the first by default, or `primaryIndex`.
700
+ - **Updates and deletes** go to the child that returned the event. If that child can't write, the composite refuses with a read-only error (so `oneventmove` reaches the host). An id no fetch returned is offered to each writable child in turn.
701
+ - **`onFetchError(error, adapterIndex)`** — with it, a failing child is reported to you and the others still load; without it, one failing child fails the whole load (surfacing through `onerror`).
576
702
 
577
703
  ```ts
578
- const adapter = createCompositeAdapter([recurring, memory], { primaryIndex: 1 });
704
+ const adapter = createCompositeAdapter([recurring, memory], {
705
+ primaryIndex: 1,
706
+ onFetchError: (error, i) => console.warn(`source ${i} failed`, error),
707
+ });
579
708
  ```
580
709
 
581
- Updates and deletes fall through each adapter in order — handy when the recurring adapter generates occurrences that the primary memory adapter doesn't know about.
582
-
583
710
  ## Custom Adapter
584
711
 
585
- Implement the `CalendarAdapter` interface to connect any data source:
712
+ Implement the `CalendarAdapter` interface to connect any data source. Only `fetchEvents` is required:
586
713
 
587
714
  ```ts
588
715
  import type { CalendarAdapter, DateRange, TimelineEvent } from '@nomideusz/svelte-calendar';
589
716
 
590
717
  const adapter: CalendarAdapter = {
718
+ // Required: events overlapping the range
591
719
  fetchEvents: async (range: DateRange) => { /* return TimelineEvent[] */ },
592
- createEvent: async (event) => { /* return created TimelineEvent with id */ },
593
- updateEvent: async (id, patch) => { /* return updated TimelineEvent */ },
720
+ // Optional: the same answer synchronously, when the data is already in memory
721
+ fetchEventsSync: (range) => { /* return TimelineEvent[], or undefined to go async */ },
722
+ // Optional writes — omit them for a read-only source
723
+ createEvent: async (event) => { /* return the created TimelineEvent with its id */ },
724
+ updateEvent: async (id, patch) => { /* return the updated TimelineEvent */ },
594
725
  deleteEvent: async (id) => { /* void */ },
595
726
  };
596
727
  ```
597
728
 
729
+ `WritableCalendarAdapter` is the same interface with all three write methods required.
730
+
731
+ **`fetchEventsSync`** lets the first render — including the server render — hold the events instead of a loading state. The memory, recurring and seeded adapters implement it. To seed an async adapter with events you already loaded on the server, wrap it:
732
+
733
+ ```ts
734
+ import { createRestAdapter, withInitialEvents } from '@nomideusz/svelte-calendar';
735
+
736
+ // data.events: TimelineEvent[] for the range the page opens on
737
+ const adapter = withInitialEvents(createRestAdapter({ baseUrl: '/api' }), data.events);
738
+ ```
739
+
740
+ The first load is answered synchronously from the seed; every later load (navigation, refresh) goes to the wrapped adapter.
741
+
742
+ ### Refusing a write
743
+
744
+ The Calendar tells two refusals apart when `updateEvent` throws:
745
+
746
+ | Throw | Meaning | What happens |
747
+ |-------|---------|--------------|
748
+ | `new CalendarReadOnlyError(message?)` | "I hold this event but can't store the change." | The block stays where it was dropped and `oneventmove` fires — the host stores the move and refreshes. |
749
+ | `new EventNotFoundError(id)` | "Not my event." | A composite asks its next child; a drag ends quietly. |
750
+ | any other error | A real failure | The block reverts and the error goes to `onerror` (or `console.warn`). |
751
+
752
+ An adapter without `updateEvent` counts as read-only. Plain `Error`s whose message contains `read-only` (lower case) or `not found` (any case) are read the same way, so older adapters keep working. `isReadOnlyError(e)` and `isNotFoundError(e)` test both forms.
753
+
598
754
  ## Headless API
599
755
 
600
- For full control over rendering, skip the `<Calendar>` component and drive everything from reactive state. `createCalendar()` returns computed layouts, navigation actions, drag helpers, and raw engines — zero DOM, bring your own UI:
756
+ For full control over rendering, skip `<Calendar>` and drive everything from reactive state. `createCalendar()` returns computed layouts, navigation actions, drag helpers and the raw engines — zero DOM, bring your own UI. Call it during component initialisation.
601
757
 
602
758
  ```svelte
603
759
  <script lang="ts">
@@ -628,11 +784,15 @@ For full control over rendering, skip the `<Calendar>` component and drive every
628
784
  {/each}
629
785
  ```
630
786
 
631
- `cal.days` gives flat `HeadlessDay[]` with events attached; `cal.weeks` groups them into periods. `cal.todayQueue` returns `{ past, current, upcoming }` for today — updates every second via the built-in clock. `cal.hours` yields the visible hour numbers (cropped by `visibleHours`). Drag support is fully wired: `beginDragMove`, `beginDragCreate`, `updateDrag`, `commitDrag`, `cancelDrag`, plus `isDragging` / `dragPayload` / `dragMode` signals. Raw engines (`store`, `viewState`, `selection`, `dragState`, `clock`) are exposed for advanced cases.
787
+ Options (`HeadlessCalendarOptions`) mirror the Calendar props: `adapter`, `view` (default `'week-planner'`), `mondayStart`, `initialDate`, `timezone`, `locale`, `visibleHours`, `snapInterval`, `equalDays`, `hideDays`, `blockedSlots`, `disabledDates`, `days`, `readOnly`, `minDuration`, `maxDuration`, and the callbacks `oneventclick`, `oneventcreate`, `oneventmove`.
788
+
789
+ `cal.days` gives flat `HeadlessDay[]` with events attached; `cal.weeks` groups them into periods (`HeadlessWeek`). `cal.todayQueue` returns `{ past, current, upcoming }` for today (`TodayQueue`), updated every second. `cal.hours` yields the visible hour numbers. Drag is fully wired: `beginDragMove`, `beginDragCreate`, `updateDrag`, `commitDrag` (validates like the Calendar), `cancelDrag`, plus `isDragging` / `dragPayload` / `dragMode`. `headerContext` / `navigationContext` match the Calendar's `header` / `navigation` snippet contexts. Raw engines (`store`, `viewState`, `selection`, `dragState`, `clock`) are exposed for advanced cases.
632
790
 
633
- The engine factories behind them are also exported individually — `createEventStore(adapter)`, `createViewState(options)`, `createSelection()`, `createDragState()`, `createClock()` — if you want to compose your own calendar loop instead of using `createCalendar()`.
791
+ The engine factories are exported individually — `createEventStore(adapter)`, `createViewState(options)`, `createSelection()`, `createDragState()`, `createClock(timezone?)` — if you want to compose your own calendar loop (types: `EventStore`, `ViewState`, `ViewStateOptions`, `CalendarSelection`, `DragState`, `DragMode`, `DragPayload`, `CalendarViewId`, `BuiltInViewId`, `ViewMode`).
634
792
 
635
- For a simpler day-only list view, use `createAgenda()`:
793
+ ### createAgenda
794
+
795
+ A live single-day list: `createAgenda({ adapter, initialDate?, locale?, lookahead?, timezone? })`.
636
796
 
637
797
  ```svelte
638
798
  <script lang="ts">
@@ -652,21 +812,47 @@ For a simpler day-only list view, use `createAgenda()`:
652
812
  {/each}
653
813
  ```
654
814
 
815
+ It returns `dayEvents`, `allDay`, `past`, `current`, `upcoming`, `upcomingSlots` (`TimeSlot[]`: overlapping events grouped as `{ startMs, endMs, events }`), `count`, `eta(ev)`, `progress(ev)`, `fmtTime` / `fmtDuration` / `fmtRange`, and `prev` / `next` / `goToday` / `setDate`.
816
+
817
+ ### createRangeAgenda
818
+
819
+ A window of days with events grouped per day — no clock, drag or selection. Built for read-only schedule surfaces (public timetables, embeds):
820
+
821
+ ```svelte
822
+ <script lang="ts">
823
+ import { createRangeAgenda } from '@nomideusz/svelte-calendar';
824
+
825
+ const agenda = createRangeAgenda({ adapter, days: 7, locale: 'pl-PL', timezone: 'Europe/Warsaw' });
826
+ </script>
827
+
828
+ <button onclick={agenda.prev}>←</button>
829
+ <button onclick={agenda.next}>→</button>
830
+
831
+ {#each agenda.days.filter((d) => d.events.length > 0) as day (day.ms)}
832
+ <h3>{day.date.toLocaleDateString()}</h3>
833
+ {#each day.events as ev (ev.id)}
834
+ <p>{agenda.fmtRange(ev)} {ev.title}</p>
835
+ {/each}
836
+ {/each}
837
+ ```
838
+
839
+ Options (`RangeAgendaOptions`): `adapter` (or a getter), `days` (default 7), `initialDate` (the first day, not aligned to a week start), `locale`, `timezone`. It returns `days` (`RangeAgendaDay[]`: `ms`, `date`, `weekday`, `isToday`, `isPast`, `events`), `range`, `count`, `loading`, `error`, `prev()`, `next()`, `goToday()`, `setDate(date)`, `refresh()`, `fmtTime(date)`, `fmtDuration(ev)`, `fmtRange(ev)`.
840
+
655
841
  ## Localization (i18n)
656
842
 
657
- The `locale` prop controls date/time formatting (BCP 47):
843
+ The `locale` prop controls date/time formatting (BCP 47). Without it, the global default locale is used — `'en-US'` unless you call `setDefaultLocale()`:
658
844
 
659
845
  ```svelte
660
846
  <Calendar {adapter} locale="de-DE" />
661
847
  ```
662
848
 
663
- `setDefaultLocale('de-DE')` sets the locale globally instead of per component (`getDefaultLocale()` reads it back, `is24HourLocale(tag)` tells you how times will format).
849
+ `setDefaultLocale('de-DE')` sets it for every calendar and formatter; `getDefaultLocale()` reads it back; `is24HourLocale(tag?)` tells you how times will format. An RTL locale (`ar-SA`, `he-IL`, …) sets `dir="rtl"` where the browser reports the locale's text direction (`Intl.Locale` text info); pass `dir` to be explicit.
664
850
 
665
851
  Override UI labels per calendar with the `labels` prop — it's reactive (swap it to switch language live) and instance-scoped (two calendars on one page can carry different languages):
666
852
 
667
853
  ```svelte
668
854
  <Calendar {adapter} locale="de-DE" labels={{
669
- today: 'Heute', day: 'Tag', week: 'Woche',
855
+ today: 'Heute', day: 'Tag', week: 'Woche', month: 'Monat',
670
856
  noEvents: 'Keine Termine',
671
857
  nMore: (n) => `+${n} weitere`,
672
858
  }} />
@@ -680,92 +866,115 @@ import { setLabels } from '@nomideusz/svelte-calendar';
680
866
  setLabels({ today: 'Heute', day: 'Tag', week: 'Woche' });
681
867
  ```
682
868
 
683
- The `labels` prop merges over the global set. Call `resetLabels()` to restore English defaults (`defaultLabels` exports them; `getLabels()` reads the active set). Note: global `setLabels()` calls after mount don't re-render already-mounted calendars — use the `labels` prop for dynamic language switching. Standalone primitives (`DayHeader`, `EventBlock`, …) always read the global set.
869
+ Both take a `Partial<CalendarLabels>` — that is the supported input: pass the keys you translate, and keys added in later minor releases fall back to English. The `labels` prop merges over the global set. `resetLabels()` restores English (`defaultLabels` exports it; `getLabels()` reads the active set). Global `setLabels()` calls after mount don't re-render mounted calendars — use the `labels` prop to switch language live. Standalone primitives (`DayHeader`, `EventBlock`, …) and the format helpers read the global set.
684
870
 
685
871
  <details>
686
872
  <summary>All label keys</summary>
687
873
 
688
- | Key | Default | Description |
689
- |-----|---------|-------------|
690
- | `today` | `'Today'` | Relative day label / nav button |
691
- | `yesterday` | `'Yesterday'` | Relative day label |
692
- | `tomorrow` | `'Tomorrow'` | Relative day label |
693
- | `day` | `'Day'` | Mode pill |
694
- | `week` | `'Week'` | Mode pill |
695
- | `planner` | `'Planner'` | View label |
696
- | `agenda` | `'Agenda'` | View label |
697
- | `now` | `'now'` | Live indicator badge |
698
- | `free` | `'free'` | Empty slot hint |
699
- | `allDay` | `'All day'` | All-day event label |
700
- | `done` | `'Done'` | Past section header |
701
- | `upNext` | `'Up next'` | Upcoming section header |
702
- | `until` | `'until'` | Time-until prefix |
703
- | `noEvents` | `'No events'` | Empty day |
704
- | `nothingScheduled` | `'Nothing scheduled'` | Empty state |
705
- | `allDoneForToday` | `'All done for today'` | Completed state |
706
- | `goToToday` | `'Go to today'` | Nav button aria |
707
- | `previousDay` / `nextDay` | `'Previous day'` / `'Next day'` | Nav aria |
708
- | `previousWeek` / `nextWeek` | `'Previous week'` / `'Next week'` | Nav aria |
709
- | `calendar` | `'Calendar'` | Root region aria |
710
- | `nMore(n)` | `` `+${n} more` `` | Overflow count |
711
- | `nEvents(n)` | `` `${n} event(s)` `` | Event count aria |
712
- | `nCompleted(n)` | `` `${n} completed` `` | Completed count |
713
- | `dayNOfTotal(i, t)` | `` `day ${i} of ${t}` `` | Multi-day segment |
714
- | `percentComplete(p)` | `` `${p}% complete` `` | Progress aria |
874
+ | Key | Default |
875
+ |-----|---------|
876
+ | `today` / `yesterday` / `tomorrow` | `'Today'` / `'Yesterday'` / `'Tomorrow'` |
877
+ | `day` / `week` / `month` | `'Day'` / `'Week'` / `'Month'` (mode pills) |
878
+ | `planner` / `agenda` / `scroll` | `'Planner'` / `'Agenda'` / `'Scroll'` (view-type pills) |
879
+ | `now` / `free` / `allDay` | `'now'` / `'free'` / `'All day'` |
880
+ | `done` / `upNext` / `until` | `'Done'` / `'Up next'` / `'until'` |
881
+ | `noEvents` | `'No events'` |
882
+ | `nothingScheduled` / `nothingScheduledYet` / `nothingWasScheduled` | `'Nothing scheduled'` / `'Nothing scheduled yet'` / `'Nothing was scheduled'` |
883
+ | `allDoneForToday` | `'All done for today'` |
884
+ | `goToToday` | `'Go to today'` |
885
+ | `previousDay` / `nextDay` | `'Previous day'` / `'Next day'` |
886
+ | `previousWeek` / `nextWeek` | `'Previous week'` / `'Next week'` |
887
+ | `previousMonth` / `nextMonth` | `'Previous month'` / `'Next month'` |
888
+ | `calendar` / `viewMode` | `'Calendar'` / `'View mode'` (aria) |
889
+ | `cancelled` / `tentative` / `full` / `limited` | `'cancelled'` / `'tentative'` / `'full'` / `'limited'` (event status) |
890
+ | `unavailable` | `'Unavailable'` (a blocked slot without its own label) |
891
+ | `noViews` | `'No views registered.'` |
892
+ | `dayNavigation` / `weekNavigation` | `'Day navigation'` / `'Week navigation'` (aria) |
893
+ | `dayPlanner` / `scrollableDayPlanner` | `'Day planner'` / `'Scrollable day planner'` (aria) |
894
+ | `todaysLineup` / `weekAhead` / `multiWeekGrid` | `"Today's lineup"` / `'Week ahead'` / `'Multi-week calendar grid'` (aria) |
895
+ | `currentTime` / `createEvent` | `'Current time'` / `'Create event'` (aria) |
896
+ | `happeningNow` / `past` / `completed` / `inProgress` | `'happening now'` / `'past'` / `'completed'` / `'in progress'` |
897
+ | `showLess` | `'Show less'` |
898
+ | `nMore(n)` | `+3 more` |
899
+ | `nEvents(n)` | `1 event`, `5 events` |
900
+ | `nCompleted(n)` | `3 completed` |
901
+ | `dayNOfTotal(current, total)` | `day 2 of 4` |
902
+ | `percentComplete(pct)` | `75% complete` |
903
+ | `inMinutes(mins)` / `inHours(hours, mins)` / `inDays(days)` | `in 45m` / `in 2h 15m` or `in 2h` / `in 3d` |
715
904
 
716
905
  </details>
717
906
 
718
907
  ## Timezone Support
719
908
 
720
- Render the whole calendar in any IANA timezone — events, the now-indicator
721
- and day boundaries all shift; ranges passed to `oneventcreate`/`oneventmove`
722
- convert back to real instants:
909
+ Render the whole calendar in any IANA timezone — events, the now-indicator and day boundaries all shift:
723
910
 
724
911
  ```svelte
725
912
  <Calendar {adapter} timezone="Europe/Warsaw" />
726
913
  ```
727
914
 
728
- Under the hood the adapter is wrapped with `wrapAdapterWithTimezone` (also
729
- exported) so views do plain local-time math on a zoned wall-clock plane.
730
- Known limit shared by every wall-clock calendar: the repeated hour of a DST
731
- fall-back is ambiguous, writes inside it resolve to one of the two instants.
915
+ - `initialDate` and `currentDate` are instants, read in that zone.
916
+ - `oneventcreate`, `oneventmove` and `onexternaldrop` receive real instants.
917
+ - `ondatechange` and `ondayclick` receive instants too — the start of that day in the zone — so feeding one back is stable: `currentDate={date} ondatechange={(d) => (date = d)}`. To read the date, format it in the zone:
918
+
919
+ ```ts
920
+ import { formatInTimeZone } from '@nomideusz/svelte-calendar';
921
+ formatInTimeZone(d, 'Europe/Warsaw', { year: 'numeric', month: '2-digit', day: '2-digit' }, 'en-CA'); // "2026-10-24"
922
+ ```
923
+ - Events handed to `oneventclick` / `oneventmove` / snippets are shifted the same way: their local fields show the zone's wall-clock time.
924
+ - `timezone` is read when the Calendar mounts. To change zones at runtime, remount it:
925
+
926
+ ```svelte
927
+ {#key timezone}
928
+ <Calendar {adapter} {timezone} />
929
+ {/key}
930
+ ```
732
931
 
733
- Convert events manually with the built-in helpers:
932
+ Under the hood the adapter is wrapped with `wrapAdapterWithTimezone(adapter, zone)` (also exported), so views do plain local-time math on a zoned wall-clock plane. Known limit shared by every wall-clock calendar: the repeated hour of a DST fall-back is ambiguous, and writes inside it resolve to one of the two instants.
933
+
934
+ The headless helpers take the same option: `createCalendar({ timezone })`, `createAgenda({ timezone })`, `createRangeAgenda({ timezone })`, plus `createClock(timezone)` and `createViewState({ timezone })`.
935
+
936
+ Convert dates manually with the built-in helpers:
734
937
 
735
938
  ```ts
736
- import { toZonedTime, fromZonedTime, nowInZone } from '@nomideusz/svelte-calendar';
939
+ import { toZonedTime, fromZonedTime, nowInZone, formatInTimeZone } from '@nomideusz/svelte-calendar';
737
940
 
738
- // Display a UTC date in a specific timezone
941
+ // A Date whose local fields show the instant's wall-clock time in New York
739
942
  const local = toZonedTime(utcDate, 'America/New_York');
740
943
 
741
- // Convert back to UTC before saving
742
- const utc = fromZonedTime(localDate, 'America/New_York');
944
+ // Back to the real instant before saving
945
+ const utc = fromZonedTime(local, 'America/New_York');
743
946
 
744
- // Current time in a timezone
947
+ // Current time in a timezone (wall-clock Date)
745
948
  const now = nowInZone('Asia/Tokyo');
746
949
 
747
- // Format a date directly in a timezone
748
- formatInTimeZone(date, 'America/New_York', { hour: '2-digit', minute: '2-digit' });
950
+ // Format an instant directly in a timezone: (date, zone, Intl options?, locale?)
951
+ formatInTimeZone(utcDate, 'America/New_York', { hour: '2-digit', minute: '2-digit' });
749
952
  ```
750
953
 
751
954
  ## Utilities
752
955
 
753
- Small helpers used by the built-in views, exported for custom rendering:
956
+ Small helpers used by the built-in views, exported for custom rendering. Formatters take an optional `locale` (default: the global default locale); day helpers work on millisecond timestamps.
754
957
 
755
958
  | Export | Purpose |
756
959
  |--------|---------|
757
- | `fmtTime(date, locale?)` / `fmtH(hour, locale?)` | Locale-aware time / hour labels |
758
- | `fmtDuration(ms)` | `"1h 30m"`-style durations |
759
- | `fmtDay` / `fmtWeekRange` / `dateShort` / `dateWithWeekday` | Date labels |
760
- | `weekdayShort` / `weekdayLong` / `monthShort` / `monthLong` | Name parts from a day timestamp |
761
- | `startOfWeek(ms, mondayStart)` | Start-of-week timestamp |
960
+ | `fmtTime(date, locale?)` / `fmtH(hour, locale?)` | Compact time / hour labels: `9:00a` / `9a` in 12-hour locales, `9:00` / `9` in 24-hour ones |
961
+ | `fmtDuration(start, end)` | `"45m"`, `"1h"`, `"1h 30m"` |
962
+ | `fmtDay(ms, todayMs, opts?, locale?)` | `"Today · Feb 21"`, `"Mon, Feb 17"`; `{ short: true }` for the short form |
963
+ | `fmtWeekRange(startMs, locale?, endMs?)` | A date range in the locale's own order: `"Sep 21 – 27, 2026"`, `"21–27 wrz 2026"`. `endMs` is the last day shown (default: six days on). |
964
+ | `dateShort` / `dateWithWeekday` | `"Feb 21"` / `"Mon, Feb 17"` |
965
+ | `weekdayShort` / `weekdayLong` / `monthShort` / `monthLong` | Name parts of a timestamp |
966
+ | `sod(ms)` | Start of that day (local midnight) |
967
+ | `startOfWeek(ms, mondayStart = true)` | Start-of-week timestamp |
968
+ | `addDaysMs(ms, n)` / `diffDays(a, b)` | Add / count **calendar** days — DST-safe (a DST day is 23 or 25 hours, so don't add `n * 86_400_000`) |
762
969
  | `isAllDay(ev)` / `isMultiDay(ev)` | Event classification |
763
- | `segmentForDay(ev, dayMs)` | The slice of a multi-day event that falls on one day |
764
- | `createClock()` | Reactive clock (`tick`, `today`) driving now-lines and relative labels |
765
- | `typeset(text)` / `breakLines(text, font, width)` | Knuth-Plass paragraph breaking over the text's own spaces and soft hyphens, measured with pretext. `<p {@attach typeset(text)}>` sets justified block-span lines, re-done on resize; if the browser disagrees with the measure the plain text goes back. Progressive — SSR text stays. |
766
- | `fitLabel([long, short])` / `pickFit` / `fits` / `textHeight` / `textWidth` | Text fitting via [pretext](https://github.com/chenglou/pretext) — measure before render, no layout thrash. `<span class="eb-title" {@attach fitLabel([ev.title, ev.short])}>` keeps the longest label that fits. Browser-only. |
767
- | `createRecurringAdapter(schedule, { movable })` | Projects a repeating schedule. Occurrences are read-only unless `movable` is set — the adapter stores nothing, so a host that opts in must handle `oneventmove` itself: add the date to the rule's `excludeDates` and keep the moved occurrence wherever its one-off events live. |
768
- | `fitParts(parts, budget)` | Which parts of a dense chip fit, along either axis. Each part is `{ key, text, font }` to measure or `{ key, size }` to state its cost, plus `priority` (`0` never drops, higher goes first) and `extra` for a gap or icon. Anchors always show — they are what earns the ellipsis — and the first part that does not fit ends it, so a chip never keeps a later detail after dropping an earlier one. A budget of `0` (server-rendered, not yet measured) leaves the anchors. |
970
+ | `segmentForDay(ev, dayMs)` | The slice of a multi-day event on one day (`DaySegment`), or `null` |
971
+ | `createClock(timezone?)` | Reactive clock: `tick` (ms, every second), `today`, `hm`, `s`, `fractionalHour`, `destroy()` |
972
+ | `textWidth(text, font)` / `textHeight(text, font, width, lineHeight)` / `lineCount(text, font, width, options?)` / `fits(text, font, width, lines = 1)` | Text measurement via [pretext](https://github.com/chenglou/pretext) — before render, no layout thrash. `options` is `TextLayoutOptions` (`whiteSpace`, `wordBreak`, `letterSpacing`). Browser-only. |
973
+ | `pickFit(candidates, font, width, lines = 1)` | The longest candidate that fits |
974
+ | `fitLabel(candidates, lines = 1)` | Attachment: `<span {@attach fitLabel([ev.title, ev.short])}>` keeps the longest label that fits its box, re-fitting on resize |
975
+ | `fontOf(el)` | An element's computed font as a canvas font string |
976
+ | `fitParts(parts, budget, measure?)` | Which parts of a dense chip fit, along either axis → `Record<key, boolean>`. Each `ChipPart` is `{ key, text, font }` to measure or `{ key, size }` to state its cost, plus `priority` (`0` is an anchor and never drops; higher numbers drop first) and `extra` (a gap or icon). The first optional part that doesn't fit ends the list, so a chip never keeps a later detail after dropping an earlier one. A budget of `0` (server-rendered, not yet measured) leaves only the anchors. |
977
+ | `typeset(text)` / `breakLines(text, font, width)` | Knuth-Plass paragraph breaking over the text's own spaces and soft hyphens. `<p {@attach typeset(text)}>` sets justified lines, re-done on resize; if the browser disagrees with the measure, the plain text goes back. Progressive — SSR text stays. |
769
978
 
770
979
  ## Embeddable Widget
771
980
 
@@ -780,81 +989,107 @@ Drop into any HTML page — no build tools needed. Registers a `<day-calendar>`
780
989
  view="week-planner"
781
990
  height="600"
782
991
  locale="en-US"
783
- mondaystart="true"
784
992
  ></day-calendar>
785
993
  ```
786
994
 
787
- The widget renders inside shadow DOM: host-page CSS (resets, theme stylesheets) cannot affect the calendar and calendar styles never leak out, while the `auto` theme still probes the host page's colors and fonts across the shadow boundary.
788
-
789
- | Attribute | Description |
790
- |-----------|-------------|
791
- | `api` | REST endpoint — fetched as `GET {api}?start=...&end=...` |
792
- | `events` | Inline JSON array of events (alternative to `api`) |
793
- | `view` | Initial view ID (`week-planner`, `day-agenda`, …) |
794
- | `theme` | `auto`, `neutral`, or `midnight` |
795
- | `height` | Calendar height in px |
796
- | `locale` | BCP 47 locale tag |
797
- | `dir` | `ltr` or `rtl` |
798
- | `mondaystart` | `"true"` / `"false"` — week-start day |
799
- | `headers` | JSON string of extra headers to send with `api` requests |
995
+ In a bundler, `import '@nomideusz/svelte-calendar/widget'` registers the same element.
996
+
997
+ The widget renders inside shadow DOM: host-page CSS cannot affect the calendar and calendar styles never leak out, while the `auto` theme still probes the host page's colors and fonts across the shadow boundary.
998
+
999
+ | Attribute | Default | Description |
1000
+ |-----------|---------|-------------|
1001
+ | `api` | — | Events endpoint, fetched as `GET {api}?start=…&end=…` (ISO instants; appended with `&` if `api` already has a query). It must answer JSON: an array of events, or `{ "events": [...] }`. |
1002
+ | `events` | — | Inline JSON array of events, used when there is no `api` |
1003
+ | `headers` | — | JSON object of extra request headers for `api`. Without it the GET is a simple CORS request (no preflight). |
1004
+ | `readonly` | read-only | The widget is read-only unless `readonly="false"`. There is no write endpoint: with `api`, drags are only visual; with `events`, they are kept in memory. |
1005
+ | `view` | `week-planner` | Initial view ID (`week-planner`, `day-agenda`, `month-grid`, …) |
1006
+ | `theme` | `auto` | `auto`, `neutral` or `midnight` (unknown names fall back to `neutral`) |
1007
+ | `height` | `600` | Height in px (`"600"` or `"600px"`), or `auto` |
1008
+ | `locale` | `en-US` | BCP 47 locale tag |
1009
+ | `dir` | from `locale` | `ltr`, `rtl` or `auto` |
1010
+ | `mondaystart` | `true` | `"false"` starts weeks on Sunday |
1011
+ | `pills` | `true` | `"false"` hides the view pills |
1012
+ | `nav` | `true` | `"false"` hides prev/next/today |
1013
+ | `mobile` | `auto` | `auto`, `true` or `false` |
1014
+ | `days` | `7` | Days in week views, `1`–`7` |
1015
+ | `compact` | `false` | `"true"` for compact agenda rows |
1016
+ | `timezone` | viewer's zone | IANA zone, e.g. `Europe/Warsaw` (read when the element mounts) |
1017
+
1018
+ Each event in `api` responses and `events` needs `start` and `end` as date strings (ISO 8601) and may carry `id`, `title`, `color`, `allDay`, `subtitle` and `location`; other fields are ignored, as are events with an invalid or empty range. Changing an attribute updates the calendar in place.
800
1019
 
801
1020
  ## All Props
802
1021
 
803
1022
  <details>
804
- <summary>Full Calendar props reference</summary>
1023
+ <summary>Full Calendar props reference (<code>CalendarProps</code>)</summary>
805
1024
 
806
1025
  | Prop | Type | Default | Description |
807
1026
  |------|------|---------|-------------|
808
- | `adapter` | `CalendarAdapter` | *required* | Data layer (memory, recurring, mapped, composite, REST, or custom) |
809
- | `views` | `CalendarView[]` | 6 built-in | Registered view components (4 desktop + 2 mobile variants) |
810
- | `view` | `string` | first view | Active view ID |
811
- | `theme` | `string` | `auto` | CSS theme string (`--dt-*` custom properties). `auto` probes the host page and generates matching tokens. |
812
- | `autoTheme` | `AutoThemeOptions \| false` | `{}` | Fine-tune auto-detection: `{ mode, accent, font }`. Set `false` to disable probing. |
813
- | `mobile` | `'auto' \| boolean` | `'auto'` | Mobile mode. `'auto'` detects via container width (<768px). Remaps Planner→Mobile views. |
814
- | `height` | `number \| 'auto'` | `600` | Height in pixels, or `'auto'` to let content grow naturally (ideal for Agenda views) |
815
- | `borderRadius` | `number` | `12` | Border radius in pixels. Set to `0` for no rounding. |
816
- | `locale` | `string` | `'en-US'` | BCP 47 locale tag |
817
- | `dir` | `'ltr' \| 'rtl' \| 'auto'` | — | Text direction |
818
- | `mondayStart` | `boolean` | `true` | Start week on Monday |
819
- | `readOnly` | `boolean` | `false` | Disable drag, resize, and click-to-create |
820
- | `visibleHours` | `[number, number]` | — | Crop grid to `[startHour, endHour)` |
821
- | `initialDate` | `Date` | today | Date to focus on at mount |
1027
+ | `adapter` | `CalendarAdapter` | *required* | Data source (memory, recurring, mapped, composite, REST, JMAP, or custom) |
1028
+ | `views` | `readonly CalendarView[]` | `defaultViews` | View registry (the 8 built-ins). Spread `defaultViews` to add your own. |
1029
+ | `view` | `CalendarViewId` | first registered (`'day-planner'`) | Active view ID. Reactive. |
1030
+ | `theme` | `string` | `auto` | CSS theme string of `--dt-*` properties. `auto` probes the host page. |
1031
+ | `autoTheme` | `AutoThemeOptions \| false` | `undefined` (probe) | Fine-tune auto-detection: `{ mode, accent, font }`. `false` disables probing. |
1032
+ | `mobile` | `'auto' \| boolean` | `'auto'` | `'auto'` switches to mobile views below a 768px container width |
1033
+ | `height` | `number \| 'auto'` | `600` | Height in px, or `'auto'` to grow with content (ideal for Agenda views) |
1034
+ | `borderRadius` | `number` | `12` | Border radius in px. `0` for none. |
1035
+ | `locale` | `string` | global default (`'en-US'`) | BCP 47 locale tag; see `setDefaultLocale` |
1036
+ | `labels` | `Partial<CalendarLabels>` | — | Per-instance UI labels, merged over the global set. Reactive. |
1037
+ | `dir` | `'ltr' \| 'rtl' \| 'auto'` | from `locale` | Text direction |
1038
+ | `mondayStart` | `boolean` | `true` | Start weeks on Monday |
1039
+ | `readOnly` | `boolean` | `false` | Disable drag, resize, click-to-create and external drops |
1040
+ | `visibleHours` | `[number, number]` | — | Crop the grid to `[startHour, endHour)` |
1041
+ | `initialDate` | `Date` | today | Date to focus at mount (an instant; read in `timezone`) |
1042
+ | `currentDate` | `Date` | — | Controlled focus date (an instant; read in `timezone`) |
1043
+ | `timezone` | `string` | viewer's zone | IANA zone to render in. Read at mount. |
822
1044
  | `snapInterval` | `number` | `15` | Drag snap in minutes |
823
- | `showModePills` | `boolean` | `true` | Show the Day/Week mode pills |
824
- | `showNavigation` | `boolean` | `true` | Show prev/next/today navigation |
1045
+ | `minColumnWidth` | `number` | `110` | Minimum day-column width (px) in planner views; below the total the grid scrolls horizontally |
1046
+ | `days` | `number` | `7` | Days shown in week mode (e.g. `3` for a rolling 3-day view) |
1047
+ | `hideDays` | `number[]` | — | ISO weekdays to hide (1=Mon … 7=Sun), e.g. `[6, 7]` |
1048
+ | `showModePills` | `boolean` | `true` | Show the Day/Week/Month pills and the view-type pills |
1049
+ | `showNavigation` | `boolean` | `true` | Show prev/next/today |
1050
+ | `showDates` | `boolean` | `true` | Show date numbers and the date label. `false` = day names only (templates, recurring schedules) |
825
1051
  | `equalDays` | `boolean` | `false` | Treat all days equally (no past-day dimming/collapsing) |
826
- | `showDates` | `boolean` | `true` | Show date numbers in headers. `false` = day names only (Mon, Tue, …) |
827
- | `hideDays` | `number[]` | — | ISO weekdays to hide (1=Mon … 7=Sun). E.g. `[6, 7]` hides weekends |
828
- | `currentDate` | `Date` | — | Controlled focus date (drives which date the calendar shows) |
829
- | `days` | `number` | `7` | Number of days shown in week views (e.g. `3` for a rolling 3-day view) |
830
- | `blockedSlots` | `BlockedSlot[]` | — | Time ranges that cannot be booked (hatched overlay in planner views) |
831
- | `disabledDates` | `Date[]` | — | Dates that reject creation/moves while keeping existing events visible and clickable |
832
- | `minDuration` | `number` | — | Minimum event duration in minutes (enforced on create & resize) |
833
- | `maxDuration` | `number` | — | Maximum event duration in minutes (enforced on create & resize) |
834
- | `compact` | `boolean` | `false` | Minimal text-row rendering in Agenda views (dot + time + title) |
835
- | `columns` | `boolean` | `false` | Timetable layout: week-agenda days as side-by-side columns on desktop (mobile keeps the stacked list). Pairs well with `equalDays`; overrides `compact` while active |
836
- | `dayHeader` | `Snippet<[{ date, isToday, dayName }]>` | — | Custom day header snippet for planner/agenda views |
837
- | `header` | `Snippet<[HeaderContext]>` | — | Replace entire header chrome (date label + mode pills + nav) |
838
- | `navigation` | `Snippet<[NavigationContext]>` | — | Replace just the prev/next/today controls |
839
- | `oneventclick` | `(event) => void` | — | Event clicked |
840
- | `oneventcreate` | `(range) => void` | — | New time range selected |
841
- | `oneventmove` | `(event, start, end) => void` | — | Event dragged to new time |
842
- | `onviewchange` | `(viewId) => void` | — | Active view changed |
843
- | `oneventhover` | `(event) => void` | — | Pointer enters an event (for tooltips, previews) |
844
- | `ondatechange` | `(date) => void` | — | Focused date changed (navigation, scroll, etc.) |
845
- | `event` | `Snippet<[TimelineEvent]>` | — | Custom event rendering |
846
- | `empty` | `Snippet` | — | Empty state content |
1052
+ | `blockedSlots` | `BlockedSlot[]` | — | `{ day?, start, end, label? }` hour ranges that can't be booked (hatched in planner views) |
1053
+ | `disabledDates` | `Date[]` | — | Dates that refuse creates/moves; existing events stay visible and clickable |
1054
+ | `minDuration` | `number` | — | Minimum duration in minutes; clamps creates and resizes |
1055
+ | `maxDuration` | `number` | — | Maximum duration in minutes; clamps creates and resizes |
1056
+ | `compact` | `boolean` | `false` | Minimal text rows in Agenda views (dot + time + title) |
1057
+ | `columns` | `boolean` | `false` | Timetable layout: `week-agenda` days side by side on desktop. Pairs with `equalDays`; overrides `compact` |
1058
+ | `event` | `Snippet<[TimelineEvent]>` | — | Custom event content |
1059
+ | `empty` | `Snippet` | — | Empty state for the day agenda, mobile day and an empty planner week |
1060
+ | `dayHeader` | `Snippet<[{ date, isToday, dayName }]>` | — | Custom day header |
1061
+ | `header` | `Snippet<[HeaderContext]>` | — | Replace the whole header (`dateLabel`, `mode`, `modes`, `switchMode`, `prev`, `next`, `goToday`, `isViewOnToday`, `focusDate`) |
1062
+ | `navigation` | `Snippet<[NavigationContext]>` | — | Replace prev/next/today (`prev`, `next`, `goToday`, `isViewOnToday`, `focusDate`, `mode`) |
1063
+ | `oneventclick` | `(event, anchor?: DOMRect) => void` | — | Event clicked (and selected) |
1064
+ | `oneventcreate` | `(range: { start, end }) => void` | — | A validated new range (real instants) |
1065
+ | `oneventmove` | `(event, start, end) => void` | — | Drag/resize stored, or refused read-only (real instants) |
1066
+ | `onexternaldrop` | `(info: { start, dataTransfer }) => void` | — | Outside HTML5 drag dropped on the grid |
1067
+ | `onviewchange` | `(viewId) => void` | — | Once on mount, then on every view change |
1068
+ | `ondatechange` | `(date) => void` | — | Once on mount, then on every focus-date change |
1069
+ | `ondayclick` | `(date) => void` | open a day view | A month-grid day clicked |
1070
+ | `oneventhover` | `(event) => void` | — | Pointer enters an event |
1071
+ | `onerror` | `(error: Error) => void` | — | Load and commit failures |
847
1072
 
848
1073
  </details>
849
1074
 
1075
+ ## Versioning
1076
+
1077
+ The package follows [semver](https://semver.org/) from 1.0:
1078
+
1079
+ - The public API is exactly what the `.` and `./widget` entry points export. Deep imports into `dist/` are not supported.
1080
+ - `TimelineEvent`, `CalendarAdapter` and `DateRange` are stable shapes: fields may be added in minor releases, never renamed, removed or given new meaning outside a major.
1081
+ - The `--dt-*` theme tokens are part of the contract, as are the `<day-calendar>` attributes.
1082
+ - `CalendarLabels` gains keys in minor releases — pass a `Partial` (see [Localization](#localization-i18n)).
1083
+
850
1084
  ## Development
851
1085
 
852
1086
  ```bash
853
1087
  pnpm install
854
- pnpm dev # SvelteKit dev server
855
- pnpm check # Type check
856
- pnpm run package # Build library
857
- pnpm run build:widget # Build widget.js
1088
+ pnpm dev # demo site
1089
+ pnpm check # svelte-check
1090
+ pnpm test # vitest
1091
+ pnpm run package # build the library into dist/
1092
+ pnpm run build:widget # build widget/widget.js
858
1093
  ```
859
1094
 
860
1095
  ## License