@nomideusz/svelte-calendar 0.21.0 → 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 -269
  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 +37 -19
  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 +12 -12
  24. package/dist/core/time.d.ts +9 -0
  25. package/dist/core/time.js +8 -0
  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 +93 -56
  29. package/dist/engine/view-state.svelte.d.ts +5 -1
  30. package/dist/engine/view-state.svelte.js +5 -3
  31. package/dist/headless/create-agenda.svelte.d.ts +8 -1
  32. package/dist/headless/create-agenda.svelte.js +32 -14
  33. package/dist/headless/create-calendar.svelte.js +53 -17
  34. package/dist/headless/create-range-agenda.svelte.d.ts +7 -0
  35. package/dist/headless/create-range-agenda.svelte.js +33 -12
  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 +41 -33
  50. package/dist/views/agenda/AgendaWeek.svelte +71 -34
  51. package/dist/views/index.d.ts +2 -0
  52. package/dist/views/index.js +2 -0
  53. package/dist/views/mobile/MobileDay.svelte +98 -41
  54. package/dist/views/mobile/MobileWeek.svelte +29 -13
  55. package/dist/views/mobile/swipe.js +6 -1
  56. package/dist/views/month/MonthGrid.svelte +29 -17
  57. package/dist/views/month/MonthGrid.svelte.d.ts +1 -1
  58. package/dist/views/planner/PlannerScroll.svelte +183 -51
  59. package/dist/views/planner/PlannerWeek.svelte +148 -48
  60. package/dist/views/shared/context.svelte.d.ts +1 -1
  61. package/package.json +24 -9
  62. package/widget/widget.js +7983 -7688
  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,67 +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, Hey-Calendar style — each class at the height of its
38
- start time, month names down the side; drag to move, drop onto a day,
39
- scrolls under a drag),
40
- **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. |
41
52
 
42
53
  ```svelte
43
- <Calendar {adapter} view="week-planner" /> <!-- default -->
54
+ <Calendar {adapter} view="week-planner" />
44
55
  <Calendar {adapter} view="week-scroll" />
45
- <Calendar {adapter} view="day-planner" />
46
- <Calendar {adapter} view="week-agenda" />
47
- <Calendar {adapter} view="day-agenda" />
48
56
  <Calendar {adapter} view="month-grid" />
49
57
  ```
50
58
 
51
- 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
+ ```
52
64
 
53
65
  Planner views are designed for direct manipulation:
54
66
 
55
67
  - **Move** — drag an event to another day or time; a ghost previews the target before the move commits.
56
- - **Resize** — drag an event's top/bottom edge handles to change its duration; `minDuration`/`maxDuration` clamp at commit.
57
- - **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 })`.
58
71
 
59
- 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.
60
73
 
61
- 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).
62
75
 
63
- ```svelte
64
- <Calendar {adapter} view="day-planner" showModePills={false} />
65
- ```
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`).
66
83
 
67
84
  ### Custom Views
68
85
 
69
- 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:
70
87
 
71
88
  ```svelte
72
89
  <script lang="ts">
73
- import { Calendar, type CalendarView } from '@nomideusz/svelte-calendar';
90
+ import { Calendar, defaultViews, type CalendarView } from '@nomideusz/svelte-calendar';
74
91
  import KanbanDay from './KanbanDay.svelte';
75
92
 
76
93
  const views: CalendarView[] = [
77
- { id: 'day-kanban', label: 'Kanban', mode: 'day', component: KanbanDay },
78
- { id: 'week-kanban', label: 'Kanban', mode: 'week', component: KanbanDay },
94
+ ...defaultViews,
95
+ { id: 'day-kanban', label: 'Kanban', mode: 'day', component: KanbanDay, props: { columns: 3 } },
79
96
  ];
80
97
  </script>
81
98
 
82
99
  <Calendar {adapter} {views} view="day-kanban" />
83
100
  ```
84
101
 
85
- 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)).
86
142
 
87
143
  ## Mobile
88
144
 
89
- 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:
90
146
 
91
147
  ```svelte
92
- <!-- Auto-detect (default) — switches at 768px -->
148
+ <!-- Auto-detect (default) — switches at a 768px container width -->
93
149
  <Calendar {adapter} />
94
150
 
95
151
  <!-- Force mobile layout -->
@@ -99,40 +155,106 @@ On narrow screens (`< 768px`), the calendar automatically remaps Planner views t
99
155
  <Calendar {adapter} mobile={false} />
100
156
  ```
101
157
 
102
- Mobile views include:
103
- - **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
104
- - **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.
105
162
 
106
- 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.
107
164
 
108
165
  ## Callbacks
109
166
 
110
167
  ```svelte
111
168
  <Calendar
112
169
  {adapter}
113
- oneventclick={(event) => console.log('Clicked', event.title)}
170
+ oneventclick={(event, anchor) => console.log('Clicked', event.title, anchor)}
114
171
  oneventcreate={(range) => console.log('New slot', range.start, range.end)}
115
172
  oneventmove={(event, start, end) => console.log('Moved', event.title, start, end)}
173
+ onexternaldrop={({ start, dataTransfer }) => console.log('Dropped', start, dataTransfer.getData('text/plain'))}
116
174
  onviewchange={(viewId) => console.log('View', viewId)}
175
+ ondatechange={(date) => console.log('Date', date)}
117
176
  ondayclick={(date) => console.log('Day', date)}
118
177
  onerror={(error) => console.error('Calendar', error)}
119
178
  />
120
179
  ```
121
180
 
122
- `onerror` surfaces adapter load failures and rejected drag commits that would
123
- otherwise only reach the console. Clicking an event also selects it — the
124
- 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.
125
194
 
126
- 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:
127
214
 
128
215
  ```svelte
129
216
  <Calendar {adapter} readOnly />
130
217
  ```
131
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
+
132
254
  Hide nav controls (prev/next/today) and treat all days equally (no past-day dimming):
133
255
 
134
256
  ```svelte
135
- <!-- Yoga studio: fixed weekly schedule, no browsing, all days equal -->
257
+ <!-- Fixed weekly schedule, no browsing, all days equal -->
136
258
  <Calendar
137
259
  {adapter}
138
260
  view="week-agenda"
@@ -146,7 +268,6 @@ Hide nav controls (prev/next/today) and treat all days equally (no past-day dimm
146
268
  Hide weekends for a workweek view:
147
269
 
148
270
  ```svelte
149
- <!-- Office planner: Mon–Fri only -->
150
271
  <Calendar {adapter} view="week-planner" hideDays={[6, 7]} />
151
272
  ```
152
273
 
@@ -164,7 +285,7 @@ Control which date the calendar shows from your app:
164
285
  />
165
286
  ```
166
287
 
167
- Show a rolling 3-day view:
288
+ Show a rolling 3-day view (week views other than 7 days start at the focused date):
168
289
 
169
290
  ```svelte
170
291
  <Calendar {adapter} view="week-planner" days={3} />
@@ -177,8 +298,8 @@ Block lunch hours and enforce 30–120 min events:
177
298
  import type { BlockedSlot } from '@nomideusz/svelte-calendar';
178
299
 
179
300
  const blocked: BlockedSlot[] = [
180
- { start: 12, end: 13, label: 'Lunch' }, // every day 12–1 PM
181
- { 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)
182
303
  ];
183
304
  </script>
184
305
 
@@ -194,7 +315,7 @@ Disable specific dates:
194
315
  />
195
316
  ```
196
317
 
197
- 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.
198
319
 
199
320
  Custom day headers and hover previews:
200
321
 
@@ -218,11 +339,12 @@ Replace the built-in navigation or the entire header chrome with your own contro
218
339
  </Calendar>
219
340
 
220
341
  <Calendar {adapter}>
221
- {#snippet header({ dateLabel, mode, switchMode, prev, next, goToday })}
342
+ {#snippet header({ dateLabel, mode, modes, switchMode, prev, next, goToday })}
222
343
  <nav class="my-toolbar">
223
344
  <h2>{dateLabel}</h2>
224
- <button class:active={mode === 'day'} onclick={() => switchMode('day')}>Day</button>
225
- <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}
226
348
  <button onclick={prev}>←</button>
227
349
  <button onclick={goToday}>Today</button>
228
350
  <button onclick={next}>→</button>
@@ -231,7 +353,7 @@ Replace the built-in navigation or the entire header chrome with your own contro
231
353
  </Calendar>
232
354
  ```
233
355
 
234
- 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:
235
357
 
236
358
  ```svelte
237
359
  <Calendar {adapter} view="week-agenda" height="auto" compact />
@@ -243,8 +365,8 @@ Three built-in presets:
243
365
 
244
366
  | Preset | Description |
245
367
  |--------|-------------|
246
- | `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. |
247
- | `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. |
248
370
  | `midnight` | Explicit dark theme. Charcoal bg, red accent. |
249
371
 
250
372
  ```svelte
@@ -257,21 +379,23 @@ Three built-in presets:
257
379
  <Calendar {adapter} theme={midnight} /> <!-- explicit dark mode -->
258
380
  ```
259
381
 
382
+ `presets` maps the names to the strings (`presets.neutral`); the type `PresetName` is `'auto' | 'neutral' | 'midnight'`.
383
+
260
384
  ### Smart Auto Theme
261
385
 
262
- 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:
263
387
 
264
- - **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
265
389
  - **Light/dark mode** — from background luminance
266
- - **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
267
391
  - **Text color** — validated for contrast against the background
268
- - **Fonts** — inherited via CSS cascade
392
+ - **Fonts** — the host's computed font stack; the mono stack from `--font-mono` and similar variables
269
393
 
270
- 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.
271
395
 
272
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.
273
397
 
274
- Fine-tune auto-detection with the `autoTheme` prop:
398
+ Fine-tune auto-detection with the `autoTheme` prop (`AutoThemeOptions`):
275
399
 
276
400
  ```svelte
277
401
  <!-- Force dark mode even if the page background is light -->
@@ -283,73 +407,63 @@ Fine-tune auto-detection with the `autoTheme` prop:
283
407
  <!-- Override the font stack -->
284
408
  <Calendar {adapter} autoTheme={{ font: '"Poppins", sans-serif' }} />
285
409
 
286
- <!-- Combine overrides -->
287
- <Calendar {adapter} autoTheme={{ mode: 'dark', accent: '#10b981' }} />
288
-
289
- <!-- Disable auto-probing entirely (passive CSS inheritance only) -->
410
+ <!-- Disable probing entirely: inherit --dt-* from ancestors -->
290
411
  <Calendar {adapter} autoTheme={false} />
291
412
  ```
292
413
 
293
- ### Manual CSS Variables
414
+ ### Your own tokens
294
415
 
295
- 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:
296
417
 
297
- ```html
298
- <!-- Wrap in a div with your overrides -->
299
- <div style="--dt-accent: #e11d48; --dt-bg: #1a1a2e; --dt-text: rgba(255,255,255,0.87);">
300
- <Calendar {adapter} />
301
- </div>
302
- ```
418
+ - **Change a few tokens** — append them to a preset (later declarations win):
303
419
 
304
- Or set them at the page level:
305
-
306
- ```css
307
- :root {
308
- --dt-bg: #fafafa;
309
- --dt-accent: #2563eb;
310
- --dt-text: rgba(0, 0, 0, 0.87);
311
- --dt-border: rgba(0, 0, 0, 0.08);
312
- }
313
- ```
420
+ ```ts
421
+ import { neutral } from '@nomideusz/svelte-calendar';
314
422
 
315
- 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
+ ```
316
425
 
317
- ### Extending Presets
426
+ ```svelte
427
+ <Calendar {adapter} theme={custom} />
428
+ ```
318
429
 
319
- 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:
320
432
 
321
- ```ts
322
- import { neutral } from '@nomideusz/svelte-calendar';
323
-
324
- // neutral base + custom accent + rounded feel
325
- const custom = `${neutral}; --dt-accent: #e11d48;`;
326
- ```
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
+ ```
327
438
 
328
- ```svelte
329
- <Calendar {adapter} theme={custom} />
330
- ```
439
+ Component fallbacks use system fonts and a neutral blue accent, so the package stays clean in apps that set no tokens.
331
440
 
332
441
  <details>
333
442
  <summary>All design tokens</summary>
334
443
 
335
444
  | Token | Purpose |
336
445
  |-------|---------|
337
- | `--dt-stage-bg` | Background behind the calendar (page area) |
338
- | `--dt-bg` | Calendar card background |
339
- | `--dt-surface` | Elevated surface (alternating rows, headers) |
446
+ | `--dt-bg` | Calendar background |
447
+ | `--dt-surface` | Elevated surface (headers, alternating rows, chips) |
340
448
  | `--dt-border` | Default border |
341
- | `--dt-border-day` | Day-column dividers |
449
+ | `--dt-border-day` | Day-column / day-cell dividers |
342
450
  | `--dt-text` | Primary text |
343
451
  | `--dt-text-2` | Secondary text |
344
452
  | `--dt-text-3` | Tertiary text |
345
453
  | `--dt-accent` | Accent color |
346
- | `--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 |
347
457
  | `--dt-glow` | Accent glow / focus ring |
348
458
  | `--dt-today-bg` | Today column highlight |
349
- | `--dt-btn-text` | Button label color |
459
+ | `--dt-weekend-bg` | Weekend cell tint |
460
+ | `--dt-hover` | Hover background |
350
461
  | `--dt-scrollbar` | Scrollbar thumb |
351
462
  | `--dt-success` | Completed indicator |
352
- | `--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. |
353
467
 
354
468
  </details>
355
469
 
@@ -367,23 +481,23 @@ const custom = `${neutral}; --dt-accent: #e11d48;`;
367
481
  | `subtitle` | `string?` | Secondary text below the title |
368
482
  | `tags` | `string[]?` | Small accent-colored pills |
369
483
  | `allDay` | `boolean?` | Render as an all-day event |
370
- | `location` | `string?` | Room, venue, or address (universal across domains) |
484
+ | `location` | `string?` | Room, venue, or address |
371
485
  | `status` | `EventStatus?` | `'confirmed'` (default), `'cancelled'`, `'tentative'`, `'full'`, `'limited'` |
372
486
  | `externalId` | `string?` | ID from an upstream system (booking platform, CRM, LMS) |
373
- | `resourceId` | `string?` | Resource this event belongs to (room, instructor, court) for multi-resource views |
374
- | `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. |
375
489
 
376
- 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.
377
491
 
378
492
  ### Auto-Coloring
379
493
 
380
- 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`):
381
495
 
382
496
  ```ts
383
497
  const events = [
384
- { id: '1', title: 'Yoga', category: 'wellness', start: ..., end: ... },
385
- { id: '2', title: 'Pilates', category: 'wellness', start: ..., end: ... }, // same color
386
- { 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
387
501
  ];
388
502
  ```
389
503
 
@@ -392,29 +506,26 @@ Generate a theme-harmonious palette from any accent color:
392
506
  ```ts
393
507
  import { createMemoryAdapter, generatePalette } from '@nomideusz/svelte-calendar';
394
508
 
395
- // Colors that harmonize with your theme's accent
396
- const palette = generatePalette('#e11d48');
509
+ const palette = generatePalette('#e11d48'); // (accent?, count = 15)
397
510
  const adapter = createMemoryAdapter(events, { palette });
398
511
  ```
399
512
 
400
- 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).
401
514
 
402
515
  ### Multi-day & All-day
403
516
 
404
- 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:
405
518
 
406
519
  ```ts
407
520
  const events = [
408
521
  { id: '1', title: 'Conference', start: new Date('2026-03-15'), end: new Date('2026-03-18'), allDay: true },
409
- { 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') },
410
523
  ];
411
524
  ```
412
525
 
413
526
  ### Custom Event Rendering
414
527
 
415
- Use the `event` snippet to replace the event *content* in every view (the
416
- interactive shell — click, keyboard, drag, selection — stays intact). The
417
- `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:
418
529
 
419
530
  ```svelte
420
531
  <Calendar {adapter}>
@@ -424,6 +535,9 @@ interactive shell — click, keyboard, drag, selection — stays intact). The
424
535
  {#if ev.subtitle}<small>{ev.subtitle}</small>{/if}
425
536
  </div>
426
537
  {/snippet}
538
+ {#snippet empty()}
539
+ <p>Nothing booked — drag on the grid to add a class.</p>
540
+ {/snippet}
427
541
  </Calendar>
428
542
  ```
429
543
 
@@ -441,10 +555,11 @@ For fixed repeating events (class timetables, office hours):
441
555
  startDate: '2026-03-01', until: '2026-03-31' },
442
556
  { id: '3', title: 'Review', frequency: 'monthly', dayOfMonth: 15,
443
557
  startTime: '10:00', endTime: '11:00' },
558
+ { id: '4', title: 'Night shift', dayOfWeek: [5, 6], startTime: '22:00', endTime: '02:00' },
444
559
  ]);
445
560
  </script>
446
561
 
447
- <Calendar {adapter} readOnly />
562
+ <Calendar {adapter} />
448
563
  ```
449
564
 
450
565
  <details>
@@ -452,42 +567,52 @@ For fixed repeating events (class timetables, office hours):
452
567
 
453
568
  | Field | Type | Default | Description |
454
569
  |-------|------|---------|-------------|
455
- | `id` | `string` | *required* | Unique identifier |
570
+ | `id` | `string` | *required* | Rule identifier |
456
571
  | `title` | `string` | *required* | Event title |
457
- | `startTime` | `string` | *required* | Start time `"HH:MM"` |
458
- | `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). |
459
574
  | `frequency` | `'daily' \| 'weekly' \| 'monthly'` | `'weekly'` | Recurrence frequency |
460
- | `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. |
461
576
  | `dayOfWeek` | `number \| number[]` | — | ISO weekday 1=Mon…7=Sun. Required for weekly. |
462
- | `dayOfMonth` | `number` | `1` | Day of month (1–31). For monthly. |
463
- | `startDate` | `string` | — | First occurrence `"YYYY-MM-DD"` |
464
- | `until` | `string` | — | Last occurrence `"YYYY-MM-DD"` |
465
- | `count` | `number` | — | Max occurrences from `startDate` |
466
- | `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` |
467
585
 
468
586
  </details>
469
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
+
470
592
  ## REST Adapter
471
593
 
472
594
  Connect to any REST API:
473
595
 
474
596
  ```ts
475
- 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 };
476
600
 
477
601
  const adapter = createRestAdapter({
478
602
  baseUrl: 'https://api.example.com/v1',
479
603
  headers: { Authorization: 'Bearer TOKEN' },
480
- // Optional: map your API shape to TimelineEvent[]
481
- mapEvents: (data) => data.items.map(item => ({
482
- id: item.id,
483
- title: item.name,
484
- start: new Date(item.startAt),
485
- end: new Date(item.endAt),
486
- })),
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
+ })),
487
612
  });
488
613
  ```
489
614
 
490
- 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.
491
616
 
492
617
  ## JMAP Adapter
493
618
 
@@ -512,11 +637,11 @@ const adapter = createJmapAdapter(client, {
512
637
  });
513
638
  ```
514
639
 
515
- 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).
516
641
 
517
642
  ## Mapped Adapter
518
643
 
519
- 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:
520
645
 
521
646
  ```ts
522
647
  import { createMappedAdapter } from '@nomideusz/svelte-calendar';
@@ -528,7 +653,6 @@ const adapter = createMappedAdapter(rawClasses, {
528
653
  end: 'ends_at_iso',
529
654
  subtitle: 'teacher',
530
655
  location: 'room',
531
- color: 'color',
532
656
  externalId: 'reference_id',
533
657
  status: 'is_cancelled', // boolean → 'cancelled' / 'confirmed'
534
658
  tags: ['is_free', 'is_bookable_online'],
@@ -536,9 +660,12 @@ const adapter = createMappedAdapter(rawClasses, {
536
660
  });
537
661
  ```
538
662
 
539
- `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.
540
667
 
541
- For full control, pass a `mapEvent` transform and skip `fields` entirely:
668
+ For full control, pass `mapEvent(raw, index)` and skip `fields`:
542
669
 
543
670
  ```ts
544
671
  const adapter = createMappedAdapter(rawData, {
@@ -553,53 +680,80 @@ const adapter = createMappedAdapter(rawData, {
553
680
  });
554
681
  ```
555
682
 
556
- 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.
557
684
 
558
685
  ## Composite Adapter
559
686
 
560
- 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:
561
688
 
562
689
  ```ts
563
- import {
564
- Calendar,
565
- createMemoryAdapter,
566
- createRecurringAdapter,
567
- createCompositeAdapter,
568
- } from '@nomideusz/svelte-calendar';
690
+ import { createMemoryAdapter, createRecurringAdapter, createCompositeAdapter } from '@nomideusz/svelte-calendar';
569
691
 
570
692
  const memory = createMemoryAdapter(oneOffEvents);
571
693
  const recurring = createRecurringAdapter(weeklySchedule);
572
694
 
573
695
  const adapter = createCompositeAdapter([memory, recurring]);
574
- // Reads from both; writes go to memory (the primary — first adapter by default)
575
696
  ```
576
697
 
577
- 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`).
578
702
 
579
703
  ```ts
580
- 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
+ });
581
708
  ```
582
709
 
583
- 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.
584
-
585
710
  ## Custom Adapter
586
711
 
587
- Implement the `CalendarAdapter` interface to connect any data source:
712
+ Implement the `CalendarAdapter` interface to connect any data source. Only `fetchEvents` is required:
588
713
 
589
714
  ```ts
590
715
  import type { CalendarAdapter, DateRange, TimelineEvent } from '@nomideusz/svelte-calendar';
591
716
 
592
717
  const adapter: CalendarAdapter = {
718
+ // Required: events overlapping the range
593
719
  fetchEvents: async (range: DateRange) => { /* return TimelineEvent[] */ },
594
- createEvent: async (event) => { /* return created TimelineEvent with id */ },
595
- 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 */ },
596
725
  deleteEvent: async (id) => { /* void */ },
597
726
  };
598
727
  ```
599
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
+
600
754
  ## Headless API
601
755
 
602
- 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.
603
757
 
604
758
  ```svelte
605
759
  <script lang="ts">
@@ -630,11 +784,15 @@ For full control over rendering, skip the `<Calendar>` component and drive every
630
784
  {/each}
631
785
  ```
632
786
 
633
- `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.
634
790
 
635
- 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`).
636
792
 
637
- For a simpler day-only list view, use `createAgenda()`:
793
+ ### createAgenda
794
+
795
+ A live single-day list: `createAgenda({ adapter, initialDate?, locale?, lookahead?, timezone? })`.
638
796
 
639
797
  ```svelte
640
798
  <script lang="ts">
@@ -654,21 +812,47 @@ For a simpler day-only list view, use `createAgenda()`:
654
812
  {/each}
655
813
  ```
656
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
+
657
841
  ## Localization (i18n)
658
842
 
659
- 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()`:
660
844
 
661
845
  ```svelte
662
846
  <Calendar {adapter} locale="de-DE" />
663
847
  ```
664
848
 
665
- `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.
666
850
 
667
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):
668
852
 
669
853
  ```svelte
670
854
  <Calendar {adapter} locale="de-DE" labels={{
671
- today: 'Heute', day: 'Tag', week: 'Woche',
855
+ today: 'Heute', day: 'Tag', week: 'Woche', month: 'Monat',
672
856
  noEvents: 'Keine Termine',
673
857
  nMore: (n) => `+${n} weitere`,
674
858
  }} />
@@ -682,92 +866,115 @@ import { setLabels } from '@nomideusz/svelte-calendar';
682
866
  setLabels({ today: 'Heute', day: 'Tag', week: 'Woche' });
683
867
  ```
684
868
 
685
- 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.
686
870
 
687
871
  <details>
688
872
  <summary>All label keys</summary>
689
873
 
690
- | Key | Default | Description |
691
- |-----|---------|-------------|
692
- | `today` | `'Today'` | Relative day label / nav button |
693
- | `yesterday` | `'Yesterday'` | Relative day label |
694
- | `tomorrow` | `'Tomorrow'` | Relative day label |
695
- | `day` | `'Day'` | Mode pill |
696
- | `week` | `'Week'` | Mode pill |
697
- | `planner` | `'Planner'` | View label |
698
- | `agenda` | `'Agenda'` | View label |
699
- | `now` | `'now'` | Live indicator badge |
700
- | `free` | `'free'` | Empty slot hint |
701
- | `allDay` | `'All day'` | All-day event label |
702
- | `done` | `'Done'` | Past section header |
703
- | `upNext` | `'Up next'` | Upcoming section header |
704
- | `until` | `'until'` | Time-until prefix |
705
- | `noEvents` | `'No events'` | Empty day |
706
- | `nothingScheduled` | `'Nothing scheduled'` | Empty state |
707
- | `allDoneForToday` | `'All done for today'` | Completed state |
708
- | `goToToday` | `'Go to today'` | Nav button aria |
709
- | `previousDay` / `nextDay` | `'Previous day'` / `'Next day'` | Nav aria |
710
- | `previousWeek` / `nextWeek` | `'Previous week'` / `'Next week'` | Nav aria |
711
- | `calendar` | `'Calendar'` | Root region aria |
712
- | `nMore(n)` | `` `+${n} more` `` | Overflow count |
713
- | `nEvents(n)` | `` `${n} event(s)` `` | Event count aria |
714
- | `nCompleted(n)` | `` `${n} completed` `` | Completed count |
715
- | `dayNOfTotal(i, t)` | `` `day ${i} of ${t}` `` | Multi-day segment |
716
- | `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` |
717
904
 
718
905
  </details>
719
906
 
720
907
  ## Timezone Support
721
908
 
722
- Render the whole calendar in any IANA timezone — events, the now-indicator
723
- and day boundaries all shift; ranges passed to `oneventcreate`/`oneventmove`
724
- convert back to real instants:
909
+ Render the whole calendar in any IANA timezone — events, the now-indicator and day boundaries all shift:
725
910
 
726
911
  ```svelte
727
912
  <Calendar {adapter} timezone="Europe/Warsaw" />
728
913
  ```
729
914
 
730
- Under the hood the adapter is wrapped with `wrapAdapterWithTimezone` (also
731
- exported) so views do plain local-time math on a zoned wall-clock plane.
732
- Known limit shared by every wall-clock calendar: the repeated hour of a DST
733
- 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
+ ```
734
931
 
735
- 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:
736
937
 
737
938
  ```ts
738
- import { toZonedTime, fromZonedTime, nowInZone } from '@nomideusz/svelte-calendar';
939
+ import { toZonedTime, fromZonedTime, nowInZone, formatInTimeZone } from '@nomideusz/svelte-calendar';
739
940
 
740
- // Display a UTC date in a specific timezone
941
+ // A Date whose local fields show the instant's wall-clock time in New York
741
942
  const local = toZonedTime(utcDate, 'America/New_York');
742
943
 
743
- // Convert back to UTC before saving
744
- const utc = fromZonedTime(localDate, 'America/New_York');
944
+ // Back to the real instant before saving
945
+ const utc = fromZonedTime(local, 'America/New_York');
745
946
 
746
- // Current time in a timezone
947
+ // Current time in a timezone (wall-clock Date)
747
948
  const now = nowInZone('Asia/Tokyo');
748
949
 
749
- // Format a date directly in a timezone
750
- 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' });
751
952
  ```
752
953
 
753
954
  ## Utilities
754
955
 
755
- 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.
756
957
 
757
958
  | Export | Purpose |
758
959
  |--------|---------|
759
- | `fmtTime(date, locale?)` / `fmtH(hour, locale?)` | Locale-aware time / hour labels |
760
- | `fmtDuration(ms)` | `"1h 30m"`-style durations |
761
- | `fmtDay` / `fmtWeekRange` / `dateShort` / `dateWithWeekday` | Date labels |
762
- | `weekdayShort` / `weekdayLong` / `monthShort` / `monthLong` | Name parts from a day timestamp |
763
- | `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`) |
764
969
  | `isAllDay(ev)` / `isMultiDay(ev)` | Event classification |
765
- | `segmentForDay(ev, dayMs)` | The slice of a multi-day event that falls on one day |
766
- | `createClock()` | Reactive clock (`tick`, `today`) driving now-lines and relative labels |
767
- | `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. |
768
- | `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. |
769
- | `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. |
770
- | `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. |
771
978
 
772
979
  ## Embeddable Widget
773
980
 
@@ -782,81 +989,107 @@ Drop into any HTML page — no build tools needed. Registers a `<day-calendar>`
782
989
  view="week-planner"
783
990
  height="600"
784
991
  locale="en-US"
785
- mondaystart="true"
786
992
  ></day-calendar>
787
993
  ```
788
994
 
789
- 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.
790
-
791
- | Attribute | Description |
792
- |-----------|-------------|
793
- | `api` | REST endpoint — fetched as `GET {api}?start=...&end=...` |
794
- | `events` | Inline JSON array of events (alternative to `api`) |
795
- | `view` | Initial view ID (`week-planner`, `day-agenda`, …) |
796
- | `theme` | `auto`, `neutral`, or `midnight` |
797
- | `height` | Calendar height in px |
798
- | `locale` | BCP 47 locale tag |
799
- | `dir` | `ltr` or `rtl` |
800
- | `mondaystart` | `"true"` / `"false"` — week-start day |
801
- | `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.
802
1019
 
803
1020
  ## All Props
804
1021
 
805
1022
  <details>
806
- <summary>Full Calendar props reference</summary>
1023
+ <summary>Full Calendar props reference (<code>CalendarProps</code>)</summary>
807
1024
 
808
1025
  | Prop | Type | Default | Description |
809
1026
  |------|------|---------|-------------|
810
- | `adapter` | `CalendarAdapter` | *required* | Data layer (memory, recurring, mapped, composite, REST, or custom) |
811
- | `views` | `CalendarView[]` | 6 built-in | Registered view components (4 desktop + 2 mobile variants) |
812
- | `view` | `string` | first view | Active view ID |
813
- | `theme` | `string` | `auto` | CSS theme string (`--dt-*` custom properties). `auto` probes the host page and generates matching tokens. |
814
- | `autoTheme` | `AutoThemeOptions \| false` | `{}` | Fine-tune auto-detection: `{ mode, accent, font }`. Set `false` to disable probing. |
815
- | `mobile` | `'auto' \| boolean` | `'auto'` | Mobile mode. `'auto'` detects via container width (<768px). Remaps Planner→Mobile views. |
816
- | `height` | `number \| 'auto'` | `600` | Height in pixels, or `'auto'` to let content grow naturally (ideal for Agenda views) |
817
- | `borderRadius` | `number` | `12` | Border radius in pixels. Set to `0` for no rounding. |
818
- | `locale` | `string` | `'en-US'` | BCP 47 locale tag |
819
- | `dir` | `'ltr' \| 'rtl' \| 'auto'` | — | Text direction |
820
- | `mondayStart` | `boolean` | `true` | Start week on Monday |
821
- | `readOnly` | `boolean` | `false` | Disable drag, resize, and click-to-create |
822
- | `visibleHours` | `[number, number]` | — | Crop grid to `[startHour, endHour)` |
823
- | `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. |
824
1044
  | `snapInterval` | `number` | `15` | Drag snap in minutes |
825
- | `showModePills` | `boolean` | `true` | Show the Day/Week mode pills |
826
- | `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) |
827
1051
  | `equalDays` | `boolean` | `false` | Treat all days equally (no past-day dimming/collapsing) |
828
- | `showDates` | `boolean` | `true` | Show date numbers in headers. `false` = day names only (Mon, Tue, …) |
829
- | `hideDays` | `number[]` | — | ISO weekdays to hide (1=Mon … 7=Sun). E.g. `[6, 7]` hides weekends |
830
- | `currentDate` | `Date` | — | Controlled focus date (drives which date the calendar shows) |
831
- | `days` | `number` | `7` | Number of days shown in week views (e.g. `3` for a rolling 3-day view) |
832
- | `blockedSlots` | `BlockedSlot[]` | — | Time ranges that cannot be booked (hatched overlay in planner views) |
833
- | `disabledDates` | `Date[]` | — | Dates that reject creation/moves while keeping existing events visible and clickable |
834
- | `minDuration` | `number` | — | Minimum event duration in minutes (enforced on create & resize) |
835
- | `maxDuration` | `number` | — | Maximum event duration in minutes (enforced on create & resize) |
836
- | `compact` | `boolean` | `false` | Minimal text-row rendering in Agenda views (dot + time + title) |
837
- | `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 |
838
- | `dayHeader` | `Snippet<[{ date, isToday, dayName }]>` | — | Custom day header snippet for planner/agenda views |
839
- | `header` | `Snippet<[HeaderContext]>` | — | Replace entire header chrome (date label + mode pills + nav) |
840
- | `navigation` | `Snippet<[NavigationContext]>` | — | Replace just the prev/next/today controls |
841
- | `oneventclick` | `(event) => void` | — | Event clicked |
842
- | `oneventcreate` | `(range) => void` | — | New time range selected |
843
- | `oneventmove` | `(event, start, end) => void` | — | Event dragged to new time |
844
- | `onviewchange` | `(viewId) => void` | — | Active view changed |
845
- | `oneventhover` | `(event) => void` | — | Pointer enters an event (for tooltips, previews) |
846
- | `ondatechange` | `(date) => void` | — | Focused date changed (navigation, scroll, etc.) |
847
- | `event` | `Snippet<[TimelineEvent]>` | — | Custom event rendering |
848
- | `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 |
849
1072
 
850
1073
  </details>
851
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
+
852
1084
  ## Development
853
1085
 
854
1086
  ```bash
855
1087
  pnpm install
856
- pnpm dev # SvelteKit dev server
857
- pnpm check # Type check
858
- pnpm run package # Build library
859
- 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
860
1093
  ```
861
1094
 
862
1095
  ## License