@cocoar/vue-ui 3.1.0 → 3.2.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/checkbox/CoarCheckbox.vue.d.ts +1 -1
- package/dist/components/checkbox/CoarCheckbox.vue.d.ts.map +1 -1
- package/dist/components/code-block/CoarCodeBlock.vue.d.ts +1 -1
- package/dist/components/code-block/CoarCodeBlock.vue.d.ts.map +1 -1
- package/dist/components/data-list/CoarDataList.vue.d.ts +158 -0
- package/dist/components/data-list/CoarDataList.vue.d.ts.map +1 -0
- package/dist/components/data-list/CoarDataListToolbar.vue.d.ts +30 -0
- package/dist/components/data-list/CoarDataListToolbar.vue.d.ts.map +1 -0
- package/dist/components/data-list/data-list-builder.d.ts +288 -0
- package/dist/components/data-list/data-list-builder.d.ts.map +1 -0
- package/dist/components/data-list/index.d.ts +12 -0
- package/dist/components/data-list/index.d.ts.map +1 -0
- package/dist/components/data-list/internal/compare.d.ts +14 -0
- package/dist/components/data-list/internal/compare.d.ts.map +1 -0
- package/dist/components/data-list/internal/reorder-core.d.ts +46 -0
- package/dist/components/data-list/internal/reorder-core.d.ts.map +1 -0
- package/dist/components/data-list/internal/search.d.ts +16 -0
- package/dist/components/data-list/internal/search.d.ts.map +1 -0
- package/dist/components/data-list/internal/useDataListLines.d.ts +92 -0
- package/dist/components/data-list/internal/useDataListLines.d.ts.map +1 -0
- package/dist/components/data-list/internal/useDataListReorder.d.ts +68 -0
- package/dist/components/data-list/internal/useDataListReorder.d.ts.map +1 -0
- package/dist/components/data-list/internal/useSearchHighlight.d.ts +19 -0
- package/dist/components/data-list/internal/useSearchHighlight.d.ts.map +1 -0
- package/dist/components/data-list/types.d.ts +185 -0
- package/dist/components/data-list/types.d.ts.map +1 -0
- package/dist/components/data-list/useDataListModel.d.ts +79 -0
- package/dist/components/data-list/useDataListModel.d.ts.map +1 -0
- package/dist/components/date-time/_shared/maskito-config.d.ts +7 -2
- package/dist/components/date-time/_shared/maskito-config.d.ts.map +1 -1
- package/dist/components/date-time/_shared/time-helpers.d.ts +11 -4
- package/dist/components/date-time/_shared/time-helpers.d.ts.map +1 -1
- package/dist/components/date-time/_shared/use-date-picker-base.d.ts +1 -1
- package/dist/components/date-time/plain-date-picker/CoarPlainDatePicker.vue.d.ts +1 -1
- package/dist/components/date-time/plain-date-picker/CoarPlainDatePicker.vue.d.ts.map +1 -1
- package/dist/components/date-time/plain-date-time-picker/CoarPlainDateTimePicker.vue.d.ts +1 -1
- package/dist/components/date-time/plain-date-time-picker/CoarPlainDateTimePicker.vue.d.ts.map +1 -1
- package/dist/components/date-time/zoned-date-time-picker/CoarZonedDateTimePicker.vue.d.ts +1 -1
- package/dist/components/date-time/zoned-date-time-picker/CoarZonedDateTimePicker.vue.d.ts.map +1 -1
- package/dist/components/dual-listbox/CoarDualListbox.vue.d.ts +3 -0
- package/dist/components/dual-listbox/CoarDualListbox.vue.d.ts.map +1 -1
- package/dist/components/form-field/CoarFormField.vue.d.ts +1 -1
- package/dist/components/form-field/CoarFormField.vue.d.ts.map +1 -1
- package/dist/components/icon/CoarIcon.vue.d.ts +2 -2
- package/dist/components/icon/CoarIcon.vue.d.ts.map +1 -1
- package/dist/components/listbox/CoarListbox.vue.d.ts +7 -0
- package/dist/components/listbox/CoarListbox.vue.d.ts.map +1 -1
- package/dist/components/number-input/CoarNumberInput.vue.d.ts +1 -1
- package/dist/components/number-input/CoarNumberInput.vue.d.ts.map +1 -1
- package/dist/components/otp-input/CoarOtpInput.vue.d.ts +1 -1
- package/dist/components/otp-input/CoarOtpInput.vue.d.ts.map +1 -1
- package/dist/components/password-input/CoarPasswordInput.vue.d.ts +1 -1
- package/dist/components/password-input/CoarPasswordInput.vue.d.ts.map +1 -1
- package/dist/components/switch/CoarSwitch.vue.d.ts +1 -1
- package/dist/components/switch/CoarSwitch.vue.d.ts.map +1 -1
- package/dist/components/text-input/CoarTextInput.vue.d.ts +1 -1
- package/dist/components/text-input/CoarTextInput.vue.d.ts.map +1 -1
- package/dist/composables/dragRegistry.d.ts +12 -0
- package/dist/composables/dragRegistry.d.ts.map +1 -1
- package/dist/composables/index.d.ts +2 -2
- package/dist/composables/index.d.ts.map +1 -1
- package/dist/composables/useDragDrop.d.ts +62 -6
- package/dist/composables/useDragDrop.d.ts.map +1 -1
- package/dist/composables/useVirtualList.d.ts +21 -1
- package/dist/composables/useVirtualList.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4508 -2039
- package/package.json +5 -3
- package/skills/cocoar-vue-ui/SKILL.md +244 -0
- package/skills/cocoar-vue-ui/references/components/avatar.md +178 -0
- package/skills/cocoar-vue-ui/references/components/badge.md +290 -0
- package/skills/cocoar-vue-ui/references/components/breadcrumb.md +238 -0
- package/skills/cocoar-vue-ui/references/components/button.md +252 -0
- package/skills/cocoar-vue-ui/references/components/calendar/agenda-view.md +353 -0
- package/skills/cocoar-vue-ui/references/components/calendar/coar-calendar.md +1645 -0
- package/skills/cocoar-vue-ui/references/components/calendar/day-view.md +361 -0
- package/skills/cocoar-vue-ui/references/components/calendar/index.md +312 -0
- package/skills/cocoar-vue-ui/references/components/calendar/month-view.md +475 -0
- package/skills/cocoar-vue-ui/references/components/calendar/performance.md +186 -0
- package/skills/cocoar-vue-ui/references/components/calendar/timeline-view.md +301 -0
- package/skills/cocoar-vue-ui/references/components/calendar/week-view.md +382 -0
- package/skills/cocoar-vue-ui/references/components/calendar/work-week-view.md +145 -0
- package/skills/cocoar-vue-ui/references/components/calendar/year-view.md +63 -0
- package/skills/cocoar-vue-ui/references/components/card.md +151 -0
- package/skills/cocoar-vue-ui/references/components/checkbox-group.md +103 -0
- package/skills/cocoar-vue-ui/references/components/checkbox.md +279 -0
- package/skills/cocoar-vue-ui/references/components/code-block.md +241 -0
- package/skills/cocoar-vue-ui/references/components/context-menu.md +355 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/checkbox.md +213 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/date-columns.md +272 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/editing.md +236 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/multi-select.md +193 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/number.md +147 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/select.md +202 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/text.md +127 -0
- package/skills/cocoar-vue-ui/references/components/data-grid.md +1225 -0
- package/skills/cocoar-vue-ui/references/components/data-list.md +2121 -0
- package/skills/cocoar-vue-ui/references/components/date-or-time-picker.md +200 -0
- package/skills/cocoar-vue-ui/references/components/date-picker.md +203 -0
- package/skills/cocoar-vue-ui/references/components/date-time-picker.md +154 -0
- package/skills/cocoar-vue-ui/references/components/date-views.md +212 -0
- package/skills/cocoar-vue-ui/references/components/dialog.md +148 -0
- package/skills/cocoar-vue-ui/references/components/divider.md +157 -0
- package/skills/cocoar-vue-ui/references/components/document-viewer/annotations.md +400 -0
- package/skills/cocoar-vue-ui/references/components/document-viewer/coar-document-viewer.md +327 -0
- package/skills/cocoar-vue-ui/references/components/document-viewer/index.md +234 -0
- package/skills/cocoar-vue-ui/references/components/document-viewer/toolbar.md +221 -0
- package/skills/cocoar-vue-ui/references/components/drag-drop.md +267 -0
- package/skills/cocoar-vue-ui/references/components/dual-listbox.md +449 -0
- package/skills/cocoar-vue-ui/references/components/file-explorer/asset-store.md +327 -0
- package/skills/cocoar-vue-ui/references/components/file-explorer/in-memory-store.md +176 -0
- package/skills/cocoar-vue-ui/references/components/file-explorer/index.md +311 -0
- package/skills/cocoar-vue-ui/references/components/file-explorer/use-file-explorer.md +300 -0
- package/skills/cocoar-vue-ui/references/components/form-field.md +1025 -0
- package/skills/cocoar-vue-ui/references/components/fragment-parser.md +298 -0
- package/skills/cocoar-vue-ui/references/components/link.md +235 -0
- package/skills/cocoar-vue-ui/references/components/listbox.md +575 -0
- package/skills/cocoar-vue-ui/references/components/map/editor.md +448 -0
- package/skills/cocoar-vue-ui/references/components/map/index.md +351 -0
- package/skills/cocoar-vue-ui/references/components/markdown-diagrams.md +210 -0
- package/skills/cocoar-vue-ui/references/components/markdown-editor.md +1478 -0
- package/skills/cocoar-vue-ui/references/components/markdown-embeds.md +387 -0
- package/skills/cocoar-vue-ui/references/components/markdown-form.md +447 -0
- package/skills/cocoar-vue-ui/references/components/markdown.md +276 -0
- package/skills/cocoar-vue-ui/references/components/menu.md +380 -0
- package/skills/cocoar-vue-ui/references/components/mermaid.md +172 -0
- package/skills/cocoar-vue-ui/references/components/navbar.md +147 -0
- package/skills/cocoar-vue-ui/references/components/note.md +96 -0
- package/skills/cocoar-vue-ui/references/components/notice.md +131 -0
- package/skills/cocoar-vue-ui/references/components/number-input.md +215 -0
- package/skills/cocoar-vue-ui/references/components/otp-input.md +324 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/authoring-contract.md +271 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-builder.md +476 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-renderer.md +677 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/custom-elements.md +441 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/idp-integration.md +122 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/index.md +573 -0
- package/skills/cocoar-vue-ui/references/components/pagination.md +150 -0
- package/skills/cocoar-vue-ui/references/components/panel-layout.md +372 -0
- package/skills/cocoar-vue-ui/references/components/password-input.md +175 -0
- package/skills/cocoar-vue-ui/references/components/popconfirm.md +186 -0
- package/skills/cocoar-vue-ui/references/components/popover.md +173 -0
- package/skills/cocoar-vue-ui/references/components/progress-bar.md +178 -0
- package/skills/cocoar-vue-ui/references/components/radio-group.md +225 -0
- package/skills/cocoar-vue-ui/references/components/script-editor.md +1271 -0
- package/skills/cocoar-vue-ui/references/components/segmented-control.md +238 -0
- package/skills/cocoar-vue-ui/references/components/select.md +463 -0
- package/skills/cocoar-vue-ui/references/components/sidebar.md +421 -0
- package/skills/cocoar-vue-ui/references/components/spinner.md +132 -0
- package/skills/cocoar-vue-ui/references/components/switch.md +195 -0
- package/skills/cocoar-vue-ui/references/components/table.md +170 -0
- package/skills/cocoar-vue-ui/references/components/tabs.md +382 -0
- package/skills/cocoar-vue-ui/references/components/tag.md +178 -0
- package/skills/cocoar-vue-ui/references/components/text-input.md +256 -0
- package/skills/cocoar-vue-ui/references/components/toast.md +160 -0
- package/skills/cocoar-vue-ui/references/components/tooltip.md +121 -0
- package/skills/cocoar-vue-ui/references/components/transitions.md +193 -0
- package/skills/cocoar-vue-ui/references/components/tree.md +2388 -0
- package/skills/cocoar-vue-ui/references/components/virtual-list.md +212 -0
- package/skills/cocoar-vue-ui/references/components/wizard.md +251 -0
- package/skills/cocoar-vue-ui/references/components/zoned-date-time-picker.md +177 -0
- package/skills/cocoar-vue-ui/references/foundations/colors.md +708 -0
- package/skills/cocoar-vue-ui/references/foundations/design-principles.md +115 -0
- package/skills/cocoar-vue-ui/references/foundations/icons.md +381 -0
- package/skills/cocoar-vue-ui/references/foundations/localization/formatting.md +530 -0
- package/skills/cocoar-vue-ui/references/foundations/localization/setup.md +86 -0
- package/skills/cocoar-vue-ui/references/foundations/localization/timezones.md +182 -0
- package/skills/cocoar-vue-ui/references/foundations/localization/translations.md +305 -0
- package/skills/cocoar-vue-ui/references/foundations/motion.md +549 -0
- package/skills/cocoar-vue-ui/references/foundations/spacing.md +330 -0
- package/skills/cocoar-vue-ui/references/foundations/theming.md +140 -0
- package/skills/cocoar-vue-ui/references/foundations/typography.md +206 -0
- package/skills/cocoar-vue-ui/references/guide/error-handling.md +162 -0
- package/skills/cocoar-vue-ui/references/guide/getting-started.md +119 -0
- package/skills/cocoar-vue-ui/references/guide/migration-page-builder-3.md +207 -0
- package/skills/cocoar-vue-ui/references/guide/migration.md +140 -0
- 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.
|