@cocoar/vue-ui 3.2.0-beta.5 → 3.2.0-beta.7

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 (111) hide show
  1. package/bin/cli.mjs +53 -0
  2. package/package.json +9 -3
  3. package/skills/cocoar-vue-ui/SKILL.md +244 -0
  4. package/skills/cocoar-vue-ui/references/components/avatar.md +178 -0
  5. package/skills/cocoar-vue-ui/references/components/badge.md +290 -0
  6. package/skills/cocoar-vue-ui/references/components/breadcrumb.md +238 -0
  7. package/skills/cocoar-vue-ui/references/components/button.md +252 -0
  8. package/skills/cocoar-vue-ui/references/components/calendar/agenda-view.md +353 -0
  9. package/skills/cocoar-vue-ui/references/components/calendar/coar-calendar.md +1645 -0
  10. package/skills/cocoar-vue-ui/references/components/calendar/day-view.md +361 -0
  11. package/skills/cocoar-vue-ui/references/components/calendar/index.md +312 -0
  12. package/skills/cocoar-vue-ui/references/components/calendar/month-view.md +475 -0
  13. package/skills/cocoar-vue-ui/references/components/calendar/performance.md +186 -0
  14. package/skills/cocoar-vue-ui/references/components/calendar/timeline-view.md +301 -0
  15. package/skills/cocoar-vue-ui/references/components/calendar/week-view.md +382 -0
  16. package/skills/cocoar-vue-ui/references/components/calendar/work-week-view.md +145 -0
  17. package/skills/cocoar-vue-ui/references/components/calendar/year-view.md +63 -0
  18. package/skills/cocoar-vue-ui/references/components/card.md +151 -0
  19. package/skills/cocoar-vue-ui/references/components/checkbox-group.md +103 -0
  20. package/skills/cocoar-vue-ui/references/components/checkbox.md +279 -0
  21. package/skills/cocoar-vue-ui/references/components/code-block.md +241 -0
  22. package/skills/cocoar-vue-ui/references/components/context-menu.md +355 -0
  23. package/skills/cocoar-vue-ui/references/components/data-grid/checkbox.md +213 -0
  24. package/skills/cocoar-vue-ui/references/components/data-grid/date-columns.md +272 -0
  25. package/skills/cocoar-vue-ui/references/components/data-grid/editing.md +236 -0
  26. package/skills/cocoar-vue-ui/references/components/data-grid/multi-select.md +193 -0
  27. package/skills/cocoar-vue-ui/references/components/data-grid/number.md +147 -0
  28. package/skills/cocoar-vue-ui/references/components/data-grid/select.md +202 -0
  29. package/skills/cocoar-vue-ui/references/components/data-grid/text.md +127 -0
  30. package/skills/cocoar-vue-ui/references/components/data-grid.md +1225 -0
  31. package/skills/cocoar-vue-ui/references/components/data-list.md +2121 -0
  32. package/skills/cocoar-vue-ui/references/components/date-or-time-picker.md +200 -0
  33. package/skills/cocoar-vue-ui/references/components/date-picker.md +203 -0
  34. package/skills/cocoar-vue-ui/references/components/date-time-picker.md +154 -0
  35. package/skills/cocoar-vue-ui/references/components/date-views.md +212 -0
  36. package/skills/cocoar-vue-ui/references/components/dialog.md +148 -0
  37. package/skills/cocoar-vue-ui/references/components/divider.md +157 -0
  38. package/skills/cocoar-vue-ui/references/components/document-viewer/annotations.md +400 -0
  39. package/skills/cocoar-vue-ui/references/components/document-viewer/coar-document-viewer.md +327 -0
  40. package/skills/cocoar-vue-ui/references/components/document-viewer/index.md +234 -0
  41. package/skills/cocoar-vue-ui/references/components/document-viewer/toolbar.md +221 -0
  42. package/skills/cocoar-vue-ui/references/components/drag-drop.md +267 -0
  43. package/skills/cocoar-vue-ui/references/components/dual-listbox.md +449 -0
  44. package/skills/cocoar-vue-ui/references/components/file-explorer/asset-store.md +327 -0
  45. package/skills/cocoar-vue-ui/references/components/file-explorer/in-memory-store.md +176 -0
  46. package/skills/cocoar-vue-ui/references/components/file-explorer/index.md +311 -0
  47. package/skills/cocoar-vue-ui/references/components/file-explorer/use-file-explorer.md +300 -0
  48. package/skills/cocoar-vue-ui/references/components/form-field.md +1025 -0
  49. package/skills/cocoar-vue-ui/references/components/fragment-parser.md +298 -0
  50. package/skills/cocoar-vue-ui/references/components/link.md +235 -0
  51. package/skills/cocoar-vue-ui/references/components/listbox.md +575 -0
  52. package/skills/cocoar-vue-ui/references/components/map/editor.md +448 -0
  53. package/skills/cocoar-vue-ui/references/components/map/index.md +351 -0
  54. package/skills/cocoar-vue-ui/references/components/markdown-diagrams.md +210 -0
  55. package/skills/cocoar-vue-ui/references/components/markdown-editor.md +1478 -0
  56. package/skills/cocoar-vue-ui/references/components/markdown-embeds.md +387 -0
  57. package/skills/cocoar-vue-ui/references/components/markdown-form.md +447 -0
  58. package/skills/cocoar-vue-ui/references/components/markdown.md +276 -0
  59. package/skills/cocoar-vue-ui/references/components/menu.md +380 -0
  60. package/skills/cocoar-vue-ui/references/components/mermaid.md +172 -0
  61. package/skills/cocoar-vue-ui/references/components/navbar.md +147 -0
  62. package/skills/cocoar-vue-ui/references/components/note.md +96 -0
  63. package/skills/cocoar-vue-ui/references/components/notice.md +131 -0
  64. package/skills/cocoar-vue-ui/references/components/number-input.md +215 -0
  65. package/skills/cocoar-vue-ui/references/components/otp-input.md +324 -0
  66. package/skills/cocoar-vue-ui/references/components/page-builder/authoring-contract.md +271 -0
  67. package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-builder.md +476 -0
  68. package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-renderer.md +677 -0
  69. package/skills/cocoar-vue-ui/references/components/page-builder/custom-elements.md +441 -0
  70. package/skills/cocoar-vue-ui/references/components/page-builder/idp-integration.md +122 -0
  71. package/skills/cocoar-vue-ui/references/components/page-builder/index.md +573 -0
  72. package/skills/cocoar-vue-ui/references/components/pagination.md +150 -0
  73. package/skills/cocoar-vue-ui/references/components/panel-layout.md +372 -0
  74. package/skills/cocoar-vue-ui/references/components/password-input.md +175 -0
  75. package/skills/cocoar-vue-ui/references/components/popconfirm.md +186 -0
  76. package/skills/cocoar-vue-ui/references/components/popover.md +173 -0
  77. package/skills/cocoar-vue-ui/references/components/progress-bar.md +178 -0
  78. package/skills/cocoar-vue-ui/references/components/radio-group.md +225 -0
  79. package/skills/cocoar-vue-ui/references/components/script-editor.md +1271 -0
  80. package/skills/cocoar-vue-ui/references/components/segmented-control.md +238 -0
  81. package/skills/cocoar-vue-ui/references/components/select.md +463 -0
  82. package/skills/cocoar-vue-ui/references/components/sidebar.md +421 -0
  83. package/skills/cocoar-vue-ui/references/components/spinner.md +132 -0
  84. package/skills/cocoar-vue-ui/references/components/switch.md +195 -0
  85. package/skills/cocoar-vue-ui/references/components/table.md +170 -0
  86. package/skills/cocoar-vue-ui/references/components/tabs.md +382 -0
  87. package/skills/cocoar-vue-ui/references/components/tag.md +178 -0
  88. package/skills/cocoar-vue-ui/references/components/text-input.md +256 -0
  89. package/skills/cocoar-vue-ui/references/components/toast.md +160 -0
  90. package/skills/cocoar-vue-ui/references/components/tooltip.md +121 -0
  91. package/skills/cocoar-vue-ui/references/components/transitions.md +193 -0
  92. package/skills/cocoar-vue-ui/references/components/tree.md +2388 -0
  93. package/skills/cocoar-vue-ui/references/components/virtual-list.md +212 -0
  94. package/skills/cocoar-vue-ui/references/components/wizard.md +251 -0
  95. package/skills/cocoar-vue-ui/references/components/zoned-date-time-picker.md +177 -0
  96. package/skills/cocoar-vue-ui/references/foundations/colors.md +708 -0
  97. package/skills/cocoar-vue-ui/references/foundations/design-principles.md +115 -0
  98. package/skills/cocoar-vue-ui/references/foundations/icons.md +381 -0
  99. package/skills/cocoar-vue-ui/references/foundations/localization/formatting.md +530 -0
  100. package/skills/cocoar-vue-ui/references/foundations/localization/setup.md +86 -0
  101. package/skills/cocoar-vue-ui/references/foundations/localization/timezones.md +182 -0
  102. package/skills/cocoar-vue-ui/references/foundations/localization/translations.md +305 -0
  103. package/skills/cocoar-vue-ui/references/foundations/motion.md +549 -0
  104. package/skills/cocoar-vue-ui/references/foundations/spacing.md +330 -0
  105. package/skills/cocoar-vue-ui/references/foundations/theming.md +140 -0
  106. package/skills/cocoar-vue-ui/references/foundations/typography.md +206 -0
  107. package/skills/cocoar-vue-ui/references/guide/error-handling.md +162 -0
  108. package/skills/cocoar-vue-ui/references/guide/getting-started.md +116 -0
  109. package/skills/cocoar-vue-ui/references/guide/migration-page-builder-3.md +207 -0
  110. package/skills/cocoar-vue-ui/references/guide/migration.md +140 -0
  111. package/skills/cocoar-vue-ui/references/guide/theming.md +98 -0
@@ -0,0 +1,1645 @@
1
+ <!-- Generated from apps/docs/components/calendar/coar-calendar.md by apps/docs/scripts/sync-skill.mjs. Do not edit; edit the docs page. -->
2
+
3
+ # `<CoarCalendar>` — Composer (Preview)
4
+
5
+ `<CoarCalendar>` is the top-level shell that wires the complete calendar hierarchy together with prev / today / next navigation and responsive display controls. It's driven by the **builder** returned from `useCalendar()` — a single chainable surface that owns events, recurrence, configuration, handlers, renderers, and an imperative `api` object.
6
+
7
+ The builder is **flat**: every setter lives directly on it, including view-specific ones (`timeRange`, `slotDuration` for Day / Week; `maxEventsPerCell` for Month; `agendaLengthDays` / `showEmptyDays` for Agenda). View-specific settings are no-ops outside their target view, so a single chained `.timeRange(...).maxEventsPerCell(...)` is fine — each one only takes effect when the matching view is active.
8
+
9
+ When you embed `<CoarDayView>` / `<CoarWeekView>` / `<CoarMonthView>` / `<CoarAgendaView>` standalone (without the shell), they consume the same builder type — `useDayView()` etc. are just shorthands that pre-set the matching `view` value.
10
+
11
+ ## View hierarchy
12
+
13
+ The primary switcher follows iOS while keeping the fixed web time grids as useful additions:
14
+
15
+ | Primary view | Display choices |
16
+ |---|---|
17
+ | Year | Responsive twelve-month overview; selecting a day or month drills into Month. |
18
+ | Month | Compact, Stacked, Details, List. The first three use continuously scrolling 4–6-week month sections. |
19
+ | Week | Fixed seven-day time grid. |
20
+ | Work week | The Week grid filtered by `workDays(...)`. |
21
+ | Day | One day or width-aware Multi-day (1–7 complete columns). |
22
+ | Agenda | Virtualized chronological list. |
23
+
24
+ `monthList` is a serializable `CalendarView`, but the shell presents it as the **List** choice under Month. Likewise, `dayMode('single' | 'multiDay')` changes the Day renderer without adding another primary view. `dayAgenda` and `timeline` remain opt-in views and are not in the default switcher.
25
+
26
+ ```ts
27
+ builder
28
+ .availableViews(['year', 'month', 'monthList', 'week', 'workWeek', 'day', 'agenda'])
29
+ .monthDensity('stacked')
30
+ .dayMode('multiDay')
31
+ .dayColumnMinWidth(220)
32
+ .shadeWeekends(true);
33
+ ```
34
+
35
+ ## Basic usage
36
+
37
+ ```ts
38
+ import { ref } from 'vue';
39
+ import { Temporal } from '@js-temporal/polyfill';
40
+ import {
41
+ CoarCalendar,
42
+ useCalendar,
43
+ type CalendarEvent,
44
+ type CalendarView,
45
+ } from '@cocoar/vue-calendar';
46
+
47
+ const events = ref<CalendarEvent[]>([
48
+ {
49
+ id: 'standup',
50
+ // Daily standup at 09:00 IN VIENNA — store the human's intent
51
+ // (local time + IANA zone), not a UTC instant. See
52
+ // "Display vs source zone" below.
53
+ start: Temporal.ZonedDateTime.from('2026-04-15T09:00:00[Europe/Vienna]'),
54
+ end: Temporal.ZonedDateTime.from('2026-04-15T09:30:00[Europe/Vienna]'),
55
+ meta: { title: 'Daily standup', color: '#10b981' },
56
+ },
57
+ {
58
+ id: 'devconf',
59
+ start: Temporal.PlainDate.from('2026-04-13'),
60
+ end: Temporal.PlainDate.from('2026-04-16'),
61
+ meta: { title: 'DevConf — Vienna', color: '#7c3aed' },
62
+ },
63
+ ]);
64
+ const view = ref<CalendarView>('week');
65
+ const date = ref('2026-04-15');
66
+
67
+ const { builder, api } = useCalendar();
68
+ builder
69
+ .events(events)
70
+ .view(view) // caller-owned view ref (optional)
71
+ .date(date) // caller-owned date ref (optional)
72
+ .timezone('Europe/Vienna') // DISPLAY zone — set this to a real IANA
73
+ // zone (or `detectBrowserTimezone()`).
74
+ // Don't use 'UTC' unless your users
75
+ // actually live in UTC.
76
+ .onEventClick(({ event }) => console.log(event.id));
77
+ ```
78
+
79
+ > **Warning: Don't use `'UTC'` as your display zone**
80
+ >
81
+ > UTC is a derived value (article 4: "store intent, derive math"). If your users live in Vienna, set `.timezone('Europe/Vienna')`. If you serve users worldwide, set `.timezone(detectBrowserTimezone())`. Setting the display zone to UTC makes 09:00 wall-time render at 11:00 in summer / 10:00 in winter — invisible bugs that surface when you ship.
82
+
83
+ ```html
84
+ <CoarCalendar :builder="builder" />
85
+ ```
86
+
87
+ **Demo — `calendar/demos/CalendarBasic.vue`**
88
+
89
+ ```vue
90
+ <template>
91
+ <div style="height: 600px; border: 1px solid var(--coar-border-neutral-tertiary); border-radius: var(--coar-radius-xs); overflow: hidden;">
92
+ <CoarCalendar :builder="builder" />
93
+ </div>
94
+ </template>
95
+
96
+ <script setup lang="ts">
97
+ import { ref } from 'vue';
98
+ import {
99
+ CoarCalendar,
100
+ useCalendar,
101
+ Temporal,
102
+ type CalendarEvent,
103
+ type CalendarView,
104
+ } from '@cocoar/vue-calendar';
105
+
106
+ const view = ref<CalendarView>('week');
107
+ const date = ref(Temporal.PlainDate.from('2026-04-15'));
108
+
109
+ const pd = (iso: string) => Temporal.PlainDate.from(iso);
110
+ // Defaulting the source zone to Vienna deliberately — the events
111
+ // represent meetings scheduled BY VIENNA WORKERS. Article 4: store
112
+ // intent (local + IANA zone), derive UTC.
113
+ const zdt = (iso: string, tz = 'Europe/Vienna') =>
114
+ Temporal.ZonedDateTime.from(`${iso}[${tz}]`);
115
+
116
+ // Mutable so drag/keyboard moves can rewrite start/end in place.
117
+ const events = ref<CalendarEvent[]>([
118
+ {
119
+ id: 'standup',
120
+ start: zdt('2026-04-15T09:00:00'),
121
+ end: zdt('2026-04-15T09:30:00'),
122
+ meta: { title: 'Daily standup', color: '#10b981' },
123
+ },
124
+ {
125
+ id: 'design',
126
+ start: zdt('2026-04-15T11:00:00'),
127
+ end: zdt('2026-04-15T12:30:00'),
128
+ meta: { title: 'Design review', color: '#8b5cf6' },
129
+ },
130
+ {
131
+ id: 'devconf',
132
+ start: pd('2026-04-13'),
133
+ end: pd('2026-04-16'),
134
+ meta: { title: 'DevConf — Vienna', color: '#7c3aed' },
135
+ },
136
+ {
137
+ id: 'sven-ooo',
138
+ start: pd('2026-04-14'),
139
+ end: pd('2026-04-17'),
140
+ meta: { title: 'Sven — OOO', color: '#9ca3af' },
141
+ },
142
+ {
143
+ id: 'lunch',
144
+ start: zdt('2026-04-16T12:00:00'),
145
+ end: zdt('2026-04-16T13:00:00'),
146
+ meta: { title: 'Lunch with Anna', color: '#ef4444' },
147
+ },
148
+ ]);
149
+
150
+ const { builder } = useCalendar();
151
+ builder
152
+ .events(events)
153
+ .view(view)
154
+ .date(date)
155
+ .timezone('Europe/Vienna')
156
+ // Article 9: locale is a separate decision from timezone. Set it
157
+ // explicitly to silence the dev-mode "defaults are not decisions"
158
+ // warning. For multi-region apps, bind to your i18n source's
159
+ // current language (e.g. `useLocalization().language`).
160
+ .locale('en-US')
161
+ // Article 5: how to resolve drops landing in DST gaps / fall-back
162
+ // overlaps. `'compatible'` = silently shift forward / pick earlier
163
+ // (Temporal default). `'reject'` = drop is vetoed, snap-back fires.
164
+ // `'earlier'` / `'later'` = pick the corresponding instant on
165
+ // overlap. Whatever fires, `target.disambiguation` tells the
166
+ // consumer (see onEventDrop below).
167
+ .dstPolicy('compatible')
168
+ // The library doesn't mutate `events` itself — it emits `eventDrop`
169
+ // with the consumer's responsibility to apply the move. This handler
170
+ // patches the event in place so the move sticks (and so keyboard
171
+ // navigation, drag-and-drop, and resize handles all work in the
172
+ // demo). Cloning the array forces Vue to emit a fresh reference for
173
+ // downstream computeds (event-index, layout, etc.).
174
+ //
175
+ // `next.start` / `next.end` are Temporal values matching the source
176
+ // event's shape (PlainDate for all-day, ZonedDateTime for timed) —
177
+ // assign them straight back onto the event.
178
+ .onEventDrop(({ event, next, original, target }) => {
179
+ // Article 5: when the drop landed in a DST gap or fall-back
180
+ // overlap, `target.disambiguation` tells the consumer so they
181
+ // can show a toast / dialog. Default `dstPolicy('compatible')`
182
+ // resolves silently; consumers wanting different UX should set
183
+ // `.dstPolicy('reject')` (drop is vetoed) or read this flag.
184
+ if (target.disambiguation === 'gap') {
185
+ // eslint-disable-next-line no-console
186
+ console.info(
187
+ `[CalendarBasic] DST gap on ${target.date} — meeting was ` +
188
+ 'shifted forward to the next valid wall-clock minute.',
189
+ );
190
+ } else if (target.disambiguation === 'overlap') {
191
+ // eslint-disable-next-line no-console
192
+ console.info(
193
+ `[CalendarBasic] DST overlap on ${target.date} — meeting was ` +
194
+ "resolved per dstPolicy (default 'compatible' = earlier instant).",
195
+ );
196
+ }
197
+ // Article 3 / Article 9 — record the user's viewing context with
198
+ // the move so audit logs / undo stacks can replay in the right
199
+ // zone:
200
+ // - `original.displayZone` = zone the user was looking at when
201
+ // the drag started
202
+ // - `target.displayZone` = zone the drop snapped in (same
203
+ // unless the user toggled `.timezone()` mid-drag)
204
+ // Real apps push these into a `meta.audit = { from, at }` field
205
+ // or a separate undo/redo store.
206
+ pushUndoEntry({
207
+ eventId: event.id,
208
+ from: { start: original.start, end: original.end, displayZone: original.displayZone },
209
+ to: { start: next.start, end: next.end, displayZone: target.displayZone },
210
+ disambiguation: target.disambiguation,
211
+ });
212
+ const idx = events.value.findIndex((e) => e.id === event.id);
213
+ if (idx < 0) return;
214
+ events.value = [
215
+ ...events.value.slice(0, idx),
216
+ // Cross-zone events: each endpoint may live in its own
217
+ // `timeZoneId` — write both back unchanged from the payload,
218
+ // don't try to "normalise" them.
219
+ { ...event, start: next.start, ...(next.end ? { end: next.end } : {}) },
220
+ ...events.value.slice(idx + 1),
221
+ ];
222
+ });
223
+
224
+ // Tiny demo undo stack (in-memory; real apps use a proper store).
225
+ type UndoEntry = {
226
+ eventId: string;
227
+ from: {
228
+ start: Temporal.ZonedDateTime | Temporal.PlainDate;
229
+ end?: Temporal.ZonedDateTime | Temporal.PlainDate;
230
+ displayZone: string;
231
+ };
232
+ to: {
233
+ start: Temporal.ZonedDateTime | Temporal.PlainDate;
234
+ end?: Temporal.ZonedDateTime | Temporal.PlainDate;
235
+ displayZone: string;
236
+ };
237
+ disambiguation: null | 'gap' | 'overlap';
238
+ };
239
+ const undoStack: UndoEntry[] = [];
240
+ function pushUndoEntry(entry: UndoEntry): void {
241
+ undoStack.push(entry);
242
+ }
243
+ </script>
244
+ ```
245
+
246
+ > **Tip: Why a builder?**
247
+ >
248
+ > Mirrors the `CoarGridBuilder` pattern from `<CoarDataGrid>`. One chainable surface keeps the consumer code linear, makes optional features (renderers, loaders, handlers) easy to register in any order, and gives us a single `api` object for imperative control.
249
+
250
+ ## Time range / Working hours
251
+
252
+ Constrain the visible hour range in `day` / `week` views via `timeRange([startHour, endHour])` (24-hour). Default is `[0, 24]` (full day). Events outside the range are still rendered into the all-day band when applicable.
253
+
254
+ **Demo — `calendar/demos/CalendarWorkingHours.vue`**
255
+
256
+ ```vue
257
+ <template>
258
+ <div style="display: flex; flex-direction: column; gap: 12px;">
259
+ <label style="font-size: 13px; display: flex; gap: 8px; align-items: center;">
260
+ <input v-model="workingHoursOnly" type="checkbox" />
261
+ Working hours only (8 AM – 6 PM)
262
+ </label>
263
+ <div style="height: 500px; border: 1px solid var(--coar-border-neutral-tertiary); border-radius: var(--coar-radius-xs); overflow: hidden;">
264
+ <CoarCalendar :builder="builder" />
265
+ </div>
266
+ </div>
267
+ </template>
268
+
269
+ <script setup lang="ts">
270
+ import { computed, ref } from 'vue';
271
+ import {
272
+ CoarCalendar,
273
+ useCalendar,
274
+ Temporal,
275
+ type CalendarEvent,
276
+ type CalendarView,
277
+ } from '@cocoar/vue-calendar';
278
+
279
+ const view = ref<CalendarView>('week');
280
+ const date = ref(Temporal.PlainDate.from('2026-04-15'));
281
+ const workingHoursOnly = ref(true);
282
+ const timeRange = computed(() =>
283
+ workingHoursOnly.value
284
+ ? { startMinutes: 8 * 60, endMinutes: 18 * 60 }
285
+ : { startMinutes: 0, endMinutes: 24 * 60 },
286
+ );
287
+
288
+ const zdt = (iso: string, tz = 'Europe/Vienna') =>
289
+ Temporal.ZonedDateTime.from(`${iso}[${tz}]`);
290
+
291
+ const events = ref<CalendarEvent[]>([
292
+ {
293
+ id: 'standup',
294
+ start: zdt('2026-04-15T09:00:00'),
295
+ end: zdt('2026-04-15T09:30:00'),
296
+ meta: { title: 'Standup', color: '#10b981' },
297
+ },
298
+ {
299
+ id: 'design',
300
+ start: zdt('2026-04-15T11:00:00'),
301
+ end: zdt('2026-04-15T12:30:00'),
302
+ meta: { title: 'Design review', color: '#8b5cf6' },
303
+ },
304
+ {
305
+ id: 'demo',
306
+ start: zdt('2026-04-17T15:00:00'),
307
+ end: zdt('2026-04-17T16:30:00'),
308
+ meta: { title: 'Client demo', color: '#dc2626' },
309
+ },
310
+ ]);
311
+
312
+ const { builder } = useCalendar();
313
+ builder
314
+ .events(events)
315
+ .view(view)
316
+ .date(date)
317
+ .timezone('Europe/Vienna')
318
+ .timeRange(timeRange)
319
+ .onEventDrop(({ event, next }) => {
320
+ const idx = events.value.findIndex((e) => e.id === event.id);
321
+ if (idx < 0) return;
322
+ events.value = [
323
+ ...events.value.slice(0, idx),
324
+ { ...event, start: next.start, ...(next.end ? { end: next.end } : {}) },
325
+ ...events.value.slice(idx + 1),
326
+ ];
327
+ });
328
+ </script>
329
+ ```
330
+
331
+ ## Locale & first day of week
332
+
333
+ Pass any BCP-47 locale via `locale(...)`. Without it the calendar follows the host's `@cocoar/vue-localization` language, and `en-US` when no localization service is installed. The first day of week is auto-detected from the locale (`en-US` → Sunday, `de-AT` / `fr-FR` → Monday, `ja-JP` → Sunday) and used by the `month` and `week` view windows. Override with `firstDayOfWeek(0..6)` (0 = Sunday … 6 = Saturday) when needed. Range labels and weekday names use the same locale.
334
+
335
+ **Demo — `calendar/demos/CalendarLocale.vue`**
336
+
337
+ ```vue
338
+ <template>
339
+ <div style="display: flex; flex-direction: column; gap: 12px;">
340
+ <div style="display: flex; gap: 16px; align-items: center; font-size: 13px;">
341
+ <label style="display: flex; gap: 6px; align-items: center;">
342
+ Locale:
343
+ <select v-model="locale">
344
+ <option value="en-US">en-US</option>
345
+ <option value="de-AT">de-AT</option>
346
+ <option value="ja-JP">ja-JP</option>
347
+ <option value="fr-FR">fr-FR</option>
348
+ </select>
349
+ </label>
350
+ <span style="color: var(--coar-text-neutral-secondary);">
351
+ First-day-of-week is detected from the locale (en-US → Sun, de-AT → Mon, …).
352
+ </span>
353
+ </div>
354
+ <div style="height: 500px; border: 1px solid var(--coar-border-neutral-tertiary); border-radius: var(--coar-radius-xs); overflow: hidden;">
355
+ <CoarCalendar :builder="builder" />
356
+ </div>
357
+ </div>
358
+ </template>
359
+
360
+ <script setup lang="ts">
361
+ import { ref } from 'vue';
362
+ import {
363
+ CoarCalendar,
364
+ useCalendar,
365
+ Temporal,
366
+ type CalendarEvent,
367
+ type CalendarView,
368
+ } from '@cocoar/vue-calendar';
369
+
370
+ const view = ref<CalendarView>('month');
371
+ const date = ref(Temporal.PlainDate.from('2026-04-15'));
372
+ const locale = ref('en-US');
373
+
374
+ const events = ref<CalendarEvent[]>([
375
+ {
376
+ id: 'standup',
377
+ start: Temporal.ZonedDateTime.from('2026-04-15T09:00:00[Europe/Vienna]'),
378
+ end: Temporal.ZonedDateTime.from('2026-04-15T09:30:00[Europe/Vienna]'),
379
+ meta: { title: 'Standup', color: '#10b981' },
380
+ },
381
+ ]);
382
+
383
+ const { builder } = useCalendar();
384
+ builder
385
+ .events(events)
386
+ .view(view)
387
+ .date(date)
388
+ .locale(locale)
389
+ .timezone('Europe/Vienna')
390
+ .onEventDrop(({ event, next }) => {
391
+ const idx = events.value.findIndex((e) => e.id === event.id);
392
+ if (idx < 0) return;
393
+ events.value = [
394
+ ...events.value.slice(0, idx),
395
+ { ...event, start: next.start, ...(next.end ? { end: next.end } : {}) },
396
+ ...events.value.slice(idx + 1),
397
+ ];
398
+ });
399
+ </script>
400
+ ```
401
+
402
+ ## Display vs source zone
403
+
404
+ The calendar separates the **display zone** (where events are rendered on the grid) from each event's **source zone** (the zone the consumer captured when creating the event). This is the most common source of bugs when porting calendar code from string-based APIs, so it's worth being explicit:
405
+
406
+ - **`builder.timezone(tz)` is the DISPLAY zone.** Every column / row / hour-axis label resolves in this zone. Switching it (`builder.timezone('Asia/Tokyo')`) re-renders existing events at their Tokyo wall-clock time without mutating the events.
407
+
408
+ - **`event.start.timeZoneId` is the SOURCE zone** — what the human meant when they put the event on the calendar. A meeting in Vienna is `ZonedDateTime.from('2026-06-15T10:00:00[Europe/Vienna]')`, regardless of whose calendar it eventually lands on.
409
+
410
+ - **Cross-zone events are allowed.** `start.timeZoneId !== end.timeZoneId` is fine (e.g. a flight from Tokyo to Vienna is `start: …[Asia/Tokyo]`, `end: …[Europe/Vienna]`). The calendar renders both endpoints in the display zone using their underlying instant; the source zones are preserved on the event for round-tripping.
411
+
412
+ - **All-day events are intentionally zone-less.** A `PlainDate` carries no zone — that's the correct shape for a holiday, vacation, or anniversary. They appear on the calendar day with that name in every display zone, never shifting.
413
+
414
+ ```ts
415
+ // Same builder, two display zones — the underlying event is identical.
416
+ builder.events([
417
+ {
418
+ id: 'sync',
419
+ // SOURCE zone is Vienna — that's where the meeting was scheduled.
420
+ start: Temporal.ZonedDateTime.from('2026-06-15T10:00:00[Europe/Vienna]'),
421
+ end: Temporal.ZonedDateTime.from('2026-06-15T11:00:00[Europe/Vienna]'),
422
+ },
423
+ ]);
424
+
425
+ builder.timezone('Europe/Vienna'); // renders 10:00–11:00 on Mon Jun 15
426
+ builder.timezone('Asia/Tokyo'); // same event renders 17:00–18:00 on Mon Jun 15
427
+ builder.timezone('America/Los_Angeles'); // same event renders 01:00–02:00 on Mon Jun 15
428
+ ```
429
+
430
+ > **Tip: Where to set the display zone**
431
+ >
432
+ > Most apps want a single display zone matching the viewer's local zone (`Intl.DateTimeFormat().resolvedOptions().timeZone`). If you ship a "show in my zone" toggle, the only thing that needs to change is `builder.timezone(...)` — the events themselves stay put.
433
+
434
+ ## DST handling and the drop payload
435
+
436
+ Article 5 of the *Time in Software, Done Right* series demands that DST gaps and overlaps be handled **explicitly**. The library plumbs four policies through `.dstPolicy(...)`:
437
+
438
+ | Policy | Gap (e.g. 02:30 Vienna on spring-forward) | Overlap (e.g. 02:30 Vienna on fall-back) |
439
+ |---|---|---|
440
+ | `'compatible'` (default) | Shifts forward to the first valid minute | Picks the **earlier** instant |
441
+ | `'reject'` | Drop is vetoed — `canDrop=false`, snap-back fires | Same: vetoed |
442
+ | `'earlier'` | Same as compatible | Picks the earlier instant |
443
+ | `'later'` | Same as compatible | Picks the later instant |
444
+
445
+ Whatever policy fires, the resolved drop is reported to your `onEventDrop` handler with a `target.disambiguation` field — `'gap'`, `'overlap'`, or `null` for clean drops. **Consumer apps that want to surface DST resolutions should read this field** and show a toast / dialog accordingly:
446
+
447
+ ```ts
448
+ builder.onEventDrop(({ event, next, target }) => {
449
+ if (target.disambiguation === 'gap') {
450
+ showToast(`DST gap — your meeting was shifted to ${next.start}`);
451
+ } else if (target.disambiguation === 'overlap') {
452
+ showToast(`DST fall-back — using the ${dstPolicy === 'later' ? 'second' : 'first'} 02:30`);
453
+ }
454
+ // ...persist next.start / next.end on your event store
455
+ });
456
+ ```
457
+
458
+ Article 5 quote: *"You need to decide, and your code needs to handle it explicitly."* The lib gives you the explicit handle; ignoring it silently corrupts user expectations.
459
+
460
+ > **Tip: Cross-zone events keep their zones**
461
+ >
462
+ > `next.start.timeZoneId` and `next.end.timeZoneId` may differ — a Tokyo→Vienna flight stays Tokyo→Vienna across drag-and-drop. Persist both values verbatim; don't "normalise" to one zone.
463
+
464
+ > **Tip: Undo / audit log: persist `original.displayZone` and `target.displayZone`**
465
+ >
466
+ > The drop payload carries the **user's viewing context** in two places:
467
+ >
468
+ > - `original.displayZone` — the zone the calendar was rendering in when the drag started.
469
+ > - `target.displayZone` — the zone the drop snapped in (usually the same; differs only if the user toggled `.timezone()` mid-drag).
470
+ >
471
+ > Storing both alongside `next.start` / `next.end` lets you (a) replay the user's intent on undo without inheriting whatever zone they've toggled to since, and (b) write an audit log honest about "user moved this meeting AT 14:00 IN EUROPE/VIENNA". Article 3: deadlines are hard precisely because the wall-clock + the zone are inseparable. See `CalendarBasic.vue` demo for the canonical pattern.
472
+
473
+ ## Custom event rendering
474
+
475
+ Two ways to customise event rendering — pick whichever fits the consumer code better. **Slot wins over builder renderer** when both are present.
476
+
477
+ ### Template slot
478
+
479
+ The `#event` slot replaces the default event card. The slot receives `{ event, view, layout?, item? }` so you can render differently per view. The shell forwards it to every event-rendering sub-view (and uses it as a fallback for month pills / bars when no specific `#pill` / `#multiDayBar` slot is supplied).
480
+
481
+ **Demo — `calendar/demos/CalendarCustomEventSlot.vue`**
482
+
483
+ ```vue
484
+ <template>
485
+ <div style="height: 500px; border: 1px solid var(--coar-border-neutral-tertiary); border-radius: var(--coar-radius-xs); overflow: hidden;">
486
+ <CoarCalendar :builder="builder">
487
+ <template #event="{ event }">
488
+ <div class="custom-event">
489
+ <span class="custom-event__icon">{{ iconFor(event) }}</span>
490
+ <span class="custom-event__title">{{ titleOf(event) }}</span>
491
+ </div>
492
+ </template>
493
+ </CoarCalendar>
494
+ </div>
495
+ </template>
496
+
497
+ <script setup lang="ts">
498
+ import { ref } from 'vue';
499
+ import {
500
+ CoarCalendar,
501
+ useCalendar,
502
+ Temporal,
503
+ type CalendarEvent,
504
+ type CalendarView,
505
+ } from '@cocoar/vue-calendar';
506
+
507
+ interface MyMeta extends Record<string, unknown> {
508
+ title: string;
509
+ kind: 'meeting' | 'deepwork' | 'meal';
510
+ color: string;
511
+ }
512
+
513
+ const view = ref<CalendarView>('day');
514
+ const date = ref(Temporal.PlainDate.from('2026-04-15'));
515
+
516
+ const zdt = (iso: string, tz = 'Europe/Vienna') =>
517
+ Temporal.ZonedDateTime.from(`${iso}[${tz}]`);
518
+
519
+ const events = ref<CalendarEvent<MyMeta>[]>([
520
+ {
521
+ id: 'standup',
522
+ start: zdt('2026-04-15T09:00:00'),
523
+ end: zdt('2026-04-15T09:30:00'),
524
+ meta: { title: 'Standup', kind: 'meeting', color: '#10b981' },
525
+ },
526
+ {
527
+ id: 'deepwork',
528
+ start: zdt('2026-04-15T10:00:00'),
529
+ end: zdt('2026-04-15T12:00:00'),
530
+ meta: { title: 'Deep work', kind: 'deepwork', color: '#2563eb' },
531
+ },
532
+ {
533
+ id: 'lunch',
534
+ start: zdt('2026-04-15T12:00:00'),
535
+ end: zdt('2026-04-15T13:00:00'),
536
+ meta: { title: 'Lunch', kind: 'meal', color: '#ef4444' },
537
+ },
538
+ ]);
539
+
540
+ const { builder } = useCalendar<MyMeta>();
541
+ builder
542
+ .events(events)
543
+ .view(view)
544
+ .date(date)
545
+ .timezone('Europe/Vienna')
546
+ .onEventDrop(({ event, next }) => {
547
+ const idx = events.value.findIndex((e) => e.id === event.id);
548
+ if (idx < 0) return;
549
+ events.value = [
550
+ ...events.value.slice(0, idx),
551
+ { ...event, start: next.start, ...(next.end ? { end: next.end } : {}) },
552
+ ...events.value.slice(idx + 1),
553
+ ];
554
+ });
555
+
556
+ const ICONS: Record<MyMeta['kind'], string> = {
557
+ meeting: '👥',
558
+ deepwork: '🧠',
559
+ meal: '🥗',
560
+ };
561
+ function titleOf(e: CalendarEvent): string {
562
+ return (e.meta as MyMeta | undefined)?.title ?? e.id;
563
+ }
564
+ function iconFor(e: CalendarEvent): string {
565
+ const meta = e.meta as MyMeta | undefined;
566
+ return meta ? ICONS[meta.kind] : '•';
567
+ }
568
+ </script>
569
+
570
+ <style scoped>
571
+ .custom-event {
572
+ display: flex;
573
+ align-items: center;
574
+ gap: 6px;
575
+ padding: 4px 6px;
576
+ height: 100%;
577
+ font-size: 12px;
578
+ }
579
+ .custom-event__icon {
580
+ font-size: 14px;
581
+ line-height: 1;
582
+ }
583
+ .custom-event__title {
584
+ font-weight: 500;
585
+ white-space: nowrap;
586
+ overflow: hidden;
587
+ text-overflow: ellipsis;
588
+ }
589
+ </style>
590
+ ```
591
+
592
+ ### Builder renderer
593
+
594
+ `builder.eventRenderer(...)` accepts three forms — register once, applies everywhere a slot isn't already supplied:
595
+
596
+ ```ts
597
+ // (A) one component for all events
598
+ builder.eventRenderer(MyEventCard);
599
+
600
+ // (B) function returning a component — choose by event meta
601
+ builder.eventRenderer((ctx) =>
602
+ ctx.event.meta?.kind === 'meeting' ? MeetingCard : DefaultCard,
603
+ );
604
+
605
+ // (C) function returning a VNode — fully custom h() output
606
+ builder.eventRenderer((ctx) =>
607
+ h('div', { class: 'pill' }, ctx.event.id),
608
+ );
609
+ ```
610
+
611
+ ### Branching per layout variant
612
+
613
+ The same event can render in four very different visual shapes depending on the view it lands in:
614
+
615
+ | `ctx.layout.kind` | Where it appears | Visual |
616
+ |---|---|---|
617
+ | `'positioned'` | Day / Week time-grid | Card pinned to an hour, color-bar on the leading edge |
618
+ | `'allDayBar'` | Day / Week all-day band | Bar that spans multiple day-columns at the top of the time-grid |
619
+ | `'monthPill'` | Month cell (single-day event) | Rounded pill stacked inside the cell |
620
+ | `'monthBar'` | Month row (multi-day event) | Bar that spans across day-cells in a row |
621
+
622
+ `builder.eventRenderer((ctx) => …)` is invoked for **every** variant. Inspect `ctx.layout?.kind` and branch:
623
+
624
+ ```ts
625
+ builder.eventRenderer((ctx) => {
626
+ switch (ctx.layout?.kind) {
627
+ case 'monthPill':
628
+ return h('div', { class: 'fancy-pill' }, [
629
+ h('span', '🎉'),
630
+ h('span', ctx.event.meta.title),
631
+ ]);
632
+ case 'monthBar':
633
+ return h('div', { class: 'fancy-bar' }, ctx.event.meta.title);
634
+ case 'allDayBar':
635
+ return h('div', { class: 'fancy-allday' }, ctx.event.meta.title);
636
+ case 'positioned':
637
+ return h('div', { class: 'fancy-card' }, [
638
+ h('span', { class: 'time' }, ctx.event.start.toString()),
639
+ h('span', ctx.event.meta.title),
640
+ ]);
641
+ default:
642
+ // Agenda rows etc. — fall through to the lib's default by
643
+ // returning `undefined`.
644
+ return undefined;
645
+ }
646
+ });
647
+ ```
648
+
649
+ Returning `undefined` from any branch lets the lib fall back to its built-in default for that variant.
650
+
651
+ > **Tip: Template slot trumps the renderer**
652
+ >
653
+ > A `<template #event>` slot on `<CoarCalendar>` always wins over `builder.eventRenderer(...)`. Use the slot for the common case; reach for the renderer when you need a function (e.g. dispatch on `meta.kind` to pick a Vue component dynamically).
654
+
655
+ `dayHeaderRenderer` is the only other renderer setter — it controls the per-day column header in week / month views and has its own dedicated `ctx` shape (`{ date, isToday, isWeekend }`), so it's a separate setter rather than a branch on `ctx.layout.kind`.
656
+
657
+ **Demo — `calendar/demos/CalendarBuilderRenderer.vue`**
658
+
659
+ ```vue
660
+ <template>
661
+ <div style="height: 500px; border: 1px solid var(--coar-border-neutral-tertiary); border-radius: var(--coar-radius-xs); overflow: hidden;">
662
+ <CoarCalendar :builder="builder" />
663
+ </div>
664
+ </template>
665
+
666
+ <script setup lang="ts">
667
+ /**
668
+ * Builder-renderer demo.
669
+ *
670
+ * Same custom event shape as the slot demo — but registered via
671
+ * `builder.eventRenderer((ctx) => h(...))`. The renderer is a
672
+ * function returning a VNode, which gives full control over the
673
+ * rendered markup without writing a `<template #event>` inside
674
+ * the consumer template.
675
+ */
676
+
677
+ import { h, ref } from 'vue';
678
+ import {
679
+ CoarCalendar,
680
+ useCalendar,
681
+ Temporal,
682
+ type CalendarEvent,
683
+ type CalendarView,
684
+ } from '@cocoar/vue-calendar';
685
+
686
+ interface MyMeta extends Record<string, unknown> {
687
+ title: string;
688
+ kind: 'meeting' | 'deepwork' | 'meal';
689
+ color: string;
690
+ }
691
+
692
+ const view = ref<CalendarView>('day');
693
+ const date = ref(Temporal.PlainDate.from('2026-04-15'));
694
+
695
+ const zdt = (iso: string, tz = 'Europe/Vienna') =>
696
+ Temporal.ZonedDateTime.from(`${iso}[${tz}]`);
697
+
698
+ const events = ref<CalendarEvent<MyMeta>[]>([
699
+ {
700
+ id: 'standup',
701
+ start: zdt('2026-04-15T09:00:00'),
702
+ end: zdt('2026-04-15T09:30:00'),
703
+ meta: { title: 'Standup', kind: 'meeting', color: '#10b981' },
704
+ },
705
+ {
706
+ id: 'deepwork',
707
+ start: zdt('2026-04-15T10:00:00'),
708
+ end: zdt('2026-04-15T12:00:00'),
709
+ meta: { title: 'Deep work', kind: 'deepwork', color: '#2563eb' },
710
+ },
711
+ {
712
+ id: 'lunch',
713
+ start: zdt('2026-04-15T12:00:00'),
714
+ end: zdt('2026-04-15T13:00:00'),
715
+ meta: { title: 'Lunch', kind: 'meal', color: '#ef4444' },
716
+ },
717
+ ]);
718
+
719
+ const ICONS: Record<MyMeta['kind'], string> = {
720
+ meeting: '👥',
721
+ deepwork: '🧠',
722
+ meal: '🥗',
723
+ };
724
+
725
+ const { builder } = useCalendar<MyMeta>();
726
+ builder
727
+ .events(events)
728
+ .view(view)
729
+ .date(date)
730
+ .timezone('Europe/Vienna')
731
+ .onEventDrop(({ event, next }) => {
732
+ const idx = events.value.findIndex((e) => e.id === event.id);
733
+ if (idx < 0) return;
734
+ events.value = [
735
+ ...events.value.slice(0, idx),
736
+ { ...event, start: next.start, ...(next.end ? { end: next.end } : {}) },
737
+ ...events.value.slice(idx + 1),
738
+ ];
739
+ })
740
+ .eventRenderer((ctx) => {
741
+ const meta = ctx.event.meta;
742
+ if (!meta) return h('div', { class: 'br-event' }, ctx.event.id);
743
+ return h(
744
+ 'div',
745
+ {
746
+ class: 'br-event',
747
+ style: { borderLeftColor: meta.color },
748
+ },
749
+ [
750
+ h('span', { class: 'br-event__icon' }, ICONS[meta.kind]),
751
+ h('span', { class: 'br-event__title' }, meta.title),
752
+ ],
753
+ );
754
+ });
755
+ </script>
756
+
757
+ <style scoped>
758
+ .br-event {
759
+ display: flex;
760
+ align-items: center;
761
+ gap: 6px;
762
+ padding: 4px 6px;
763
+ height: 100%;
764
+ border-left: 3px solid currentColor;
765
+ font-size: 12px;
766
+ }
767
+ .br-event__icon {
768
+ font-size: 14px;
769
+ line-height: 1;
770
+ }
771
+ .br-event__title {
772
+ font-weight: 500;
773
+ white-space: nowrap;
774
+ overflow: hidden;
775
+ text-overflow: ellipsis;
776
+ }
777
+ </style>
778
+ ```
779
+
780
+ ## Loading events on-demand
781
+
782
+ Backends with thousands of events benefit from loader mode — give the builder an async function and the calendar requests events for the visible window only, with caching + debouncing built in:
783
+
784
+ ```ts
785
+ builder.eventsLoader(async (window) => {
786
+ const res = await fetch(`/api/events?from=${window.start}&to=${window.end}`);
787
+ return res.json();
788
+ });
789
+ ```
790
+
791
+ The calendar:
792
+
793
+ - Watches the visible window and calls the loader whenever it changes.
794
+ - Debounces rapid view-nav (50 ms) — clicking next / prev / today in quick succession fires a single fetch for the window the user lands on.
795
+ - Caches results per `${view}|${timezone}|${start}|${end}` key. Going back to a previously-loaded window is instant. The display timezone is part of the key so a `.timezone()` toggle re-fetches (events near local midnight differ across zones).
796
+ - Tracks in-flight count via `api.loading` (a readonly `Ref<boolean>`).
797
+ - Exposes `api.refresh()` to invalidate the entire cache, and `api.refreshRange(start, end)` to invalidate only the window(s) that intersect a date range.
798
+
799
+ `events()` and `eventsLoader()` are mutually exclusive — calling one drops the other.
800
+
801
+ **Demo — `calendar/demos/CalendarEventsLoader.vue`**
802
+
803
+ ```vue
804
+ <template>
805
+ <div style="display: flex; flex-direction: column; gap: 8px;">
806
+ <div style="display: flex; gap: 8px; align-items: center; font-size: 13px;">
807
+ <span :class="['loader-pill', { 'loader-pill--active': loading }]">
808
+ {{ loading ? 'Loading…' : 'Idle' }}
809
+ </span>
810
+ <CoarButton variant="secondary" size="s" @click="api.refresh()">
811
+ Refresh
812
+ </CoarButton>
813
+ <span style="color: var(--coar-text-neutral-secondary);">
814
+ Fetch count: {{ fetchCount }} (rapid prev/next coalesces into one fetch)
815
+ </span>
816
+ </div>
817
+ <div style="height: 480px; border: 1px solid var(--coar-border-neutral-tertiary); border-radius: var(--coar-radius-xs); overflow: hidden;">
818
+ <CoarCalendar :builder="builder" />
819
+ </div>
820
+ </div>
821
+ </template>
822
+
823
+ <script setup lang="ts">
824
+ /**
825
+ * Loader demo.
826
+ *
827
+ * `builder.eventsLoader(window => fetch(window))` lets the calendar
828
+ * pull events for the visible window only. Rapid view-nav debounces
829
+ * into a single fetch; results are cached per-window so revisiting
830
+ * a previously-loaded window is instant. `api.loading` toggles
831
+ * around the in-flight promise; `api.refresh()` invalidates the
832
+ * cache and refetches.
833
+ *
834
+ * The mock loader below sleeps for 300 ms then returns synthesised
835
+ * events for the window, so the loading state is observable.
836
+ *
837
+ * Returned events use the article-4 typed shape — `ZonedDateTime`
838
+ * for timed entries (and `PlainDate` for all-day, if any).
839
+ */
840
+
841
+ import { ref } from 'vue';
842
+ import { CoarButton } from '@cocoar/vue-ui';
843
+ import {
844
+ CoarCalendar,
845
+ useCalendar,
846
+ Temporal,
847
+ type CalendarEvent,
848
+ type CalendarView,
849
+ } from '@cocoar/vue-calendar';
850
+
851
+ const view = ref<CalendarView>('week');
852
+ const date = ref(Temporal.PlainDate.from('2026-04-15'));
853
+ const fetchCount = ref(0);
854
+
855
+ function sleep(ms: number) {
856
+ return new Promise<void>((res) => setTimeout(res, ms));
857
+ }
858
+
859
+ const zdt = (iso: string, tz = 'Europe/Vienna') =>
860
+ Temporal.ZonedDateTime.from(`${iso}[${tz}]`);
861
+
862
+ async function fakeBackendLoad(start: string, end: string): Promise<CalendarEvent[]> {
863
+ await sleep(300);
864
+ // Walk the date range using Temporal — `PlainDate.add({days:1})` is
865
+ // calendar-correct (no DST drift, no UTC tax). The lib's article
866
+ // series exists to kill `new Date() + 86_400_000` math; the demos
867
+ // shouldn't teach it.
868
+ const out: CalendarEvent[] = [];
869
+ let cursor = Temporal.PlainDate.from(start);
870
+ const stop = Temporal.PlainDate.from(end);
871
+ while (Temporal.PlainDate.compare(cursor, stop) < 0) {
872
+ // Temporal: dayOfWeek 1=Mon … 7=Sun. Mon-Fri = 1..5.
873
+ if (cursor.dayOfWeek >= 1 && cursor.dayOfWeek <= 5) {
874
+ const iso = cursor.toString();
875
+ out.push({
876
+ id: `loaded-${iso}`,
877
+ start: zdt(`${iso}T11:00:00`),
878
+ end: zdt(`${iso}T12:00:00`),
879
+ meta: { title: `Loaded for ${iso}`, color: '#7c3aed' },
880
+ });
881
+ }
882
+ cursor = cursor.add({ days: 1 });
883
+ }
884
+ return out;
885
+ }
886
+
887
+ const { builder, api } = useCalendar();
888
+ builder
889
+ .view(view)
890
+ .date(date)
891
+ .timezone('Europe/Vienna')
892
+ .eventsLoader(async (window) => {
893
+ fetchCount.value++;
894
+ return fakeBackendLoad(window.start, window.end);
895
+ });
896
+
897
+ const loading = api.loading;
898
+ </script>
899
+
900
+ <style scoped>
901
+ .loader-pill {
902
+ padding: 2px 8px;
903
+ border-radius: 9999px;
904
+ font-size: 12px;
905
+ background: var(--coar-background-neutral-secondary);
906
+ color: var(--coar-text-neutral-secondary);
907
+ font-variant-numeric: tabular-nums;
908
+ }
909
+ .loader-pill--active {
910
+ background: var(--coar-color-accent, #2563eb);
911
+ color: #fff;
912
+ }
913
+ </style>
914
+ ```
915
+
916
+ ## Recurring events
917
+
918
+ `@cocoar/vue-calendar` expands recurring series at the visible-window boundary — the engine never sees occurrences outside the current view, so a series with `RRULE:FREQ=DAILY` from year 2000 doesn't pay 25 years of expansion cost when you mount the calendar today. Two source modes mirror non-recurring events:
919
+
920
+ ```ts
921
+ import { Temporal } from '@js-temporal/polyfill';
922
+ import type { RecurringSeries } from '@cocoar/vue-calendar';
923
+
924
+ const series = ref<RecurringSeries[]>([
925
+ {
926
+ id: 'standup',
927
+ rrule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR',
928
+ dtstart: Temporal.ZonedDateTime.from('2026-06-01T09:00:00[Europe/Vienna]'),
929
+ duration: { minutes: 30 },
930
+ meta: { title: 'Standup', color: '#4f46e5' },
931
+ },
932
+ {
933
+ id: 'public-holiday',
934
+ rrule: 'FREQ=YEARLY;BYMONTH=8;BYMONTHDAY=15',
935
+ // All-day series — `dtstart` is a `PlainDate`, not a `ZonedDateTime`.
936
+ dtstart: Temporal.PlainDate.from('2026-08-15'),
937
+ meta: { title: 'Mariä Himmelfahrt' },
938
+ },
939
+ ]);
940
+
941
+ builder.series(series); // reactive; mutating the ref re-expands
942
+ ```
943
+
944
+ For backend-managed series, use the loader form — the calendar fetches once per visible window, results cached the same way as `eventsLoader`:
945
+
946
+ ```ts
947
+ builder.seriesLoader(async (window) => {
948
+ const res = await fetch(`/api/series?from=${window.start}&to=${window.end}`);
949
+ return res.json();
950
+ });
951
+ ```
952
+
953
+ `series()` and `seriesLoader()` are mutually exclusive but both compose with `events()` / `eventsLoader()` — `getVisibleEvents()` returns the merged set.
954
+
955
+ ### What ships in the wire
956
+
957
+ `RecurringSeries` is the public type (in `@cocoar/vue-calendar`):
958
+
959
+ ```ts
960
+ interface RecurringSeries<TMeta = Record<string, unknown>> {
961
+ id: string; // stable series identifier
962
+ rrule: string; // RFC 5545 RRULE, e.g. 'FREQ=WEEKLY;BYDAY=MO'
963
+ dtstart:
964
+ | Temporal.ZonedDateTime // timed series — local + IANA zone
965
+ | Temporal.PlainDate; // all-day series
966
+ duration?: { minutes?: number; hours?: number; days?: number };
967
+ rdate?: ReadonlyArray<Temporal.ZonedDateTime | Temporal.PlainDate>;
968
+ exdate?: ReadonlyArray<Temporal.ZonedDateTime | Temporal.PlainDate>;
969
+ meta?: TMeta;
970
+ }
971
+ ```
972
+
973
+ The Temporal-typed `dtstart` is a non-negotiable: ISO strings, native `Date`, and floating `Temporal.PlainDateTime` are rejected at the boundary. Article 4 — store intent (local time + IANA zone), derive instants. The same rule applies to `rdate` and `exdate`: every entry's `timeZoneId` is preserved to the output, so a series in Tokyo with an RDATE in Vienna keeps both zones.
974
+
975
+ Each expanded `CalendarEvent` carries provenance under `meta.__recurrence`:
976
+
977
+ ```ts
978
+ import { getRecurrenceMeta } from '@cocoar/vue-calendar/recurrence';
979
+
980
+ builder.onEventClick(({ event }) => {
981
+ const meta = getRecurrenceMeta(event);
982
+ if (meta) {
983
+ console.log(meta.seriesId); // 'standup'
984
+ console.log(meta.recurrenceId); // ZonedDateTime — the original wall-time slot
985
+ console.log(meta.source); // 'rrule' | 'rdate'
986
+ }
987
+ });
988
+ ```
989
+
990
+ `event.id` is a unique synthetic value of shape `${seriesId}__${recurrenceId}` — the layout pipeline dedupes by id, so series identity lives in the provenance accessor, not on the event id directly. `recurrenceId` matches RFC 5545 RECURRENCE-ID semantics (the original slot) and is the stable boundary for occurrence-scoped editing.
991
+
992
+ ### Editing a recurring occurrence
993
+
994
+ The calendar owns expansion and provenance; the host application owns persistence. When an occurrence is clicked, dragged, resized, or deleted, use `getRecurrenceMeta(event)` to offer the familiar scopes:
995
+
996
+ | Scope | Persistence operation |
997
+ |---|---|
998
+ | This occurrence | Add `recurrenceId` to the source series' EXDATE set and, for a move/edit, persist one standalone event at the new date/time. |
999
+ | This and following | Split the RRULE immediately before `recurrenceId`, then persist the following part as a new series whose DTSTART contains the edit or move. |
1000
+ | Entire series | Update the original series and shift its DTSTART / RDATE / EXDATE values losslessly. |
1001
+
1002
+ The library deliberately does not guess a backend wire shape or perform those writes. It guarantees that every occurrence handler receives the original series id, RFC recurrence id, and source (`rrule` or `rdate`) needed to implement all three operations without identifying an occurrence by its rendered position.
1003
+
1004
+ ### Standalone expansion
1005
+
1006
+ `expandSeries(...)` is exported from a subpath so apps that don't use the builder still avoid pulling the engine into their main bundle:
1007
+
1008
+ ```ts
1009
+ import { expandSeries } from '@cocoar/vue-calendar/recurrence';
1010
+ import { Temporal } from '@js-temporal/polyfill';
1011
+
1012
+ const occurrences = await expandSeries(
1013
+ series,
1014
+ {
1015
+ start: Temporal.ZonedDateTime.from('2026-06-01T00:00:00[Europe/Vienna]'),
1016
+ end: Temporal.ZonedDateTime.from('2026-07-01T00:00:00[Europe/Vienna]'),
1017
+ },
1018
+ 'compatible', // DstPolicy — same union as builder.dstPolicy(...)
1019
+ /* engine? optional */ // defaults to lazy-loaded rrule-temporal adapter
1020
+ );
1021
+ ```
1022
+
1023
+ ### Custom engines
1024
+
1025
+ The calendar ships one bundled engine — a `rrule-temporal` adapter at the `@cocoar/vue-calendar/recurrence-rrule-temporal` subpath, lazy-loaded on first call. Apps with extreme volume or specialized needs (server-side pre-expansion, alternative parsers) implement the `RecurrenceEngine` interface in their own code:
1026
+
1027
+ ```ts
1028
+ import type { RecurrenceEngine } from '@cocoar/vue-calendar/recurrence';
1029
+
1030
+ const myEngine: RecurrenceEngine = {
1031
+ async expand(request) {
1032
+ // request.window.{startMs, endMs}
1033
+ // request.series — the typed wire shape (no string roundtrips)
1034
+ // …
1035
+ return { results, errors };
1036
+ },
1037
+ };
1038
+
1039
+ builder.recurrenceEngine(myEngine);
1040
+ // or, SSR-friendly factory form:
1041
+ builder.recurrenceEngine(() => new MyEngine());
1042
+ ```
1043
+
1044
+ Engine-swap invariance is enforced by the library: every occurrence is re-resolved from intended wallclock + source zone + `DstPolicy` after the engine returns, so observable output depends only on the contract inputs, never on which engine ran underneath.
1045
+
1046
+ **Demo — `calendar/demos/CalendarRecurrence.vue`**
1047
+
1048
+ ```vue
1049
+ <template>
1050
+ <div style="display: flex; flex-direction: column; gap: 8px;">
1051
+ <div style="display: flex; gap: 8px; align-items: center; font-size: 13px; flex-wrap: wrap;">
1052
+ <label style="display: inline-flex; gap: 4px; align-items: center;">
1053
+ DST policy:
1054
+ <select v-model="dstPolicyValue" style="padding: 2px 6px;">
1055
+ <option value="compatible">compatible</option>
1056
+ <option value="reject">reject</option>
1057
+ <option value="earlier">earlier</option>
1058
+ <option value="later">later</option>
1059
+ </select>
1060
+ </label>
1061
+ <CoarButton variant="secondary" size="s" @click="addExtraSeries">
1062
+ + Add series
1063
+ </CoarButton>
1064
+ <CoarButton variant="secondary" size="s" @click="resetSeries">
1065
+ Reset
1066
+ </CoarButton>
1067
+ <span style="color: var(--coar-text-neutral-secondary);">
1068
+ Visible: {{ visibleCount }} (recurring: {{ recurringCount }})
1069
+ </span>
1070
+ </div>
1071
+ <div
1072
+ style="height: 480px; border: 1px solid var(--coar-border-neutral-tertiary); border-radius: var(--coar-radius-xs); overflow: hidden;"
1073
+ >
1074
+ <CoarCalendar :builder="builder" />
1075
+ </div>
1076
+ <div
1077
+ v-if="lastClick"
1078
+ style="font-family: monospace; font-size: 11px; color: var(--coar-text-neutral-secondary);"
1079
+ >
1080
+ {{ lastClick }}
1081
+ </div>
1082
+ </div>
1083
+ </template>
1084
+
1085
+ <script setup lang="ts">
1086
+ /**
1087
+ * Recurrence demo.
1088
+ *
1089
+ * `builder.series([...])` for in-memory recurring series. Reactive —
1090
+ * mutating the source ref re-expands. Composes with one-off events
1091
+ * via `builder.events([...])`. `dstPolicy` applied uniformly to
1092
+ * every occurrence via the post-processing layer; engine swap never
1093
+ * changes observable semantics. Click an event to read its
1094
+ * `__recurrence` provenance.
1095
+ */
1096
+
1097
+ import { computed, ref } from 'vue';
1098
+ import { CoarButton } from '@cocoar/vue-ui';
1099
+ import {
1100
+ CoarCalendar,
1101
+ Temporal,
1102
+ useCalendar,
1103
+ type CalendarEvent,
1104
+ type CalendarView,
1105
+ type DstPolicy,
1106
+ type RecurringSeries,
1107
+ } from '@cocoar/vue-calendar';
1108
+ import { getRecurrenceMeta } from '@cocoar/vue-calendar/recurrence';
1109
+
1110
+ const view = ref<CalendarView>('month');
1111
+ const cursor = ref(Temporal.PlainDate.from('2026-06-15'));
1112
+ const dstPolicyValue = ref<DstPolicy>('compatible');
1113
+ const lastClick = ref<string>('');
1114
+
1115
+ const standup: RecurringSeries = {
1116
+ id: 'standup',
1117
+ rrule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR',
1118
+ dtstart: Temporal.ZonedDateTime.from(
1119
+ '2026-06-01T09:00:00[Europe/Vienna]',
1120
+ ),
1121
+ duration: { minutes: 30 },
1122
+ meta: { title: 'Standup', color: '#4f46e5' },
1123
+ };
1124
+ const sprintReview: RecurringSeries = {
1125
+ id: 'sprint-review',
1126
+ rrule: 'FREQ=WEEKLY;INTERVAL=2;BYDAY=FR',
1127
+ dtstart: Temporal.ZonedDateTime.from(
1128
+ '2026-06-05T15:00:00[Europe/Vienna]',
1129
+ ),
1130
+ duration: { hours: 1 },
1131
+ meta: { title: 'Sprint Review', color: '#06b6d4' },
1132
+ };
1133
+ const oneOff: CalendarEvent = {
1134
+ id: 'kickoff',
1135
+ start: Temporal.ZonedDateTime.from('2026-06-08T11:00:00[Europe/Vienna]'),
1136
+ end: Temporal.ZonedDateTime.from('2026-06-08T12:30:00[Europe/Vienna]'),
1137
+ meta: { title: 'Project Kickoff', color: '#f59e0b' },
1138
+ };
1139
+
1140
+ const seriesSource = ref<RecurringSeries[]>([standup, sprintReview]);
1141
+
1142
+ const { builder, api } = useCalendar();
1143
+ builder
1144
+ .view(view)
1145
+ .date(cursor)
1146
+ .timezone('Europe/Vienna')
1147
+ .locale('de-AT')
1148
+ .firstDayOfWeek(1)
1149
+ .events([oneOff])
1150
+ .series(seriesSource)
1151
+ .dstPolicy(dstPolicyValue)
1152
+ .onEventClick(({ event }) => {
1153
+ const title = (event.meta as { title?: string } | undefined)?.title ?? event.id;
1154
+ const meta = getRecurrenceMeta(event);
1155
+ lastClick.value = meta
1156
+ ? `${title} — series=${meta.seriesId}, source=${meta.source}, recurrenceId=${meta.recurrenceId.toString()}`
1157
+ : `${title} (one-off)`;
1158
+ });
1159
+
1160
+ function addExtraSeries() {
1161
+ const id = `extra-${seriesSource.value.length}`;
1162
+ seriesSource.value = [
1163
+ ...seriesSource.value,
1164
+ {
1165
+ id,
1166
+ rrule: 'FREQ=DAILY;COUNT=3',
1167
+ dtstart: Temporal.ZonedDateTime.from(
1168
+ '2026-06-10T13:00:00[Europe/Vienna]',
1169
+ ),
1170
+ duration: { minutes: 45 },
1171
+ meta: { title: `Extra ${id}`, color: '#ec4899' },
1172
+ },
1173
+ ];
1174
+ }
1175
+
1176
+ function resetSeries() {
1177
+ seriesSource.value = [standup, sprintReview];
1178
+ lastClick.value = '';
1179
+ }
1180
+
1181
+ const visibleCount = computed(() => api.getVisibleEvents().length);
1182
+ const recurringCount = computed(
1183
+ () =>
1184
+ api.getVisibleEvents().filter((e) => getRecurrenceMeta(e) !== null).length,
1185
+ );
1186
+ </script>
1187
+ ```
1188
+
1189
+ ## Create and edit integration
1190
+
1191
+ The calendar owns interaction geometry; the host application owns the editing experience and persistence. There is deliberately no fixed create/edit overlay because web consumers may want a popover, modal, side panel, routed page, or a completely custom view. The flat builder exposes the same integration seam for each choice:
1192
+
1193
+ ```ts
1194
+ builder
1195
+ .onDateClick(({ date }) => selectDay(date))
1196
+ .onTimeClick(({ date, time }) => selectSlot(date, time))
1197
+ .onDateDoubleClick(({ date, native }) => openCreate({ date, anchor: native.currentTarget }))
1198
+ .onTimeDoubleClick(({ date, time, native }) =>
1199
+ openCreate({ date, time, anchor: native.currentTarget }),
1200
+ )
1201
+ .onEventClick(({ event, native }) => openDetails(event, native.currentTarget))
1202
+ .onEventDoubleClick(({ event }) => openEditModal(event))
1203
+ .onEventDrop(({ event, next }) => persistMove(event.id, next));
1204
+ ```
1205
+
1206
+ Single-click and double-click on an empty cell or slot are separate hooks. The desktop convention "click selects, double-click creates" (Apple Calendar, Outlook) therefore needs no click-timer on the host side, and the Google-style "click opens a popup" model is one handler away. The single-click hooks still fire for the two clicks that precede a double-click. A double-click on an event element stops at the event and reaches `onEventDoubleClick` only — never the date / time hooks. `onTimeDoubleClick` snaps to the slot grid exactly like `onTimeClick`.
1207
+
1208
+ `native.currentTarget` is the rendered calendar element and can be passed directly to `@cocoar/vue-ui`'s overlay service. Applications using a modal or routed editor can ignore it. For recurring events, call `getRecurrenceMeta(event)` before opening the editor so the host can offer This occurrence / This and following / Entire series; the calendar supplies stable occurrence identity but never guesses the backend mutation.
1209
+
1210
+ ## Popovers and tooltips
1211
+
1212
+ The library deliberately doesn't ship a built-in popover — every app wants different content (title-only tooltip vs full action menu vs edit-in-place panel). What it does ship are two handlers that surface the timing + anchor element so consumer code can wire `useOverlay()` (from `@cocoar/vue-ui`) into events:
1213
+
1214
+ ```ts
1215
+ import { useOverlay, popoverPreset, type OverlayRef } from '@cocoar/vue-ui';
1216
+
1217
+ const overlay = useOverlay();
1218
+ const activeOverlay = ref<OverlayRef | null>(null);
1219
+
1220
+ builder
1221
+ .onEventHover(({ event, native }) => {
1222
+ activeOverlay.value?.close(); // close previous first
1223
+ activeOverlay.value = overlay.open({
1224
+ spec: {
1225
+ ...popoverPreset,
1226
+ anchor: { kind: 'element', element: native.currentTarget as Element },
1227
+ },
1228
+ content: { kind: 'component', component: MyEventPopover },
1229
+ inputs: { event },
1230
+ });
1231
+ })
1232
+ .onEventHoverLeave(() => {
1233
+ activeOverlay.value?.close();
1234
+ activeOverlay.value = null;
1235
+ });
1236
+ ```
1237
+
1238
+ `native.currentTarget` is the event-element DOM node — pass it straight to the overlay's anchor spec. The same pattern works for click-driven popovers (use `onEventClick`) and double-click triggers (`onEventDoubleClick`). No hover delay is applied; wrap the open in `setTimeout(..., 200)` if you want one. For touch / pen pointers, `pointerenter` fires on press — handler doubles as a long-press surface on tablets when paired with a delay.
1239
+
1240
+ Handlers fire across every event-rendering view at the same DOM elements that handle click. The library never opens an overlay itself — the entire interaction lifecycle (open / close / outside-click / escape / scroll-strategy) lives in consumer code via the overlay spec.
1241
+
1242
+ **Demo — `calendar/demos/CalendarPopover.vue`**
1243
+
1244
+ ```vue
1245
+ <template>
1246
+ <div style="display: flex; flex-direction: column; gap: 8px;">
1247
+ <div style="font-family: monospace; font-size: 11px; color: var(--coar-text-neutral-secondary);">
1248
+ Currently hovered: <strong>{{ hoveredEventId ?? '(none)' }}</strong>
1249
+ </div>
1250
+ <div style="height: 520px; border: 1px solid var(--coar-border-neutral-tertiary); border-radius: var(--coar-radius-xs); overflow: hidden;">
1251
+ <CoarCalendar :builder="builder" />
1252
+ </div>
1253
+ </div>
1254
+ </template>
1255
+
1256
+ <script setup lang="ts">
1257
+ /**
1258
+ * Wires `builder.onEventHover` / `onEventHoverLeave` to
1259
+ * `useOverlay()` from `@cocoar/vue-ui` so events get a hover popover
1260
+ * with consumer-defined content. The lib doesn't ship a built-in
1261
+ * popover — different apps want different content (title + time,
1262
+ * action buttons, edit-in-place, full preview). The handlers
1263
+ * surface the timing + anchor element; consumer composes the rest.
1264
+ *
1265
+ * Pattern:
1266
+ * 1. `useOverlay()` to get the overlay service.
1267
+ * 2. Store the active `OverlayRef` in a local ref.
1268
+ * 3. `onEventHover` opens (closing previous first — pointer can
1269
+ * enter event B before leaving event A's bubble area).
1270
+ * 4. `onEventHoverLeave` closes.
1271
+ *
1272
+ * No hover delay applied — wrap the open call in `setTimeout` if a
1273
+ * delay is wanted. For touch / pen pointers, `pointerenter` fires
1274
+ * on press, so this doubles as a long-press surface on tablets when
1275
+ * paired with a delay.
1276
+ */
1277
+
1278
+ import { h, markRaw, ref } from 'vue';
1279
+ import { useOverlay, popoverPreset, type OverlayRef } from '@cocoar/vue-ui';
1280
+ import {
1281
+ CoarCalendar,
1282
+ Temporal,
1283
+ useCalendar,
1284
+ type CalendarEvent,
1285
+ } from '@cocoar/vue-calendar';
1286
+
1287
+ // Consumer-defined popover content. Receives the hovered event via
1288
+ // `inputs`. Replace this with whatever fits your domain — action
1289
+ // buttons, descriptions, status pills, edit forms…
1290
+ const EventPopover = markRaw({
1291
+ name: 'EventPopover',
1292
+ props: {
1293
+ event: { type: Object, required: true },
1294
+ },
1295
+ setup(props: { event: CalendarEvent }) {
1296
+ return () =>
1297
+ h(
1298
+ 'div',
1299
+ {
1300
+ style: {
1301
+ padding: '12px 16px',
1302
+ background: 'var(--coar-background-neutral-primary, white)',
1303
+ border: '1px solid var(--coar-border-neutral-tertiary, #e5e7eb)',
1304
+ borderRadius: '8px',
1305
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
1306
+ fontFamily: 'var(--coar-body-base-family)',
1307
+ fontSize: '13px',
1308
+ minWidth: '200px',
1309
+ maxWidth: '320px',
1310
+ },
1311
+ },
1312
+ [
1313
+ h(
1314
+ 'div',
1315
+ { style: { fontWeight: 600, marginBottom: '4px' } },
1316
+ (props.event.meta as { title?: string } | undefined)?.title ??
1317
+ props.event.id,
1318
+ ),
1319
+ h(
1320
+ 'div',
1321
+ { style: { color: 'var(--coar-text-neutral-secondary, #666)', fontSize: '12px' } },
1322
+ formatEventTime(props.event),
1323
+ ),
1324
+ ],
1325
+ );
1326
+ },
1327
+ });
1328
+
1329
+ function formatEventTime(event: CalendarEvent): string {
1330
+ if (event.start instanceof Temporal.ZonedDateTime) {
1331
+ const start = event.start.toString().slice(0, 16).replace('T', ' ');
1332
+ if (event.end instanceof Temporal.ZonedDateTime) {
1333
+ return `${start} – ${event.end.toString().slice(11, 16)}`;
1334
+ }
1335
+ return start;
1336
+ }
1337
+ if (event.end instanceof Temporal.PlainDate) {
1338
+ return `${event.start.toString()} – ${event.end.toString()}`;
1339
+ }
1340
+ return event.start.toString();
1341
+ }
1342
+
1343
+ const events = ref<CalendarEvent[]>([
1344
+ {
1345
+ id: 'standup',
1346
+ start: Temporal.ZonedDateTime.from('2026-06-15T09:00:00[Europe/Vienna]'),
1347
+ end: Temporal.ZonedDateTime.from('2026-06-15T09:30:00[Europe/Vienna]'),
1348
+ meta: { title: 'Daily Standup', color: '#4f46e5' },
1349
+ },
1350
+ {
1351
+ id: 'design-review',
1352
+ start: Temporal.ZonedDateTime.from('2026-06-16T14:00:00[Europe/Vienna]'),
1353
+ end: Temporal.ZonedDateTime.from('2026-06-16T15:30:00[Europe/Vienna]'),
1354
+ meta: { title: 'Design Review', color: '#06b6d4' },
1355
+ },
1356
+ {
1357
+ id: 'lunch',
1358
+ start: Temporal.ZonedDateTime.from('2026-06-17T12:00:00[Europe/Vienna]'),
1359
+ end: Temporal.ZonedDateTime.from('2026-06-17T13:00:00[Europe/Vienna]'),
1360
+ meta: { title: 'Lunch with Anna', color: '#f59e0b' },
1361
+ },
1362
+ {
1363
+ id: 'vacation',
1364
+ start: Temporal.PlainDate.from('2026-06-22'),
1365
+ end: Temporal.PlainDate.from('2026-06-27'),
1366
+ meta: { title: 'Vacation', color: '#10b981' },
1367
+ },
1368
+ ]);
1369
+
1370
+ const { builder } = useCalendar();
1371
+ const overlay = useOverlay();
1372
+ const activeOverlay = ref<OverlayRef | null>(null);
1373
+ const hoveredEventId = ref<string | null>(null);
1374
+
1375
+ builder
1376
+ .events(events)
1377
+ .timezone('Europe/Vienna')
1378
+ .locale('de-AT')
1379
+ .firstDayOfWeek(1)
1380
+ .view('week')
1381
+ .date(Temporal.PlainDate.from('2026-06-15'))
1382
+ .onEventHover(({ event, native }) => {
1383
+ activeOverlay.value?.close();
1384
+ hoveredEventId.value = event.id;
1385
+ activeOverlay.value = overlay.open({
1386
+ spec: {
1387
+ ...popoverPreset,
1388
+ anchor: {
1389
+ kind: 'element',
1390
+ element: native.currentTarget as Element,
1391
+ },
1392
+ },
1393
+ content: { kind: 'component', component: EventPopover },
1394
+ inputs: { event },
1395
+ });
1396
+ })
1397
+ .onEventHoverLeave(() => {
1398
+ activeOverlay.value?.close();
1399
+ activeOverlay.value = null;
1400
+ hoveredEventId.value = null;
1401
+ });
1402
+ </script>
1403
+ ```
1404
+
1405
+ ## Imperative API
1406
+
1407
+ The builder exposes an `api` object — same shape regardless of whether the calendar component has mounted yet. Stash it from `useCalendar()` and call methods directly:
1408
+
1409
+ ```ts
1410
+ const { builder, api } = useCalendar();
1411
+
1412
+ api.next(); // ±1 view-page
1413
+ api.prev();
1414
+ api.goToToday();
1415
+ api.goTo('2026-12-25');
1416
+ api.setView('month');
1417
+ api.setMonthDensity('compact');
1418
+ api.setDayMode('multiDay');
1419
+ api.scrollToTime(8); // day / week only
1420
+ api.scrollToDate('2026-04-15'); // agenda only
1421
+ api.getVisibleRange(); // ViewWindow | null
1422
+ api.getVisibleEvents(); // events touching the current window
1423
+ api.refresh(); // re-run the loader for the current window
1424
+ api.refreshRange(start, end); // invalidate intersecting cache entries
1425
+
1426
+ watch(api.loading, (b) => console.log('loading?', b));
1427
+ watch(api.visibleRange, (w) => console.log('window changed', w));
1428
+ api.rangeLabel.value; // "15.–21. Juni 2026" — the header's title, for your own header
1429
+ api.topmostVisibleMonth.value; // Month view: the month at the top while scrolling (cursor follows on settle)
1430
+ ```
1431
+
1432
+ **Demo — `calendar/demos/CalendarImperativeApi.vue`**
1433
+
1434
+ ```vue
1435
+ <template>
1436
+ <div style="display: flex; flex-direction: column; gap: 12px;">
1437
+ <div style="display: flex; gap: 8px; flex-wrap: wrap;">
1438
+ <CoarButton variant="secondary" size="s" @click="api.prev()">
1439
+ prev
1440
+ </CoarButton>
1441
+ <CoarButton variant="secondary" size="s" @click="api.goToToday()">
1442
+ today
1443
+ </CoarButton>
1444
+ <CoarButton variant="secondary" size="s" @click="api.next()">
1445
+ next
1446
+ </CoarButton>
1447
+ <CoarButton variant="secondary" size="s" @click="api.setView('month')">
1448
+ Switch to Month
1449
+ </CoarButton>
1450
+ <CoarButton variant="secondary" size="s" @click="api.scrollToTime(8)">
1451
+ Scroll to 8 AM
1452
+ </CoarButton>
1453
+ <CoarButton variant="secondary" size="s" @click="logVisible">
1454
+ Log visible range
1455
+ </CoarButton>
1456
+ </div>
1457
+ <div style="height: 500px; border: 1px solid var(--coar-border-neutral-tertiary); border-radius: var(--coar-radius-xs); overflow: hidden;">
1458
+ <CoarCalendar :builder="builder" />
1459
+ </div>
1460
+ <pre v-if="logLine" class="log">{{ logLine }}</pre>
1461
+ </div>
1462
+ </template>
1463
+
1464
+ <script setup lang="ts">
1465
+ import { ref } from 'vue';
1466
+ import { CoarButton } from '@cocoar/vue-ui';
1467
+ import {
1468
+ CoarCalendar,
1469
+ useCalendar,
1470
+ Temporal,
1471
+ type CalendarEvent,
1472
+ type CalendarView,
1473
+ } from '@cocoar/vue-calendar';
1474
+
1475
+ const view = ref<CalendarView>('week');
1476
+ const date = ref(Temporal.PlainDate.from('2026-04-15'));
1477
+ const logLine = ref('');
1478
+
1479
+ const events = ref<CalendarEvent[]>([
1480
+ {
1481
+ id: 'standup',
1482
+ start: Temporal.ZonedDateTime.from('2026-04-15T09:00:00[Europe/Vienna]'),
1483
+ end: Temporal.ZonedDateTime.from('2026-04-15T09:30:00[Europe/Vienna]'),
1484
+ meta: { title: 'Standup', color: '#10b981' },
1485
+ },
1486
+ ]);
1487
+
1488
+ const { builder, api } = useCalendar();
1489
+ builder
1490
+ .events(events)
1491
+ .view(view)
1492
+ .date(date)
1493
+ .timezone('Europe/Vienna')
1494
+ .onEventDrop(({ event, next }) => {
1495
+ const idx = events.value.findIndex((e) => e.id === event.id);
1496
+ if (idx < 0) return;
1497
+ events.value = [
1498
+ ...events.value.slice(0, idx),
1499
+ { ...event, start: next.start, ...(next.end ? { end: next.end } : {}) },
1500
+ ...events.value.slice(idx + 1),
1501
+ ];
1502
+ });
1503
+
1504
+ function logVisible() {
1505
+ const r = api.getVisibleRange();
1506
+ if (r) logLine.value = `${r.view}: ${r.start} → ${r.end}`;
1507
+ }
1508
+ </script>
1509
+
1510
+ <style scoped>
1511
+ .log {
1512
+ margin: 0;
1513
+ padding: 8px 12px;
1514
+ background: var(--coar-background-neutral-secondary);
1515
+ border-radius: var(--coar-radius-xs);
1516
+ font-size: 12px;
1517
+ font-family: var(--coar-mono-base-family, monospace);
1518
+ }
1519
+ </style>
1520
+ ```
1521
+
1522
+ ## API reference
1523
+
1524
+ ### `useCalendar<TMeta>()`
1525
+
1526
+ ```ts
1527
+ function useCalendar<TMeta>(): {
1528
+ builder: CalendarBuilder<TMeta>;
1529
+ api: CalendarApi<TMeta>;
1530
+ };
1531
+ ```
1532
+
1533
+ Returns a fresh builder + its imperative api. Call once per `<CoarCalendar>` instance, typically at the top of `<script setup>`.
1534
+
1535
+ ### `CalendarBuilder<TMeta>` setters
1536
+
1537
+ The builder is **flat** — every setter lives directly on it. There are no sub-builders or factory callbacks.
1538
+
1539
+ | Setter | Argument | Notes |
1540
+ |--------|----------|-------|
1541
+ | `events(source)` | `MaybeRefOrGetter<readonly CalendarEvent<TMeta>[]>` | Consumer-managed event array. |
1542
+ | `eventsLoader(loader)` | `(window: ViewWindow) => CalendarEvent[] \| Promise<CalendarEvent[]>` | Calendar-managed async loader (cached, debounced). Mutually exclusive with `events`. |
1543
+ | `series(source)` | `MaybeRefOrGetter<readonly RecurringSeries<TMeta>[]>` | Recurring series — expanded per visible window. Reactive. Composes with `events` / `eventsLoader`. |
1544
+ | `seriesLoader(loader)` | `(window: ViewWindow) => RecurringSeries[] \| Promise<RecurringSeries[]>` | Calendar-managed series loader (cached). Mutually exclusive with `series`. |
1545
+ | `recurrenceEngine(engineOrFactory)` | `RecurrenceEngine \| (() => RecurrenceEngine)` | Override the bundled rrule-temporal engine. Factory form is the SSR escape. |
1546
+ | `view(model)` | `Ref<CalendarView>` | Bind a caller-owned view ref. |
1547
+ | `date(model)` | `Ref<Temporal.PlainDate>` | Bind a caller-owned date ref. |
1548
+ | `timezone(tz)` | `MaybeRefOrGetter<string>` | IANA display timezone. |
1549
+ | `locale(loc)` | `MaybeRefOrGetter<string \| undefined>` | BCP-47 locale. |
1550
+ | `firstDayOfWeek(d)` | `MaybeRefOrGetter<0..6 \| undefined>` | Override the locale-detected default. |
1551
+ | `workDays(d)` | `MaybeRefOrGetter<readonly DayOfWeek[]>` | Days to render in the `'workWeek'` view (0 = Sun … 6 = Sat). Default `[1,2,3,4,5]` (Mon–Fri). |
1552
+ | `shadeWeekends(b)` | `MaybeRefOrGetter<boolean>` | Tint Saturday / Sunday cells and headers in Month. Default `true`; disable for the unshaded iOS appearance. |
1553
+ | `monthDensity(d)` | `MaybeRefOrGetter<'compact' \| 'stacked' \| 'details'>` | Month presentation. The shell exposes these beside the optional List variation. Default `'details'`. |
1554
+ | `dayMode(m)` | `MaybeRefOrGetter<'single' \| 'multiDay'>` | One fixed day or a width-aware 1–7-day surface. Default `'single'`. |
1555
+ | `timeRange(r)` | `MaybeRefOrGetter<{ startMinutes: number; endMinutes: number }>` | Day / week visible hour range, in minutes from midnight. |
1556
+ | `slotDuration(d)` | `MaybeRefOrGetter<number>` | Time-grid slot subdivision (minutes). Default `30`. |
1557
+ | `pixelsPerHour(p)` | `MaybeRefOrGetter<number>` | Time-grid row height. Default `60`. |
1558
+ | `dayColumnCount(n)` | `MaybeRefOrGetter<number>` | Minimum complete columns in Multi-day mode. Clamped to `1…7`. |
1559
+ | `dayColumnMinWidth(px)` | `MaybeRefOrGetter<number>` | Target width used to derive extra Multi-day columns. |
1560
+ | `density(d)` | `MaybeRefOrGetter<'comfortable' \| 'compact'>` | Row / padding tightness. |
1561
+ | `maxEventsPerCell(n)` | `MaybeRefOrGetter<number>` | Month-cell pill hint. Default `3`. |
1562
+ | `agendaLengthDays(n)` | `MaybeRefOrGetter<number>` | Days the agenda window covers. Default `30`. |
1563
+ | `showEmptyDays(b)` | `MaybeRefOrGetter<boolean>` | Render headers for empty days (agenda). |
1564
+ | `availableViews(v)` | `MaybeRefOrGetter<readonly CalendarView[]>` | Filter the view-switcher. |
1565
+ | `dstPolicy(p)` | `MaybeRefOrGetter<'compatible' \| 'reject' \| 'earlier' \| 'later'>` | DST gap/overlap resolution (Article 5). Default `'compatible'`. See "DST handling" above. |
1566
+ | `dateStyle(s)` | `MaybeRefOrGetter<'full' \| 'long' \| 'medium' \| 'short' \| undefined>` | Verbosity of date labels (Article 9 — independent of locale). |
1567
+ | `timeStyle(s)` | `MaybeRefOrGetter<'full' \| 'long' \| 'medium' \| 'short' \| undefined>` | Verbosity of time labels (Article 9). |
1568
+ | `hour12(h)` | `MaybeRefOrGetter<boolean \| undefined>` | Force 12-/24-hour clock independent of locale. `undefined` lets the locale decide. |
1569
+ | `canDrop(fn)` | `(event, target) => boolean` | Drop-target validator. Read refs inside the function for reactive policies. |
1570
+ | `eventRenderer(r)` | `EventRenderer<TMeta>` | Universal event renderer. Branch on `ctx.layout?.kind` (`'positioned'` / `'allDayBar'` / `'monthPill'` / `'monthBar'`) to render per layout variant. See "Custom event rendering" above. |
1571
+ | `dayHeaderRenderer(r)` | `DayHeaderRenderer` | Day column header. |
1572
+ | `allDayMaxVisibleLanes(n)` | `MaybeRefOrGetter<number \| null>` | Lanes the all-day band (week / work-week / day) shows before it folds the rest behind per-day "+N" markers. Default `3`; `null` = unlimited. A click on a marker expands the band; a collapse control folds it back. |
1573
+ | `timedEventDetailMinWidth(px)` | `MaybeRefOrGetter<number>` | Unobscured width below which an overlapped Day / Week card switches to the compact anatomy (one end-truncated title line, no location, no time row). Default `112` like iOS; `0` disables the switch. See [Overlapping timed cards](./week-view.md#overlapping-timed-cards). |
1574
+ | `allDayBandMode(m)` | `MaybeRefOrGetter<'fitsContent' \| 'alwaysOneLane' \| 'reservesCap'>` | How much height the all-day band claims. `fitsContent` (default) follows the content and disappears without all-day events; `alwaysOneLane` keeps at least one lane so the grid never jumps 0↔1; `reservesCap` is always `allDayMaxVisibleLanes` tall so the hour axis sits at the same place on every day. |
1575
+ | `timeGridRange(spec)` | `MaybeRefOrGetter<TimeGridRangeSpec \| null>` | Anchor / span / filter / step of the Day view's columns and paging. Week and Work week are fixed presets of the same model. See the [Day view](./day-view.md#one-model-for-every-time-grid). |
1576
+ | `swipeNavigation(b)` | `MaybeRefOrGetter<boolean>` | Touch paging on week / work-week / day (default `true`): a horizontal pan moves the grid with the finger and pages on release past a quarter of the width or on a fast flick. A touch that never moves is a tap and reaches `onTimeClick` on release. On the columns mouse / pen are unaffected; a mouse drag across the day-name strip pages too. Honours `prefers-reduced-motion`. |
1577
+ | `prefetchNeighbours(b)` | `MaybeRefOrGetter<boolean>` | Warm the loader / series caches for the previous and next page of the time grids so the neighbour pages drawn during a swipe carry their events. Default `true`; one extra fetch per neighbour in loader mode, a no-op in `events()` mode. |
1578
+ | `eventTextContrast(p)` | `MaybeRefOrGetter<'wcag' \| 'apca'>` | How the automatic black/white text on event surfaces is chosen. `'wcag'` (default) is the WCAG 2 ratio; `'apca'` (WCAG 3 draft) picks white on saturated mid-tones such as `#e03131`, where WCAG 2 narrowly picks black. A per-event `meta.textColor` wins over either. |
1579
+ | `onEventClick(fn)` | `(payload: { event, native: PointerEvent }) => void` | Common: open details, a side panel or a click-anchored popover. |
1580
+ | `onEventDoubleClick(fn)` | `(payload: { event, native: MouseEvent }) => void` | Common: open the host application's edit UI. |
1581
+ | `onEventHover(fn)` | `(payload: { event, native: PointerEvent }) => void` | Pair with `useOverlay()` for popovers / tooltips. `native.currentTarget` is the anchor element. No hover delay applied — wrap with `setTimeout(..., 200)` if needed. |
1582
+ | `onEventHoverLeave(fn)` | `(payload: { event, native: PointerEvent }) => void` | Companion close-trigger for the popover the hover handler opened. |
1583
+ | `onEventDrop(fn)` | `(payload) => void` | Drag-and-drop / keyboard / touch all flow through this. |
1584
+ | `onDateClick(fn)` | `(payload: { date, native: PointerEvent }) => void` | Empty cell / day-header clicked. |
1585
+ | `onTimeClick(fn)` | `(payload: { date, time, native: PointerEvent }) => void` | Empty time slot (week / day). |
1586
+ | `onDateDoubleClick(fn)` | `(payload: { date, native: MouseEvent }) => void` | Empty month cell / all-day cell double-clicked. Never fires for a double-click on an event. |
1587
+ | `onTimeDoubleClick(fn)` | `(payload: { date, time, native: MouseEvent }) => void` | Empty time slot double-clicked; `time` snapped like `onTimeClick`. |
1588
+ | `onRangeChange(fn)` | `(window) => void` | Visible window changed. |
1589
+
1590
+ ### `CalendarApi<TMeta>`
1591
+
1592
+ ```ts
1593
+ interface CalendarApi<TMeta> {
1594
+ goTo(date: Temporal.PlainDate): void;
1595
+ goToToday(): void;
1596
+ next(): void;
1597
+ prev(): void;
1598
+ setView(view: CalendarView): void;
1599
+ setMonthDensity(density: CalendarMonthDensity): void;
1600
+ setDayMode(mode: CalendarDayMode): void;
1601
+ getVisibleRange(): ViewWindow | null;
1602
+ getVisibleEvents(): CalendarEvent<TMeta>[];
1603
+ /** Same read for any window, e.g. the neighbour pages drawn during a swipe. */
1604
+ getEventsForWindow(window: ViewWindow): CalendarEvent<TMeta>[];
1605
+ scrollToTime(time: Temporal.PlainTime): void;
1606
+ scrollToDate(date: Temporal.PlainDate): void;
1607
+ refresh(): void;
1608
+ refreshRange(window: ViewWindow): void;
1609
+ readonly loading: Readonly<Ref<boolean>>;
1610
+ readonly visibleRange: Readonly<Ref<ViewWindow | null>>;
1611
+ readonly gridReady: Readonly<Ref<boolean>>;
1612
+ /** Title of the visible window, exactly as the built-in header shows it. */
1613
+ readonly rangeLabel: Readonly<ComputedRef<string>>;
1614
+ /** Month view: the topmost visible month, live while scrolling; `null` elsewhere. */
1615
+ readonly topmostVisibleMonth: Readonly<ShallowRef<Temporal.PlainYearMonth | null>>;
1616
+ }
1617
+ ```
1618
+
1619
+ ### `<CoarCalendar>` slots
1620
+
1621
+ Variant-specific slots (`pill`, `multiDayBar`, `allDayEvent`) still exist on the **component** even though there are no matching builder setters — the slots take precedence over `eventRenderer` when both are supplied, so reach for a slot when you want a one-line template-side override and the renderer when you want a function with discriminated branching.
1622
+
1623
+ | Slot | Scope | Purpose |
1624
+ |------|-------|---------|
1625
+ | `header` | `{ view, cursor, range, controls }` | Replace the entire header. |
1626
+ | `headerStart` | `{ controls }` | Prepend before nav buttons. |
1627
+ | `headerEnd` | `{ controls }` | Append after the view switcher. |
1628
+ | `viewSwitcher` | `{ view, available, setView }` | Replace just the view switcher. |
1629
+ | `event` | `{ event, view, layout?, item? }` | Per-event renderer (Day / Week / Agenda; falls back for month pills / bars). |
1630
+ | `allDayEvent` | `{ event, layout }` | All-day band renderer (week / day). |
1631
+ | `pill` | `{ event, pill }` | Month single-day pill. |
1632
+ | `agendaEmpty` | — | Agenda empty state (forwarded to `<CoarAgendaView>`'s `empty` slot). Shown only when the agenda draws nothing and no load is in flight; no default. |
1633
+ | `multiDayBar` | `{ event, bar }` | Month multi-day bar. |
1634
+ | `dayHeader` | `{ date, isToday, isWeekend }` | Per-day column header (week / day). |
1635
+
1636
+ ### `<CoarCalendar>` props
1637
+
1638
+ | Prop | Type | Description |
1639
+ |------|------|-------------|
1640
+ | `builder` | `CalendarBuilder` | **Required.** From `useCalendar()`. |
1641
+ | `hideHeader` | `boolean` | Render only the body — no header bar. For hosts that own navigation and view selection and drive the calendar through `api.goTo / next / prev / setView / setMonthDensity / setDayMode`; `api.rangeLabel` is the title the built-in header would have shown. Default `false`. |
1642
+ | `hideViewSwitcher` | `boolean` | Keep the header but drop the primary view switcher. Default `false`. |
1643
+ | `hideModeSwitcher` | `boolean` | Keep the header but drop the Month / Day display-choice switcher. Default `false`. |
1644
+
1645
+ Everything else lives on the builder. To replace the header with your own controls instead of hiding it, use the `header` slot — its `controls` scope carries `prev`, `next`, `goToToday`, `setView`, the formatted `rangeLabel`, the active `view` and the `available` views. Note that an **empty** `#header` slot does not hide the header: Vue renders the built-in fallback when a slot yields no nodes, which is why `hideHeader` exists.