Appearance
FullCalendar — live demo
This is the real @rozie-ui/fullcalendar-vue package running on this page (VitePress is itself a Vue app). Click the events, drag the toolbar, switch views — then use the controls below to drive the imperative handle. Everything here is driven by the same FullCalendar.rozie source that compiles to all six frameworks.
The active view is two-way bound with v-model:view — the readout updates whether you click the calendar's own toolbar or the Month / Week buttons above, which drive the imperative handle (changeView). The Prev / Today / Next buttons call the prev, today, and next handle verbs, and the current month/week label is read back off the raw Calendar instance via getApi(). See the full API for the complete prop/event/handle surface.
One source, six outputs
You author the component once as a .rozie file:
html
<!--
FullCalendar.rozie — data-bound port of FullCalendar (fullcalendar/fullcalendar).
FullCalendar ships all four official wrappers (@fullcalendar/react,
@fullcalendar/vue3, @fullcalendar/angular, @fullcalendar/svelte) —
each one wraps the same vanilla-JS Calendar engine. The perfect
"5 wrappers, 1 source" leverage story. ONE Rozie file covers
six frameworks.
Two-way binds the current view name so consumers can react to /
drive view switches:
<FullCalendar
r-model:view="$data.view"
:events="$data.events"
@eventClick="onEventClick"
@dateClick="onDateClick"
/>
FullCalendar v6 auto-injects its CSS — consumers do NOT need a manual
stylesheet import. The .rozie file's <style> is scoped so it carries
only the wrapper's layout box, not the global .fc-* selectors.
Why this is a worthwhile demo:
- NEW domain: calendar grids with Date objects (not strings)
- NEW pattern: object spread inside an engine method call
(`cal.addEvent({ ...info, id: nextId(), color: $props.defaultColor })`)
- NEW pattern: template literals interpolating $data/$props in
$script (event title formatting)
- Multi-plugin import shape — @fullcalendar/core + per-view plugins
via separate default imports
- Structured-payload $emit: eventClick forwards { event, jsEvent,
view } — heavier payload shape than prior examples
- Rich $expose imperative handle: getApi + 7 navigation/mutation verbs
-->
<rozie name="FullCalendar" inherit-attrs="false" inherit-listeners="false" adopt-document-styles>
<props>
{
events: {
type: Array,
default: () => [],
docs: {
description:
'The event objects rendered on the calendar. Each event is normalized: a missing `title` falls back to `Event <id>`, and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array reconciles the live calendar via `removeAllEvents` + `addEvent`.',
},
},
view: {
type: String,
default: 'dayGridMonth',
model: true,
docs: {
description:
"The two-way active view name (`'dayGridMonth'`, `'timeGridWeek'`, `'timeGridDay'`, …) — the sole `model: true` prop. The calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the view via `changeView`.",
example: '<FullCalendar r-model:view="view" :events="events" />',
},
},
weekends: {
type: Boolean,
default: true,
docs: {
description:
'Show the Saturday/Sunday columns. Runtime-updatable via `setOption`.',
},
},
editable: {
type: Boolean,
default: true,
docs: {
description:
'Allow events to be dragged and resized. Runtime-updatable via `setOption`.',
},
},
selectable: {
type: Boolean,
default: true,
docs: {
description:
'Allow date/time-range selection by click-drag. Runtime-updatable via `setOption`.',
},
},
height: {
type: Number,
default: 480,
docs: {
description:
'Calendar height in pixels. Runtime-updatable via `setOption`.',
},
},
defaultColor: {
type: String,
default: '#3b82f6',
docs: {
description:
'Fallback event color stamped onto events that omit their own `color`.',
},
},
locale: {
type: String,
default: 'en',
docs: {
description:
'FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through `setOption` via the imperative handle if needed.',
},
},
firstDay: {
type: Number,
default: 0,
docs: {
description:
'First day of the week (`0` = Sunday … `1` = Monday). Runtime-updatable via `setOption`.',
},
},
slotDuration: {
type: String,
default: '00:30:00',
docs: {
description:
'Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.',
},
},
nowIndicator: {
type: Boolean,
default: false,
docs: {
description:
'Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.',
},
},
headerToolbar: {
type: Object,
default: () => ({ left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }),
docs: {
description:
'The toolbar layout (`{ left, center, right }`). A consumer-passed object **fully replaces** the built-in default rather than merging with it. Runtime-updatable via `setOption`.',
},
},
// :options passthrough — arbitrary FullCalendar options/callbacks the curated
// surface doesn't special-case (businessHours, dayMaxEvents, *DidMount hooks…).
// Spread FIRST in the opts object so explicit curated props/events/slots win
// on key collision (the curated surface is primary; :options fills gaps).
options: {
type: Object,
default: () => ({}),
docs: {
description:
'Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface does not special-case (`businessHours`, `dayMaxEvents`, `*DidMount` hooks, locale objects, …). Spread **first** into the engine config so the curated props/events/slots win on key collision; `:options` only fills gaps. Runtime-updatable per key via `setOption` (no key-removal reset — a removed key keeps its last applied value until remount; use `getApi()` for full imperative control). The `plugins` key is the one exception that **merges** with the baked-in defaults instead of overriding them, making the wrapper consumer-extensible.',
},
},
}
</props>
<script>
import { Calendar } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
let instance = null
let suppressViewSync = false
const PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin]
const normalizeEvent = (e) => {
// Object spread + template-literal default — common reconcile shape:
// pass user props through, but stamp a sensible title fallback and
// honor the wrapper's defaultColor only when the event omits one.
return {
...e,
title: e.title || `Event ${e.id ?? '(no id)'}`,
color: e.color || $props.defaultColor,
}
}
$onMount(() => {
const opts = {
// :options passthrough spread FIRST — the curated keys below + the portal
// *Content handlers added after this object override any colliding key, so
// an explicitly-bound prop (e.g. :height) wins over options.height.
//
// EXCEPTION — `plugins` is the one curated key that AUGMENTS rather than
// overrides: instead of clobbering a consumer-supplied `:options.plugins`,
// it MERGES the always-on baked-in defaults (dayGrid + timeGrid +
// interaction) with any consumer-added plugins. This makes the wrapper
// consumer-extensible (opt-in) — a consumer can engage list/rrule/premium/
// etc. via `:options="{ plugins: [listPlugin] }"` with NO bundle cost and NO
// per-plugin wrapper code. FullCalendar dedupes plugins by identity, so a
// consumer re-passing a default is harmless.
...$props.options,
plugins: [...PLUGINS, ...($props.options?.plugins ?? [])],
initialView: $props.view,
weekends: $props.weekends,
editable: $props.editable,
selectable: $props.selectable,
height: $props.height,
locale: $props.locale,
firstDay: $props.firstDay,
slotDuration: $props.slotDuration,
nowIndicator: $props.nowIndicator,
events: $props.events.map(normalizeEvent),
// D-02: a consumer-passed headerToolbar fully REPLACES the built-in
// toolbar; the built-in default lives in the `headerToolbar` prop default.
headerToolbar: $props.headerToolbar,
eventClick: (info) => {
$emit('eventClick', {
event: { id: info.event.id, title: info.event.title, start: info.event.start, end: info.event.end },
jsEvent: info.jsEvent,
})
},
dateClick: (info) => {
$emit('dateClick', { date: info.date, dateStr: info.dateStr, allDay: info.allDay })
},
eventDrop: (info) => {
$emit('eventDrop', {
event: { id: info.event.id, title: info.event.title, start: info.event.start, end: info.event.end },
delta: info.delta,
})
},
select: (info) => {
$emit('select', { start: info.start, end: info.end, startStr: info.startStr, endStr: info.endStr, allDay: info.allDay })
},
eventResize: (info) => {
$emit('eventResize', {
event: { id: info.event.id, title: info.event.title, start: info.event.start, end: info.event.end },
startDelta: info.startDelta,
endDelta: info.endDelta,
})
},
datesSet: (info) => {
$emit('datesSet', { start: info.start, end: info.end, view: info.view.type })
},
eventMouseEnter: (info) => {
$emit('eventMouseEnter', {
event: { id: info.event.id, title: info.event.title, start: info.event.start, end: info.event.end },
jsEvent: info.jsEvent,
})
},
eventMouseLeave: (info) => {
$emit('eventMouseLeave', {
event: { id: info.event.id, title: info.event.title, start: info.event.start, end: info.event.end },
jsEvent: info.jsEvent,
})
},
unselect: (info) => {
$emit('unselect', { jsEvent: info.jsEvent })
},
loading: (isLoading) => {
// FullCalendar's `loading` callback receives a bare boolean (not an info
// object) — normalize to the structured `{ isLoading }` payload shape.
$emit('loading', { isLoading })
},
eventsSet: (events) => {
// `eventsSet` receives the array of current EventApi objects — map each to
// the normalized floor shape for persistence/sync consumers.
$emit('eventsSet', { events: events.map((e) => ({ id: e.id, title: e.title, start: e.start, end: e.end })) })
},
viewDidMount: (info) => {
// viewDidMount fires both on initial mount AND on changeView calls.
// Same round-trip guard pattern as Flatpickr / LeafletMap.
if (suppressViewSync) { suppressViewSync = false; return }
if (info.view.type !== $props.view) $model.view = info.view.type
},
}
// Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
// slot, route every cell render through it. The portal helper mounts the
// consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
// Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
// handle is returned to FullCalendar so it cleans up the mounted tree when
// the cell is removed. Consumers that don't fill the slot get FullCalendar's
// default rendering (title text) — guarded by `$slots.event`.
if ($slots.event) {
opts.eventContent = (arg) => {
const node = document.createElement('div')
const dispose = $portals.event(node, { arg })
return { domNodes: [node], dispose }
}
}
// The 9 remaining *Content portal-slots — wired identically to `event`, one
// per FullCalendar per-cell content hook. Each guarded by its own slot so
// unfilled slots keep FullCalendar's default rendering. (10 portal-slots total
// counting `event` above; allDayContent + slotLaneContent are the two timeGrid
// axis/lane hooks, and noEventsContent is the list-view "no events" hook —
// inert unless the consumer engages @fullcalendar/list via :options.plugins.)
//
// NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
// hook (`nowIndicatorContent`) so it does NOT clash with the boolean
// `nowIndicator` PROP — a slot name that equals a declared prop name is now a
// hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
// unifies snippets and props into one `$props` namespace.
if ($slots.dayCell) {
opts.dayCellContent = (arg) => {
const node = document.createElement('div')
const dispose = $portals.dayCell(node, { arg })
return { domNodes: [node], dispose }
}
}
if ($slots.dayHeader) {
opts.dayHeaderContent = (arg) => {
const node = document.createElement('div')
const dispose = $portals.dayHeader(node, { arg })
return { domNodes: [node], dispose }
}
}
if ($slots.slotLabel) {
opts.slotLabelContent = (arg) => {
const node = document.createElement('div')
const dispose = $portals.slotLabel(node, { arg })
return { domNodes: [node], dispose }
}
}
if ($slots.weekNumber) {
opts.weekNumberContent = (arg) => {
const node = document.createElement('div')
const dispose = $portals.weekNumber(node, { arg })
return { domNodes: [node], dispose }
}
}
if ($slots.nowIndicatorContent) {
opts.nowIndicatorContent = (arg) => {
const node = document.createElement('div')
const dispose = $portals.nowIndicatorContent(node, { arg })
return { domNodes: [node], dispose }
}
}
if ($slots.moreLink) {
opts.moreLinkContent = (arg) => {
const node = document.createElement('div')
const dispose = $portals.moreLink(node, { arg })
return { domNodes: [node], dispose }
}
}
if ($slots.allDayContent) {
opts.allDayContent = (arg) => {
const node = document.createElement('div')
const dispose = $portals.allDayContent(node, { arg })
return { domNodes: [node], dispose }
}
}
if ($slots.slotLaneContent) {
opts.slotLaneContent = (arg) => {
const node = document.createElement('div')
const dispose = $portals.slotLaneContent(node, { arg })
return { domNodes: [node], dispose }
}
}
// noEventsContent — the list-view "no events to display" hook. Pre-declared
// and wired like the other 9 *Content slots, but INERT unless the consumer
// (a) engages @fullcalendar/list via the now-merged :options.plugins AND
// (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
// the bundled-only plugin set there is no list view, so this hook never fires
// — by design, documented, zero bundle cost.
if ($slots.noEventsContent) {
opts.noEventsContent = (arg) => {
const node = document.createElement('div')
const dispose = $portals.noEventsContent(node, { arg })
return { domNodes: [node], dispose }
}
}
instance = new Calendar($el, opts)
instance.render()
return () => instance?.destroy()
})
// Reconcile events without remounting: removeAllEvents + addEvent loop is
// FullCalendar's supported runtime-update path. setOption('events', …)
// also works but resets internal indexing.
$watch(() => $props.events, (v) => {
if (!instance) return
instance.removeAllEvents()
for (const e of v) instance.addEvent(normalizeEvent(e))
})
$watch(() => $props.view, (v) => {
if (!instance || !v) return
if (v === instance.view.type) return
suppressViewSync = true
instance.changeView(v)
})
$watch(() => $props.weekends, (v) => instance?.setOption('weekends', v))
$watch(() => $props.editable, (v) => instance?.setOption('editable', v))
$watch(() => $props.selectable, (v) => instance?.setOption('selectable', v))
$watch(() => $props.height, (v) => instance?.setOption('height', v))
$watch(() => $props.locale, (v) => instance?.setOption('locale', v))
$watch(() => $props.firstDay, (v) => instance?.setOption('firstDay', v))
$watch(() => $props.slotDuration, (v) => instance?.setOption('slotDuration', v))
$watch(() => $props.nowIndicator, (v) => instance?.setOption('nowIndicator', v))
$watch(() => $props.headerToolbar, (v) => instance?.setOption('headerToolbar', v))
// :options runtime reconcile — apply each passthrough key through setOption.
// Documented limitation: removing a key from the object does NOT reset that
// option (setOption-per-key has no "unset"); consumers needing full control
// (including option removal) use getApi() and drive the Calendar directly.
$watch(() => $props.options, (v) => {
if (!instance) return
for (const k in v) instance.setOption(k, v[k])
})
// Imperative handle (Phase 21 $expose). The 16 calendar verbs a consumer can't
// drive through props alone — exposed uniformly to all 6 targets
// (Vue defineExpose / React useImperativeHandle / Svelte instance export /
// Angular+Lit public method / Solid callback ref). Each delegates to the
// underlying Calendar instance, which is null before $onMount and after
// destroy — callers handle the pre-mount null.
//
// Collision discipline (the load-bearing flatpickr lesson): no exposed name may
// collide with an emitted event (eventClick/dateClick/eventDrop/eventResize/
// datesSet/eventMouseEnter/eventMouseLeave/eventsSet/loading/select/unselect) or
// a declared prop. This is why the selection verbs are NAMED `selectRange`
// (CalendarApi.select) and `clearSelection` (CalendarApi.unselect) — bare
// `select`/`unselect` collide with the same-named emits (ROZ121), and `select`
// is also Lit-risky. getApi returns the raw Calendar instance (NOT guard-nulled).
//
// Read-back gap closed: getDate (current anchor — the `view` model only carries
// the view TYPE, datesSet only the visible RANGE) and getEvents (synchronous
// event read — eventsSet is push-only). scrollToTime/updateSize cover timeGrid
// scroll + container-resize relayout; prevYear/nextYear mirror prev/next.
function getApi() { return instance }
function changeView(...a) { return instance?.changeView(...a) }
function addEvent(...a) { return instance?.addEvent(...a) }
function removeEvent(id) { instance?.getEventById(id)?.remove() }
function today() { instance?.today() }
function prev() { instance?.prev() }
function next() { instance?.next() }
function gotoDate(...a) { instance?.gotoDate(...a) }
function getDate() { return instance ? instance.getDate() : null }
function getEvents() { return instance ? instance.getEvents() : [] }
function scrollToTime(...a) { instance?.scrollToTime(...a) }
function updateSize() { instance?.updateSize() }
function prevYear() { instance?.prevYear() }
function nextYear() { instance?.nextYear() }
function selectRange(...a) { instance?.select(...a) }
function clearSelection() { instance?.unselect() }
$expose({
getApi, changeView, addEvent, removeEvent, today, prev, next, gotoDate,
getDate, getEvents, scrollToTime, updateSize, prevYear, nextYear,
selectRange, clearSelection,
})
</script>
<template>
<div class="rozie-fullcalendar" />
<!--
Portal-slot primitive (Spike 003). The `event` slot is declared but NOT
rendered in the template — per-target template emitters skip it. It exists
only to declare the consumer-facing render-prop / scoped-slot / contentChild
shape (per target). The wrapper invokes the slot from script via
$portals.event(node, { arg }) inside FullCalendar's `eventContent` callback;
the portal helper mounts the consumer's fragment into the engine-owned node
and returns a dispose handle the engine calls on cell teardown.
-->
<slot name="event" portal :params="['arg']" />
<slot name="dayCell" portal :params="['arg']" />
<slot name="dayHeader" portal :params="['arg']" />
<slot name="slotLabel" portal :params="['arg']" />
<slot name="weekNumber" portal :params="['arg']" />
<slot name="nowIndicatorContent" portal :params="['arg']" />
<slot name="moreLink" portal :params="['arg']" />
<slot name="allDayContent" portal :params="['arg']" />
<slot name="slotLaneContent" portal :params="['arg']" />
<slot name="noEventsContent" portal :params="['arg']" />
</template>
<style>
.rozie-fullcalendar {
width: 100%;
font-size: 0.875rem;
}
</style>
</rozie>…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/fullcalendar-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { flushSync } from 'react-dom';
import { useControllableState } from '@rozie/runtime-react';
import './FullCalendar.css';
import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
interface EventCtx { arg: any; }
interface DayCellCtx { arg: any; }
interface DayHeaderCtx { arg: any; }
interface SlotLabelCtx { arg: any; }
interface WeekNumberCtx { arg: any; }
interface NowIndicatorContentCtx { arg: any; }
interface MoreLinkCtx { arg: any; }
interface AllDayContentCtx { arg: any; }
interface SlotLaneContentCtx { arg: any; }
interface NoEventsContentCtx { arg: any; }
interface FullCalendarProps {
/**
* The event objects rendered on the calendar. Each event is normalized: a missing `title` falls back to `Event <id>`, and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array reconciles the live calendar via `removeAllEvents` + `addEvent`.
*/
events?: any[];
/**
* The two-way active view name (`'dayGridMonth'`, `'timeGridWeek'`, `'timeGridDay'`, …) — the sole `model: true` prop. The calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the view via `changeView`.
* @example
* <FullCalendar r-model:view="view" :events="events" />
*/
view?: string;
defaultView?: string;
onViewChange?: (view: string) => void;
/**
* Show the Saturday/Sunday columns. Runtime-updatable via `setOption`.
*/
weekends?: boolean;
/**
* Allow events to be dragged and resized. Runtime-updatable via `setOption`.
*/
editable?: boolean;
/**
* Allow date/time-range selection by click-drag. Runtime-updatable via `setOption`.
*/
selectable?: boolean;
/**
* Calendar height in pixels. Runtime-updatable via `setOption`.
*/
height?: number;
/**
* Fallback event color stamped onto events that omit their own `color`.
*/
defaultColor?: string;
/**
* FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through `setOption` via the imperative handle if needed.
*/
locale?: string;
/**
* First day of the week (`0` = Sunday … `1` = Monday). Runtime-updatable via `setOption`.
*/
firstDay?: number;
/**
* Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.
*/
slotDuration?: string;
/**
* Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.
*/
nowIndicator?: boolean;
/**
* The toolbar layout (`{ left, center, right }`). A consumer-passed object **fully replaces** the built-in default rather than merging with it. Runtime-updatable via `setOption`.
*/
headerToolbar?: Record<string, any>;
/**
* Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface does not special-case (`businessHours`, `dayMaxEvents`, `*DidMount` hooks, locale objects, …). Spread **first** into the engine config so the curated props/events/slots win on key collision; `:options` only fills gaps. Runtime-updatable per key via `setOption` (no key-removal reset — a removed key keeps its last applied value until remount; use `getApi()` for full imperative control). The `plugins` key is the one exception that **merges** with the baked-in defaults instead of overriding them, making the wrapper consumer-extensible.
*/
options?: Record<string, any>;
onEventClick?: (...args: any[]) => void;
onDateClick?: (...args: any[]) => void;
onEventDrop?: (...args: any[]) => void;
onSelect?: (...args: any[]) => void;
onEventResize?: (...args: any[]) => void;
onDatesSet?: (...args: any[]) => void;
onEventMouseEnter?: (...args: any[]) => void;
onEventMouseLeave?: (...args: any[]) => void;
onUnselect?: (...args: any[]) => void;
onLoading?: (...args: any[]) => void;
onEventsSet?: (...args: any[]) => void;
renderEvent?: (ctx: EventCtx) => ReactNode;
renderDayCell?: (ctx: DayCellCtx) => ReactNode;
renderDayHeader?: (ctx: DayHeaderCtx) => ReactNode;
renderSlotLabel?: (ctx: SlotLabelCtx) => ReactNode;
renderWeekNumber?: (ctx: WeekNumberCtx) => ReactNode;
renderNowIndicatorContent?: (ctx: NowIndicatorContentCtx) => ReactNode;
renderMoreLink?: (ctx: MoreLinkCtx) => ReactNode;
renderAllDayContent?: (ctx: AllDayContentCtx) => ReactNode;
renderSlotLaneContent?: (ctx: SlotLaneContentCtx) => ReactNode;
renderNoEventsContent?: (ctx: NoEventsContentCtx) => ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export interface FullCalendarHandle {
getApi: (...args: any[]) => any;
changeView: (...args: any[]) => any;
addEvent: (...args: any[]) => any;
removeEvent: (...args: any[]) => any;
today: (...args: any[]) => any;
prev: (...args: any[]) => any;
next: (...args: any[]) => any;
gotoDate: (...args: any[]) => any;
getDate: (...args: any[]) => any;
getEvents: (...args: any[]) => any;
scrollToTime: (...args: any[]) => any;
updateSize: (...args: any[]) => any;
prevYear: (...args: any[]) => any;
nextYear: (...args: any[]) => any;
selectRange: (...args: any[]) => any;
clearSelection: (...args: any[]) => any;
}
const FullCalendar = forwardRef<FullCalendarHandle, FullCalendarProps>(function FullCalendar(_props: FullCalendarProps, ref): JSX.Element {
const portalRoots = useRef<Set<Root>>(new Set());
const __defaultEvents = useState(() => (() => [])())[0];
const __defaultHeaderToolbar = useState(() => (() => ({
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
}))())[0];
const __defaultOptions = useState(() => (() => ({}))())[0];
const props: Omit<FullCalendarProps, 'events' | 'weekends' | 'editable' | 'selectable' | 'height' | 'defaultColor' | 'locale' | 'firstDay' | 'slotDuration' | 'nowIndicator' | 'headerToolbar' | 'options'> & { events: any[]; weekends: boolean; editable: boolean; selectable: boolean; height: number; defaultColor: string; locale: string; firstDay: number; slotDuration: string; nowIndicator: boolean; headerToolbar: Record<string, any>; options: Record<string, any> } = {
..._props,
events: _props.events ?? __defaultEvents,
weekends: _props.weekends ?? true,
editable: _props.editable ?? true,
selectable: _props.selectable ?? true,
height: _props.height ?? 480,
defaultColor: _props.defaultColor ?? '#3b82f6',
locale: _props.locale ?? 'en',
firstDay: _props.firstDay ?? 0,
slotDuration: _props.slotDuration ?? '00:30:00',
nowIndicator: _props.nowIndicator ?? false,
headerToolbar: _props.headerToolbar ?? __defaultHeaderToolbar,
options: _props.options ?? __defaultOptions,
};
const _renderEventRef = useRef(props.renderEvent);
_renderEventRef.current = props.renderEvent;
const _renderDayCellRef = useRef(props.renderDayCell);
_renderDayCellRef.current = props.renderDayCell;
const _renderDayHeaderRef = useRef(props.renderDayHeader);
_renderDayHeaderRef.current = props.renderDayHeader;
const _renderSlotLabelRef = useRef(props.renderSlotLabel);
_renderSlotLabelRef.current = props.renderSlotLabel;
const _renderWeekNumberRef = useRef(props.renderWeekNumber);
_renderWeekNumberRef.current = props.renderWeekNumber;
const _renderNowIndicatorContentRef = useRef(props.renderNowIndicatorContent);
_renderNowIndicatorContentRef.current = props.renderNowIndicatorContent;
const _renderMoreLinkRef = useRef(props.renderMoreLink);
_renderMoreLinkRef.current = props.renderMoreLink;
const _renderAllDayContentRef = useRef(props.renderAllDayContent);
_renderAllDayContentRef.current = props.renderAllDayContent;
const _renderSlotLaneContentRef = useRef(props.renderSlotLaneContent);
_renderSlotLaneContentRef.current = props.renderSlotLaneContent;
const _renderNoEventsContentRef = useRef(props.renderNoEventsContent);
_renderNoEventsContentRef.current = props.renderNoEventsContent;
const suppressViewSync = useRef(false);
const instance = useRef<any>(null);
const [view, setView] = useControllableState({
value: props.view,
defaultValue: props.defaultView ?? 'dayGridMonth',
onValueChange: props.onViewChange,
});
const _editableRef = useRef(props.editable);
_editableRef.current = props.editable;
const _eventsRef = useRef(props.events);
_eventsRef.current = props.events;
const _firstDayRef = useRef(props.firstDay);
_firstDayRef.current = props.firstDay;
const _headerToolbarRef = useRef(props.headerToolbar);
_headerToolbarRef.current = props.headerToolbar;
const _heightRef = useRef(props.height);
_heightRef.current = props.height;
const _localeRef = useRef(props.locale);
_localeRef.current = props.locale;
const _nowIndicatorRef = useRef(props.nowIndicator);
_nowIndicatorRef.current = props.nowIndicator;
const _optionsRef = useRef(props.options);
_optionsRef.current = props.options;
const _selectableRef = useRef(props.selectable);
_selectableRef.current = props.selectable;
const _slotDurationRef = useRef(props.slotDuration);
_slotDurationRef.current = props.slotDuration;
const _weekendsRef = useRef(props.weekends);
_weekendsRef.current = props.weekends;
const _viewRef = useRef(view);
_viewRef.current = view;
const __rozieRoot = useRef<HTMLDivElement | null>(null);
const _watch0First = useRef(true);
const _watch1First = useRef(true);
const _watch2First = useRef(true);
const _watch3First = useRef(true);
const _watch4First = useRef(true);
const _watch5First = useRef(true);
const _watch6First = useRef(true);
const _watch7First = useRef(true);
const _watch8First = useRef(true);
const _watch9First = useRef(true);
const _watch10First = useRef(true);
const _watch11First = useRef(true);
const PLUGINS = useMemo(() => [dayGridPlugin, timeGridPlugin, interactionPlugin], []);
const normalizeEvent = useCallback((e: any) => {
// Object spread + template-literal default — common reconcile shape:
// pass user props through, but stamp a sensible title fallback and
// honor the wrapper's defaultColor only when the event omits one.
return {
...e,
title: e.title || `Event ${e.id ?? '(no id)'}`,
color: e.color || props.defaultColor
};
}, [props.defaultColor]);
// Imperative handle (Phase 21 $expose). The 16 calendar verbs a consumer can't
// drive through props alone — exposed uniformly to all 6 targets
// (Vue defineExpose / React useImperativeHandle / Svelte instance export /
// Angular+Lit public method / Solid callback ref). Each delegates to the
// underlying Calendar instance, which is null before $onMount and after
// destroy — callers handle the pre-mount null.
//
// Collision discipline (the load-bearing flatpickr lesson): no exposed name may
// collide with an emitted event (eventClick/dateClick/eventDrop/eventResize/
// datesSet/eventMouseEnter/eventMouseLeave/eventsSet/loading/select/unselect) or
// a declared prop. This is why the selection verbs are NAMED `selectRange`
// (CalendarApi.select) and `clearSelection` (CalendarApi.unselect) — bare
// `select`/`unselect` collide with the same-named emits (ROZ121), and `select`
// is also Lit-risky. getApi returns the raw Calendar instance (NOT guard-nulled).
//
// Read-back gap closed: getDate (current anchor — the `view` model only carries
// the view TYPE, datesSet only the visible RANGE) and getEvents (synchronous
// event read — eventsSet is push-only). scrollToTime/updateSize cover timeGrid
// scroll + container-resize relayout; prevYear/nextYear mirror prev/next.
function getApi() {
return instance.current;
}
function changeView(...a: any[]) {
return instance.current?.changeView(...a);
}
function addEvent(...a: any[]) {
return instance.current?.addEvent(...a);
}
function removeEvent(id: any) {
instance.current?.getEventById(id)?.remove();
}
function today() {
instance.current?.today();
}
function prev() {
instance.current?.prev();
}
function next() {
instance.current?.next();
}
function gotoDate(...a: any[]) {
instance.current?.gotoDate(...a);
}
function getDate() {
return instance.current ? instance.current.getDate() : null;
}
function getEvents() {
return instance.current ? instance.current.getEvents() : [];
}
function scrollToTime(...a: any[]) {
instance.current?.scrollToTime(...a);
}
function updateSize() {
instance.current?.updateSize();
}
function prevYear() {
instance.current?.prevYear();
}
function nextYear() {
instance.current?.nextYear();
}
function selectRange(...a: any[]) {
instance.current?.select(...a);
}
function clearSelection() {
instance.current?.unselect();
}
useEffect(() => {
const portals = {
event: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _renderEventRef.current ?? props.slots?.['event'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
// Cascades the @portal event { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-event', '5589629a');
const root = createRoot(container);
flushSync(() => root.render(slot(scope)));
portalRoots.current.add(root);
return () => {
root.unmount();
portalRoots.current.delete(root);
};
},
dayCell: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _renderDayCellRef.current ?? props.slots?.['dayCell'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
// Cascades the @portal dayCell { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-dayCell', '5589629a');
const root = createRoot(container);
flushSync(() => root.render(slot(scope)));
portalRoots.current.add(root);
return () => {
root.unmount();
portalRoots.current.delete(root);
};
},
dayHeader: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _renderDayHeaderRef.current ?? props.slots?.['dayHeader'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
// Cascades the @portal dayHeader { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-dayHeader', '5589629a');
const root = createRoot(container);
flushSync(() => root.render(slot(scope)));
portalRoots.current.add(root);
return () => {
root.unmount();
portalRoots.current.delete(root);
};
},
slotLabel: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _renderSlotLabelRef.current ?? props.slots?.['slotLabel'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
// Cascades the @portal slotLabel { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-slotLabel', '5589629a');
const root = createRoot(container);
flushSync(() => root.render(slot(scope)));
portalRoots.current.add(root);
return () => {
root.unmount();
portalRoots.current.delete(root);
};
},
weekNumber: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _renderWeekNumberRef.current ?? props.slots?.['weekNumber'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
// Cascades the @portal weekNumber { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-weekNumber', '5589629a');
const root = createRoot(container);
flushSync(() => root.render(slot(scope)));
portalRoots.current.add(root);
return () => {
root.unmount();
portalRoots.current.delete(root);
};
},
nowIndicatorContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _renderNowIndicatorContentRef.current ?? props.slots?.['nowIndicatorContent'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
// Cascades the @portal nowIndicatorContent { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-nowIndicatorContent', '5589629a');
const root = createRoot(container);
flushSync(() => root.render(slot(scope)));
portalRoots.current.add(root);
return () => {
root.unmount();
portalRoots.current.delete(root);
};
},
moreLink: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _renderMoreLinkRef.current ?? props.slots?.['moreLink'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
// Cascades the @portal moreLink { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-moreLink', '5589629a');
const root = createRoot(container);
flushSync(() => root.render(slot(scope)));
portalRoots.current.add(root);
return () => {
root.unmount();
portalRoots.current.delete(root);
};
},
allDayContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _renderAllDayContentRef.current ?? props.slots?.['allDayContent'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
// Cascades the @portal allDayContent { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-allDayContent', '5589629a');
const root = createRoot(container);
flushSync(() => root.render(slot(scope)));
portalRoots.current.add(root);
return () => {
root.unmount();
portalRoots.current.delete(root);
};
},
slotLaneContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _renderSlotLaneContentRef.current ?? props.slots?.['slotLaneContent'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
// Cascades the @portal slotLaneContent { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-slotLaneContent', '5589629a');
const root = createRoot(container);
flushSync(() => root.render(slot(scope)));
portalRoots.current.add(root);
return () => {
root.unmount();
portalRoots.current.delete(root);
};
},
noEventsContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _renderNoEventsContentRef.current ?? props.slots?.['noEventsContent'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
// Cascades the @portal noEventsContent { … } selectors from the
// component's .module.css into the engine-owned subtree.
container.setAttribute('data-rozie-portal-noEventsContent', '5589629a');
const root = createRoot(container);
flushSync(() => root.render(slot(scope)));
portalRoots.current.add(root);
return () => {
root.unmount();
portalRoots.current.delete(root);
};
},
};
const opts: Record<string, any> = {
// :options passthrough spread FIRST — the curated keys below + the portal
// *Content handlers added after this object override any colliding key, so
// an explicitly-bound prop (e.g. :height) wins over options.height.
//
// EXCEPTION — `plugins` is the one curated key that AUGMENTS rather than
// overrides: instead of clobbering a consumer-supplied `:options.plugins`,
// it MERGES the always-on baked-in defaults (dayGrid + timeGrid +
// interaction) with any consumer-added plugins. This makes the wrapper
// consumer-extensible (opt-in) — a consumer can engage list/rrule/premium/
// etc. via `:options="{ plugins: [listPlugin] }"` with NO bundle cost and NO
// per-plugin wrapper code. FullCalendar dedupes plugins by identity, so a
// consumer re-passing a default is harmless.
..._optionsRef.current,
plugins: [...PLUGINS, ...(_optionsRef.current?.plugins ?? [])],
initialView: _viewRef.current,
weekends: _weekendsRef.current,
editable: _editableRef.current,
selectable: _selectableRef.current,
height: _heightRef.current,
locale: _localeRef.current,
firstDay: _firstDayRef.current,
slotDuration: _slotDurationRef.current,
nowIndicator: _nowIndicatorRef.current,
events: _eventsRef.current.map(normalizeEvent),
// D-02: a consumer-passed headerToolbar fully REPLACES the built-in
// toolbar; the built-in default lives in the `headerToolbar` prop default.
headerToolbar: _headerToolbarRef.current,
eventClick: (info: any) => {
props.onEventClick && props.onEventClick({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
dateClick: (info: any) => {
props.onDateClick && props.onDateClick({
date: info.date,
dateStr: info.dateStr,
allDay: info.allDay
});
},
eventDrop: (info: any) => {
props.onEventDrop && props.onEventDrop({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
delta: info.delta
});
},
select: (info: any) => {
props.onSelect && props.onSelect({
start: info.start,
end: info.end,
startStr: info.startStr,
endStr: info.endStr,
allDay: info.allDay
});
},
eventResize: (info: any) => {
props.onEventResize && props.onEventResize({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
startDelta: info.startDelta,
endDelta: info.endDelta
});
},
datesSet: (info: any) => {
props.onDatesSet && props.onDatesSet({
start: info.start,
end: info.end,
view: info.view.type
});
},
eventMouseEnter: (info: any) => {
props.onEventMouseEnter && props.onEventMouseEnter({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
eventMouseLeave: (info: any) => {
props.onEventMouseLeave && props.onEventMouseLeave({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
unselect: (info: any) => {
props.onUnselect && props.onUnselect({
jsEvent: info.jsEvent
});
},
loading: (isLoading: any) => {
// FullCalendar's `loading` callback receives a bare boolean (not an info
// object) — normalize to the structured `{ isLoading }` payload shape.
props.onLoading && props.onLoading({
isLoading
});
},
eventsSet: (events: any) => {
// `eventsSet` receives the array of current EventApi objects — map each to
// the normalized floor shape for persistence/sync consumers.
props.onEventsSet && props.onEventsSet({
events: events.map((e: any) => ({
id: e.id,
title: e.title,
start: e.start,
end: e.end
}))
});
},
viewDidMount: (info: any) => {
// viewDidMount fires both on initial mount AND on changeView calls.
// Same round-trip guard pattern as Flatpickr / LeafletMap.
if (suppressViewSync.current) {
suppressViewSync.current = false;
return;
}
if (info.view.type !== _viewRef.current) setView(info.view.type);
}
};
// Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
// slot, route every cell render through it. The portal helper mounts the
// consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
// Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
// handle is returned to FullCalendar so it cleans up the mounted tree when
// the cell is removed. Consumers that don't fill the slot get FullCalendar's
// default rendering (title text) — guarded by `$slots.event`.
if ((props.renderEvent ?? props.slots?.["event"])) {
opts.eventContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.event(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
// The 9 remaining *Content portal-slots — wired identically to `event`, one
// per FullCalendar per-cell content hook. Each guarded by its own slot so
// unfilled slots keep FullCalendar's default rendering. (10 portal-slots total
// counting `event` above; allDayContent + slotLaneContent are the two timeGrid
// axis/lane hooks, and noEventsContent is the list-view "no events" hook —
// inert unless the consumer engages @fullcalendar/list via :options.plugins.)
//
// NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
// hook (`nowIndicatorContent`) so it does NOT clash with the boolean
// `nowIndicator` PROP — a slot name that equals a declared prop name is now a
// hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
// unifies snippets and props into one `$props` namespace.
if ((props.renderDayCell ?? props.slots?.["dayCell"])) {
opts.dayCellContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.dayCell(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((props.renderDayHeader ?? props.slots?.["dayHeader"])) {
opts.dayHeaderContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.dayHeader(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((props.renderSlotLabel ?? props.slots?.["slotLabel"])) {
opts.slotLabelContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.slotLabel(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((props.renderWeekNumber ?? props.slots?.["weekNumber"])) {
opts.weekNumberContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.weekNumber(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((props.renderNowIndicatorContent ?? props.slots?.["nowIndicatorContent"])) {
opts.nowIndicatorContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.nowIndicatorContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((props.renderMoreLink ?? props.slots?.["moreLink"])) {
opts.moreLinkContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.moreLink(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((props.renderAllDayContent ?? props.slots?.["allDayContent"])) {
opts.allDayContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.allDayContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((props.renderSlotLaneContent ?? props.slots?.["slotLaneContent"])) {
opts.slotLaneContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.slotLaneContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
// noEventsContent — the list-view "no events to display" hook. Pre-declared
// and wired like the other 9 *Content slots, but INERT unless the consumer
// (a) engages @fullcalendar/list via the now-merged :options.plugins AND
// (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
// the bundled-only plugin set there is no list view, so this hook never fires
// — by design, documented, zero bundle cost.
if ((props.renderNoEventsContent ?? props.slots?.["noEventsContent"])) {
opts.noEventsContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.noEventsContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
instance.current = new Calendar(__rozieRoot.current!, opts);
instance.current.render();
return () => {
for (const root of portalRoots.current) root.unmount();
portalRoots.current.clear();
instance.current?.destroy();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
const v = props.events;
if (!instance.current) return;
instance.current.removeAllEvents();
for (const e of v as any) instance.current.addEvent(normalizeEvent(e));
}, [props.events]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch1First.current) { _watch1First.current = false; return; }
const v = view;
if (!instance.current || !v) return;
if (v === instance.current.view.type) return;
suppressViewSync.current = true;
instance.current.changeView(v);
}, [view]);
useEffect(() => {
if (_watch2First.current) { _watch2First.current = false; return; }
const v = props.weekends;
instance.current?.setOption('weekends', v);
}, [props.weekends]);
useEffect(() => {
if (_watch3First.current) { _watch3First.current = false; return; }
const v = props.editable;
instance.current?.setOption('editable', v);
}, [props.editable]);
useEffect(() => {
if (_watch4First.current) { _watch4First.current = false; return; }
const v = props.selectable;
instance.current?.setOption('selectable', v);
}, [props.selectable]);
useEffect(() => {
if (_watch5First.current) { _watch5First.current = false; return; }
const v = props.height;
instance.current?.setOption('height', v);
}, [props.height]);
useEffect(() => {
if (_watch6First.current) { _watch6First.current = false; return; }
const v = props.locale;
instance.current?.setOption('locale', v);
}, [props.locale]);
useEffect(() => {
if (_watch7First.current) { _watch7First.current = false; return; }
const v = props.firstDay;
instance.current?.setOption('firstDay', v);
}, [props.firstDay]);
useEffect(() => {
if (_watch8First.current) { _watch8First.current = false; return; }
const v = props.slotDuration;
instance.current?.setOption('slotDuration', v);
}, [props.slotDuration]);
useEffect(() => {
if (_watch9First.current) { _watch9First.current = false; return; }
const v = props.nowIndicator;
instance.current?.setOption('nowIndicator', v);
}, [props.nowIndicator]);
useEffect(() => {
if (_watch10First.current) { _watch10First.current = false; return; }
const v = props.headerToolbar;
instance.current?.setOption('headerToolbar', v);
}, [props.headerToolbar]);
useEffect(() => {
if (_watch11First.current) { _watch11First.current = false; return; }
const v = props.options;
if (!instance.current) return;
for (const k in v) instance.current.setOption(k, v[k]);
}, [props.options]);
const _rozieExposeRef = useRef({ getApi, changeView, addEvent, removeEvent, today, prev, next, gotoDate, getDate, getEvents, scrollToTime, updateSize, prevYear, nextYear, selectRange, clearSelection });
_rozieExposeRef.current = { getApi, changeView, addEvent, removeEvent, today, prev, next, gotoDate, getDate, getEvents, scrollToTime, updateSize, prevYear, nextYear, selectRange, clearSelection };
useImperativeHandle(ref, () => ({ getApi: (...args: Parameters<typeof getApi>): ReturnType<typeof getApi> => _rozieExposeRef.current.getApi(...args), changeView: (...args: Parameters<typeof changeView>): ReturnType<typeof changeView> => _rozieExposeRef.current.changeView(...args), addEvent: (...args: Parameters<typeof addEvent>): ReturnType<typeof addEvent> => _rozieExposeRef.current.addEvent(...args), removeEvent: (...args: Parameters<typeof removeEvent>): ReturnType<typeof removeEvent> => _rozieExposeRef.current.removeEvent(...args), today: (...args: Parameters<typeof today>): ReturnType<typeof today> => _rozieExposeRef.current.today(...args), prev: (...args: Parameters<typeof prev>): ReturnType<typeof prev> => _rozieExposeRef.current.prev(...args), next: (...args: Parameters<typeof next>): ReturnType<typeof next> => _rozieExposeRef.current.next(...args), gotoDate: (...args: Parameters<typeof gotoDate>): ReturnType<typeof gotoDate> => _rozieExposeRef.current.gotoDate(...args), getDate: (...args: Parameters<typeof getDate>): ReturnType<typeof getDate> => _rozieExposeRef.current.getDate(...args), getEvents: (...args: Parameters<typeof getEvents>): ReturnType<typeof getEvents> => _rozieExposeRef.current.getEvents(...args), scrollToTime: (...args: Parameters<typeof scrollToTime>): ReturnType<typeof scrollToTime> => _rozieExposeRef.current.scrollToTime(...args), updateSize: (...args: Parameters<typeof updateSize>): ReturnType<typeof updateSize> => _rozieExposeRef.current.updateSize(...args), prevYear: (...args: Parameters<typeof prevYear>): ReturnType<typeof prevYear> => _rozieExposeRef.current.prevYear(...args), nextYear: (...args: Parameters<typeof nextYear>): ReturnType<typeof nextYear> => _rozieExposeRef.current.nextYear(...args), selectRange: (...args: Parameters<typeof selectRange>): ReturnType<typeof selectRange> => _rozieExposeRef.current.selectRange(...args), clearSelection: (...args: Parameters<typeof clearSelection>): ReturnType<typeof clearSelection> => _rozieExposeRef.current.clearSelection(...args) }), []);
return (
<>
<div className={"rozie-fullcalendar"} ref={__rozieRoot} data-rozie-s-5589629a="" />
</>
);
});
export default FullCalendar;vue
<template>
<div class="rozie-fullcalendar" ref="__rozieRootRef"></div>
</template>
<script setup lang="ts">
import { Fragment, h, onBeforeUnmount, onMounted, ref, render, useSlots, watch } from 'vue';
const props = withDefaults(
defineProps<{
/**
* The event objects rendered on the calendar. Each event is normalized: a missing `title` falls back to `Event <id>`, and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array reconciles the live calendar via `removeAllEvents` + `addEvent`.
*/
events?: any[];
/**
* Show the Saturday/Sunday columns. Runtime-updatable via `setOption`.
*/
weekends?: boolean;
/**
* Allow events to be dragged and resized. Runtime-updatable via `setOption`.
*/
editable?: boolean;
/**
* Allow date/time-range selection by click-drag. Runtime-updatable via `setOption`.
*/
selectable?: boolean;
/**
* Calendar height in pixels. Runtime-updatable via `setOption`.
*/
height?: number;
/**
* Fallback event color stamped onto events that omit their own `color`.
*/
defaultColor?: string;
/**
* FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through `setOption` via the imperative handle if needed.
*/
locale?: string;
/**
* First day of the week (`0` = Sunday … `1` = Monday). Runtime-updatable via `setOption`.
*/
firstDay?: number;
/**
* Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.
*/
slotDuration?: string;
/**
* Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.
*/
nowIndicator?: boolean;
/**
* The toolbar layout (`{ left, center, right }`). A consumer-passed object **fully replaces** the built-in default rather than merging with it. Runtime-updatable via `setOption`.
*/
headerToolbar?: Record<string, any>;
/**
* Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface does not special-case (`businessHours`, `dayMaxEvents`, `*DidMount` hooks, locale objects, …). Spread **first** into the engine config so the curated props/events/slots win on key collision; `:options` only fills gaps. Runtime-updatable per key via `setOption` (no key-removal reset — a removed key keeps its last applied value until remount; use `getApi()` for full imperative control). The `plugins` key is the one exception that **merges** with the baked-in defaults instead of overriding them, making the wrapper consumer-extensible.
*/
options?: Record<string, any>;
}>(),
{ events: () => [], weekends: true, editable: true, selectable: true, height: 480, defaultColor: '#3b82f6', locale: 'en', firstDay: 0, slotDuration: '00:30:00', nowIndicator: false, headerToolbar: () => ({
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
}), options: () => ({}) }
);
/**
* The two-way active view name (`'dayGridMonth'`, `'timeGridWeek'`, `'timeGridDay'`, …) — the sole `model: true` prop. The calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the view via `changeView`.
* @example
* <FullCalendar r-model:view="view" :events="events" />
*/
const view = defineModel<string>('view', { default: 'dayGridMonth' });
const emit = defineEmits<{
eventClick: [...args: any[]];
dateClick: [...args: any[]];
eventDrop: [...args: any[]];
select: [...args: any[]];
eventResize: [...args: any[]];
datesSet: [...args: any[]];
eventMouseEnter: [...args: any[]];
eventMouseLeave: [...args: any[]];
unselect: [...args: any[]];
loading: [...args: any[]];
eventsSet: [...args: any[]];
}>();
defineSlots<{
event(props: { arg: any }): any;
dayCell(props: { arg: any }): any;
dayHeader(props: { arg: any }): any;
slotLabel(props: { arg: any }): any;
weekNumber(props: { arg: any }): any;
nowIndicatorContent(props: { arg: any }): any;
moreLink(props: { arg: any }): any;
allDayContent(props: { arg: any }): any;
slotLaneContent(props: { arg: any }): any;
noEventsContent(props: { arg: any }): any;
}>();
const slots = useSlots();
const __rozieRootRef = ref<HTMLElement>();
import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
let instance: any = null;
let suppressViewSync = false;
const PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];
const normalizeEvent = (e: any) => {
// Object spread + template-literal default — common reconcile shape:
// pass user props through, but stamp a sensible title fallback and
// honor the wrapper's defaultColor only when the event omits one.
return {
...e,
title: e.title || `Event ${e.id ?? '(no id)'}`,
color: e.color || props.defaultColor
};
};
// Imperative handle (Phase 21 $expose). The 16 calendar verbs a consumer can't
// drive through props alone — exposed uniformly to all 6 targets
// (Vue defineExpose / React useImperativeHandle / Svelte instance export /
// Angular+Lit public method / Solid callback ref). Each delegates to the
// underlying Calendar instance, which is null before $onMount and after
// destroy — callers handle the pre-mount null.
//
// Collision discipline (the load-bearing flatpickr lesson): no exposed name may
// collide with an emitted event (eventClick/dateClick/eventDrop/eventResize/
// datesSet/eventMouseEnter/eventMouseLeave/eventsSet/loading/select/unselect) or
// a declared prop. This is why the selection verbs are NAMED `selectRange`
// (CalendarApi.select) and `clearSelection` (CalendarApi.unselect) — bare
// `select`/`unselect` collide with the same-named emits (ROZ121), and `select`
// is also Lit-risky. getApi returns the raw Calendar instance (NOT guard-nulled).
//
// Read-back gap closed: getDate (current anchor — the `view` model only carries
// the view TYPE, datesSet only the visible RANGE) and getEvents (synchronous
// event read — eventsSet is push-only). scrollToTime/updateSize cover timeGrid
// scroll + container-resize relayout; prevYear/nextYear mirror prev/next.
function getApi() {
return instance;
}
function changeView(...a: any[]) {
return instance?.changeView(...a);
}
function addEvent(...a: any[]) {
return instance?.addEvent(...a);
}
function removeEvent(id: any) {
instance?.getEventById(id)?.remove();
}
function today() {
instance?.today();
}
function prev() {
instance?.prev();
}
function next() {
instance?.next();
}
function gotoDate(...a: any[]) {
instance?.gotoDate(...a);
}
function getDate() {
return instance ? instance.getDate() : null;
}
function getEvents() {
return instance ? instance.getEvents() : [];
}
function scrollToTime(...a: any[]) {
instance?.scrollToTime(...a);
}
function updateSize() {
instance?.updateSize();
}
function prevYear() {
instance?.prevYear();
}
function nextYear() {
instance?.nextYear();
}
function selectRange(...a: any[]) {
instance?.select(...a);
}
function clearSelection() {
instance?.unselect();
}
const portalContainers = new Set<HTMLElement>();
const portals = {
event: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slotFn = slots.event;
if (!slotFn) return () => {};
// Spike 004: portal-scope attribute injection. Cascades the @portal
// event { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-event', '5589629a');
const vnode = h(Fragment, null, slotFn(scope));
render(vnode, container);
portalContainers.add(container);
return () => {
render(null, container);
portalContainers.delete(container);
};
},
dayCell: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slotFn = slots.dayCell;
if (!slotFn) return () => {};
// Spike 004: portal-scope attribute injection. Cascades the @portal
// dayCell { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-dayCell', '5589629a');
const vnode = h(Fragment, null, slotFn(scope));
render(vnode, container);
portalContainers.add(container);
return () => {
render(null, container);
portalContainers.delete(container);
};
},
dayHeader: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slotFn = slots.dayHeader;
if (!slotFn) return () => {};
// Spike 004: portal-scope attribute injection. Cascades the @portal
// dayHeader { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-dayHeader', '5589629a');
const vnode = h(Fragment, null, slotFn(scope));
render(vnode, container);
portalContainers.add(container);
return () => {
render(null, container);
portalContainers.delete(container);
};
},
slotLabel: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slotFn = slots.slotLabel;
if (!slotFn) return () => {};
// Spike 004: portal-scope attribute injection. Cascades the @portal
// slotLabel { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-slotLabel', '5589629a');
const vnode = h(Fragment, null, slotFn(scope));
render(vnode, container);
portalContainers.add(container);
return () => {
render(null, container);
portalContainers.delete(container);
};
},
weekNumber: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slotFn = slots.weekNumber;
if (!slotFn) return () => {};
// Spike 004: portal-scope attribute injection. Cascades the @portal
// weekNumber { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-weekNumber', '5589629a');
const vnode = h(Fragment, null, slotFn(scope));
render(vnode, container);
portalContainers.add(container);
return () => {
render(null, container);
portalContainers.delete(container);
};
},
nowIndicatorContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slotFn = slots.nowIndicatorContent;
if (!slotFn) return () => {};
// Spike 004: portal-scope attribute injection. Cascades the @portal
// nowIndicatorContent { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-nowIndicatorContent', '5589629a');
const vnode = h(Fragment, null, slotFn(scope));
render(vnode, container);
portalContainers.add(container);
return () => {
render(null, container);
portalContainers.delete(container);
};
},
moreLink: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slotFn = slots.moreLink;
if (!slotFn) return () => {};
// Spike 004: portal-scope attribute injection. Cascades the @portal
// moreLink { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-moreLink', '5589629a');
const vnode = h(Fragment, null, slotFn(scope));
render(vnode, container);
portalContainers.add(container);
return () => {
render(null, container);
portalContainers.delete(container);
};
},
allDayContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slotFn = slots.allDayContent;
if (!slotFn) return () => {};
// Spike 004: portal-scope attribute injection. Cascades the @portal
// allDayContent { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-allDayContent', '5589629a');
const vnode = h(Fragment, null, slotFn(scope));
render(vnode, container);
portalContainers.add(container);
return () => {
render(null, container);
portalContainers.delete(container);
};
},
slotLaneContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slotFn = slots.slotLaneContent;
if (!slotFn) return () => {};
// Spike 004: portal-scope attribute injection. Cascades the @portal
// slotLaneContent { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-slotLaneContent', '5589629a');
const vnode = h(Fragment, null, slotFn(scope));
render(vnode, container);
portalContainers.add(container);
return () => {
render(null, container);
portalContainers.delete(container);
};
},
noEventsContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slotFn = slots.noEventsContent;
if (!slotFn) return () => {};
// Spike 004: portal-scope attribute injection. Cascades the @portal
// noEventsContent { … } selectors from the unscoped <style> block below into
// the engine-owned subtree.
container.setAttribute('data-rozie-portal-noEventsContent', '5589629a');
const vnode = h(Fragment, null, slotFn(scope));
render(vnode, container);
portalContainers.add(container);
return () => {
render(null, container);
portalContainers.delete(container);
};
},
};
onBeforeUnmount(() => {
for (const container of portalContainers) render(null, container);
portalContainers.clear();
});
let _cleanup_0: (() => void) | undefined;
onMounted(() => {
const opts: Record<string, any> = {
// :options passthrough spread FIRST — the curated keys below + the portal
// *Content handlers added after this object override any colliding key, so
// an explicitly-bound prop (e.g. :height) wins over options.height.
//
// EXCEPTION — `plugins` is the one curated key that AUGMENTS rather than
// overrides: instead of clobbering a consumer-supplied `:options.plugins`,
// it MERGES the always-on baked-in defaults (dayGrid + timeGrid +
// interaction) with any consumer-added plugins. This makes the wrapper
// consumer-extensible (opt-in) — a consumer can engage list/rrule/premium/
// etc. via `:options="{ plugins: [listPlugin] }"` with NO bundle cost and NO
// per-plugin wrapper code. FullCalendar dedupes plugins by identity, so a
// consumer re-passing a default is harmless.
...props.options,
plugins: [...PLUGINS, ...(props.options?.plugins ?? [])],
initialView: view.value,
weekends: props.weekends,
editable: props.editable,
selectable: props.selectable,
height: props.height,
locale: props.locale,
firstDay: props.firstDay,
slotDuration: props.slotDuration,
nowIndicator: props.nowIndicator,
events: props.events.map(normalizeEvent),
// D-02: a consumer-passed headerToolbar fully REPLACES the built-in
// toolbar; the built-in default lives in the `headerToolbar` prop default.
headerToolbar: props.headerToolbar,
eventClick: (info: any) => {
emit('eventClick', {
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
dateClick: (info: any) => {
emit('dateClick', {
date: info.date,
dateStr: info.dateStr,
allDay: info.allDay
});
},
eventDrop: (info: any) => {
emit('eventDrop', {
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
delta: info.delta
});
},
select: (info: any) => {
emit('select', {
start: info.start,
end: info.end,
startStr: info.startStr,
endStr: info.endStr,
allDay: info.allDay
});
},
eventResize: (info: any) => {
emit('eventResize', {
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
startDelta: info.startDelta,
endDelta: info.endDelta
});
},
datesSet: (info: any) => {
emit('datesSet', {
start: info.start,
end: info.end,
view: info.view.type
});
},
eventMouseEnter: (info: any) => {
emit('eventMouseEnter', {
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
eventMouseLeave: (info: any) => {
emit('eventMouseLeave', {
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
unselect: (info: any) => {
emit('unselect', {
jsEvent: info.jsEvent
});
},
loading: (isLoading: any) => {
// FullCalendar's `loading` callback receives a bare boolean (not an info
// object) — normalize to the structured `{ isLoading }` payload shape.
emit('loading', {
isLoading
});
},
eventsSet: (events: any) => {
// `eventsSet` receives the array of current EventApi objects — map each to
// the normalized floor shape for persistence/sync consumers.
emit('eventsSet', {
events: events.map((e: any) => ({
id: e.id,
title: e.title,
start: e.start,
end: e.end
}))
});
},
viewDidMount: (info: any) => {
// viewDidMount fires both on initial mount AND on changeView calls.
// Same round-trip guard pattern as Flatpickr / LeafletMap.
if (suppressViewSync) {
suppressViewSync = false;
return;
}
if (info.view.type !== view.value) view.value = info.view.type;
}
};
// Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
// slot, route every cell render through it. The portal helper mounts the
// consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
// Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
// handle is returned to FullCalendar so it cleans up the mounted tree when
// the cell is removed. Consumers that don't fill the slot get FullCalendar's
// default rendering (title text) — guarded by `$slots.event`.
if (slots.event) {
opts.eventContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.event(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
// The 9 remaining *Content portal-slots — wired identically to `event`, one
// per FullCalendar per-cell content hook. Each guarded by its own slot so
// unfilled slots keep FullCalendar's default rendering. (10 portal-slots total
// counting `event` above; allDayContent + slotLaneContent are the two timeGrid
// axis/lane hooks, and noEventsContent is the list-view "no events" hook —
// inert unless the consumer engages @fullcalendar/list via :options.plugins.)
//
// NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
// hook (`nowIndicatorContent`) so it does NOT clash with the boolean
// `nowIndicator` PROP — a slot name that equals a declared prop name is now a
// hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
// unifies snippets and props into one `$props` namespace.
if (slots.dayCell) {
opts.dayCellContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.dayCell(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (slots.dayHeader) {
opts.dayHeaderContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.dayHeader(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (slots.slotLabel) {
opts.slotLabelContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.slotLabel(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (slots.weekNumber) {
opts.weekNumberContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.weekNumber(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (slots.nowIndicatorContent) {
opts.nowIndicatorContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.nowIndicatorContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (slots.moreLink) {
opts.moreLinkContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.moreLink(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (slots.allDayContent) {
opts.allDayContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.allDayContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (slots.slotLaneContent) {
opts.slotLaneContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.slotLaneContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
// noEventsContent — the list-view "no events to display" hook. Pre-declared
// and wired like the other 9 *Content slots, but INERT unless the consumer
// (a) engages @fullcalendar/list via the now-merged :options.plugins AND
// (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
// the bundled-only plugin set there is no list view, so this hook never fires
// — by design, documented, zero bundle cost.
if (slots.noEventsContent) {
opts.noEventsContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.noEventsContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
instance = new Calendar(__rozieRootRef.value!, opts);
instance.render();
_cleanup_0 = () => instance?.destroy();
});
onBeforeUnmount(() => { _cleanup_0?.(); });
watch(() => props.events, (v: any) => {
if (!instance) return;
instance.removeAllEvents();
for (const e of v as any) instance.addEvent(normalizeEvent(e));
});
watch(() => view.value, (v: any) => {
if (!instance || !v) return;
if (v === instance.view.type) return;
suppressViewSync = true;
instance.changeView(v);
});
watch(() => props.weekends, (v: any) => instance?.setOption('weekends', v));
watch(() => props.editable, (v: any) => instance?.setOption('editable', v));
watch(() => props.selectable, (v: any) => instance?.setOption('selectable', v));
watch(() => props.height, (v: any) => instance?.setOption('height', v));
watch(() => props.locale, (v: any) => instance?.setOption('locale', v));
watch(() => props.firstDay, (v: any) => instance?.setOption('firstDay', v));
watch(() => props.slotDuration, (v: any) => instance?.setOption('slotDuration', v));
watch(() => props.nowIndicator, (v: any) => instance?.setOption('nowIndicator', v));
watch(() => props.headerToolbar, (v: any) => instance?.setOption('headerToolbar', v));
watch(() => props.options, (v: any) => {
if (!instance) return;
for (const k in v) instance.setOption(k, v[k]);
});
defineExpose({ getApi, changeView, addEvent, removeEvent, today, prev, next, gotoDate, getDate, getEvents, scrollToTime, updateSize, prevYear, nextYear, selectRange, clearSelection });
</script>
<style scoped>
.rozie-fullcalendar {
width: 100%;
font-size: 0.875rem;
}
</style>svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { mount, unmount } from 'svelte';
import PortalHost from '@rozie/runtime-svelte/PortalHost.svelte';
import { onMount, untrack } from 'svelte';
interface Props {
/**
* The event objects rendered on the calendar. Each event is normalized: a missing `title` falls back to `Event <id>`, and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array reconciles the live calendar via `removeAllEvents` + `addEvent`.
*/
events?: any[];
/**
* The two-way active view name (`'dayGridMonth'`, `'timeGridWeek'`, `'timeGridDay'`, …) — the sole `model: true` prop. The calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the view via `changeView`.
* @example
* <FullCalendar r-model:view="view" :events="events" />
*/
view?: string;
/**
* Show the Saturday/Sunday columns. Runtime-updatable via `setOption`.
*/
weekends?: boolean;
/**
* Allow events to be dragged and resized. Runtime-updatable via `setOption`.
*/
editable?: boolean;
/**
* Allow date/time-range selection by click-drag. Runtime-updatable via `setOption`.
*/
selectable?: boolean;
/**
* Calendar height in pixels. Runtime-updatable via `setOption`.
*/
height?: number;
/**
* Fallback event color stamped onto events that omit their own `color`.
*/
defaultColor?: string;
/**
* FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through `setOption` via the imperative handle if needed.
*/
locale?: string;
/**
* First day of the week (`0` = Sunday … `1` = Monday). Runtime-updatable via `setOption`.
*/
firstDay?: number;
/**
* Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.
*/
slotDuration?: string;
/**
* Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.
*/
nowIndicator?: boolean;
/**
* The toolbar layout (`{ left, center, right }`). A consumer-passed object **fully replaces** the built-in default rather than merging with it. Runtime-updatable via `setOption`.
*/
headerToolbar?: any;
/**
* Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface does not special-case (`businessHours`, `dayMaxEvents`, `*DidMount` hooks, locale objects, …). Spread **first** into the engine config so the curated props/events/slots win on key collision; `:options` only fills gaps. Runtime-updatable per key via `setOption` (no key-removal reset — a removed key keeps its last applied value until remount; use `getApi()` for full imperative control). The `plugins` key is the one exception that **merges** with the baked-in defaults instead of overriding them, making the wrapper consumer-extensible.
*/
options?: any;
event?: Snippet<[{ arg: any }]>;
dayCell?: Snippet<[{ arg: any }]>;
dayHeader?: Snippet<[{ arg: any }]>;
slotLabel?: Snippet<[{ arg: any }]>;
weekNumber?: Snippet<[{ arg: any }]>;
nowIndicatorContent?: Snippet<[{ arg: any }]>;
moreLink?: Snippet<[{ arg: any }]>;
allDayContent?: Snippet<[{ arg: any }]>;
slotLaneContent?: Snippet<[{ arg: any }]>;
noEventsContent?: Snippet<[{ arg: any }]>;
snippets?: Record<string, any>;
oneventclick?: (...args: unknown[]) => void;
ondateclick?: (...args: unknown[]) => void;
oneventdrop?: (...args: unknown[]) => void;
onselect?: (...args: unknown[]) => void;
oneventresize?: (...args: unknown[]) => void;
ondatesset?: (...args: unknown[]) => void;
oneventmouseenter?: (...args: unknown[]) => void;
oneventmouseleave?: (...args: unknown[]) => void;
onunselect?: (...args: unknown[]) => void;
onloading?: (...args: unknown[]) => void;
oneventsset?: (...args: unknown[]) => void;
}
let __defaultEvents = (() => [])();
let __defaultHeaderToolbar = (() => ({
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
}))();
let __defaultOptions = (() => ({}))();
let {
events = __defaultEvents,
view = $bindable('dayGridMonth'),
weekends = true,
editable = true,
selectable = true,
height = 480,
defaultColor = '#3b82f6',
locale = 'en',
firstDay = 0,
slotDuration = '00:30:00',
nowIndicator = false,
headerToolbar = __defaultHeaderToolbar,
options = __defaultOptions,
event: __eventProp,
dayCell: __dayCellProp,
dayHeader: __dayHeaderProp,
slotLabel: __slotLabelProp,
weekNumber: __weekNumberProp,
nowIndicatorContent: __nowIndicatorContentProp,
moreLink: __moreLinkProp,
allDayContent: __allDayContentProp,
slotLaneContent: __slotLaneContentProp,
noEventsContent: __noEventsContentProp,
snippets,
oneventclick,
ondateclick,
oneventdrop,
onselect,
oneventresize,
ondatesset,
oneventmouseenter,
oneventmouseleave,
onunselect,
onloading,
oneventsset
}: Props = $props();
const event = $derived(__eventProp ?? snippets?.event);
const dayCell = $derived(__dayCellProp ?? snippets?.dayCell);
const dayHeader = $derived(__dayHeaderProp ?? snippets?.dayHeader);
const slotLabel = $derived(__slotLabelProp ?? snippets?.slotLabel);
const weekNumber = $derived(__weekNumberProp ?? snippets?.weekNumber);
const nowIndicatorContent = $derived(__nowIndicatorContentProp ?? snippets?.nowIndicatorContent);
const moreLink = $derived(__moreLinkProp ?? snippets?.moreLink);
const allDayContent = $derived(__allDayContentProp ?? snippets?.allDayContent);
const slotLaneContent = $derived(__slotLaneContentProp ?? snippets?.slotLaneContent);
const noEventsContent = $derived(__noEventsContentProp ?? snippets?.noEventsContent);
let __rozieRoot = $state<HTMLElement | undefined>(undefined);
import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
let instance: any = null;
let suppressViewSync = false;
const PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];
const normalizeEvent = (e: any) => {
// Object spread + template-literal default — common reconcile shape:
// pass user props through, but stamp a sensible title fallback and
// honor the wrapper's defaultColor only when the event omits one.
return {
...e,
title: e.title || `Event ${e.id ?? '(no id)'}`,
color: e.color || defaultColor
};
};
// Imperative handle (Phase 21 $expose). The 16 calendar verbs a consumer can't
// drive through props alone — exposed uniformly to all 6 targets
// (Vue defineExpose / React useImperativeHandle / Svelte instance export /
// Angular+Lit public method / Solid callback ref). Each delegates to the
// underlying Calendar instance, which is null before $onMount and after
// destroy — callers handle the pre-mount null.
//
// Collision discipline (the load-bearing flatpickr lesson): no exposed name may
// collide with an emitted event (eventClick/dateClick/eventDrop/eventResize/
// datesSet/eventMouseEnter/eventMouseLeave/eventsSet/loading/select/unselect) or
// a declared prop. This is why the selection verbs are NAMED `selectRange`
// (CalendarApi.select) and `clearSelection` (CalendarApi.unselect) — bare
// `select`/`unselect` collide with the same-named emits (ROZ121), and `select`
// is also Lit-risky. getApi returns the raw Calendar instance (NOT guard-nulled).
//
// Read-back gap closed: getDate (current anchor — the `view` model only carries
// the view TYPE, datesSet only the visible RANGE) and getEvents (synchronous
// event read — eventsSet is push-only). scrollToTime/updateSize cover timeGrid
// scroll + container-resize relayout; prevYear/nextYear mirror prev/next.
export function getApi() {
return instance;
}
export function changeView(...a: any[]) {
return instance?.changeView(...a);
}
export function addEvent(...a: any[]) {
return instance?.addEvent(...a);
}
export function removeEvent(id: any) {
instance?.getEventById(id)?.remove();
}
export function today() {
instance?.today();
}
export function prev() {
instance?.prev();
}
export function next() {
instance?.next();
}
export function gotoDate(...a: any[]) {
instance?.gotoDate(...a);
}
export function getDate() {
return instance ? instance.getDate() : null;
}
export function getEvents() {
return instance ? instance.getEvents() : [];
}
export function scrollToTime(...a: any[]) {
instance?.scrollToTime(...a);
}
export function updateSize() {
instance?.updateSize();
}
export function prevYear() {
instance?.prevYear();
}
export function nextYear() {
instance?.nextYear();
}
export function selectRange(...a: any[]) {
instance?.select(...a);
}
export function clearSelection() {
instance?.unselect();
}
const portalInstances = new Set<Record<string, unknown>>();
const portals = {
event: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
if (!event) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-event', '5589629a');
const inst = mount(PortalHost, {
target: container,
props: { snippet: event, scope },
});
portalInstances.add(inst as Record<string, unknown>);
return () => {
unmount(inst);
portalInstances.delete(inst as Record<string, unknown>);
};
},
dayCell: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
if (!dayCell) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-dayCell', '5589629a');
const inst = mount(PortalHost, {
target: container,
props: { snippet: dayCell, scope },
});
portalInstances.add(inst as Record<string, unknown>);
return () => {
unmount(inst);
portalInstances.delete(inst as Record<string, unknown>);
};
},
dayHeader: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
if (!dayHeader) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-dayHeader', '5589629a');
const inst = mount(PortalHost, {
target: container,
props: { snippet: dayHeader, scope },
});
portalInstances.add(inst as Record<string, unknown>);
return () => {
unmount(inst);
portalInstances.delete(inst as Record<string, unknown>);
};
},
slotLabel: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
if (!slotLabel) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-slotLabel', '5589629a');
const inst = mount(PortalHost, {
target: container,
props: { snippet: slotLabel, scope },
});
portalInstances.add(inst as Record<string, unknown>);
return () => {
unmount(inst);
portalInstances.delete(inst as Record<string, unknown>);
};
},
weekNumber: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
if (!weekNumber) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-weekNumber', '5589629a');
const inst = mount(PortalHost, {
target: container,
props: { snippet: weekNumber, scope },
});
portalInstances.add(inst as Record<string, unknown>);
return () => {
unmount(inst);
portalInstances.delete(inst as Record<string, unknown>);
};
},
nowIndicatorContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
if (!nowIndicatorContent) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-nowIndicatorContent', '5589629a');
const inst = mount(PortalHost, {
target: container,
props: { snippet: nowIndicatorContent, scope },
});
portalInstances.add(inst as Record<string, unknown>);
return () => {
unmount(inst);
portalInstances.delete(inst as Record<string, unknown>);
};
},
moreLink: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
if (!moreLink) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-moreLink', '5589629a');
const inst = mount(PortalHost, {
target: container,
props: { snippet: moreLink, scope },
});
portalInstances.add(inst as Record<string, unknown>);
return () => {
unmount(inst);
portalInstances.delete(inst as Record<string, unknown>);
};
},
allDayContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
if (!allDayContent) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-allDayContent', '5589629a');
const inst = mount(PortalHost, {
target: container,
props: { snippet: allDayContent, scope },
});
portalInstances.add(inst as Record<string, unknown>);
return () => {
unmount(inst);
portalInstances.delete(inst as Record<string, unknown>);
};
},
slotLaneContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
if (!slotLaneContent) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-slotLaneContent', '5589629a');
const inst = mount(PortalHost, {
target: container,
props: { snippet: slotLaneContent, scope },
});
portalInstances.add(inst as Record<string, unknown>);
return () => {
unmount(inst);
portalInstances.delete(inst as Record<string, unknown>);
};
},
noEventsContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
if (!noEventsContent) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-noEventsContent', '5589629a');
const inst = mount(PortalHost, {
target: container,
props: { snippet: noEventsContent, scope },
});
portalInstances.add(inst as Record<string, unknown>);
return () => {
unmount(inst);
portalInstances.delete(inst as Record<string, unknown>);
};
},
};
$effect(() => () => {
for (const inst of portalInstances) unmount(inst as Parameters<typeof unmount>[0]);
portalInstances.clear();
});
onMount(() => {
const opts = {
// :options passthrough spread FIRST — the curated keys below + the portal
// *Content handlers added after this object override any colliding key, so
// an explicitly-bound prop (e.g. :height) wins over options.height.
//
// EXCEPTION — `plugins` is the one curated key that AUGMENTS rather than
// overrides: instead of clobbering a consumer-supplied `:options.plugins`,
// it MERGES the always-on baked-in defaults (dayGrid + timeGrid +
// interaction) with any consumer-added plugins. This makes the wrapper
// consumer-extensible (opt-in) — a consumer can engage list/rrule/premium/
// etc. via `:options="{ plugins: [listPlugin] }"` with NO bundle cost and NO
// per-plugin wrapper code. FullCalendar dedupes plugins by identity, so a
// consumer re-passing a default is harmless.
...options,
plugins: [...PLUGINS, ...(options?.plugins ?? [])],
initialView: view,
weekends: weekends,
editable: editable,
selectable: selectable,
height: height,
locale: locale,
firstDay: firstDay,
slotDuration: slotDuration,
nowIndicator: nowIndicator,
events: events.map(normalizeEvent),
// D-02: a consumer-passed headerToolbar fully REPLACES the built-in
// toolbar; the built-in default lives in the `headerToolbar` prop default.
headerToolbar: headerToolbar,
eventClick: (info: any) => {
oneventclick?.({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
dateClick: (info: any) => {
ondateclick?.({
date: info.date,
dateStr: info.dateStr,
allDay: info.allDay
});
},
eventDrop: (info: any) => {
oneventdrop?.({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
delta: info.delta
});
},
select: (info: any) => {
onselect?.({
start: info.start,
end: info.end,
startStr: info.startStr,
endStr: info.endStr,
allDay: info.allDay
});
},
eventResize: (info: any) => {
oneventresize?.({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
startDelta: info.startDelta,
endDelta: info.endDelta
});
},
datesSet: (info: any) => {
ondatesset?.({
start: info.start,
end: info.end,
view: info.view.type
});
},
eventMouseEnter: (info: any) => {
oneventmouseenter?.({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
eventMouseLeave: (info: any) => {
oneventmouseleave?.({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
unselect: (info: any) => {
onunselect?.({
jsEvent: info.jsEvent
});
},
loading: (isLoading: any) => {
// FullCalendar's `loading` callback receives a bare boolean (not an info
// object) — normalize to the structured `{ isLoading }` payload shape.
onloading?.({
isLoading
});
},
eventsSet: (events: any) => {
// `eventsSet` receives the array of current EventApi objects — map each to
// the normalized floor shape for persistence/sync consumers.
oneventsset?.({
events: events.map((e: any) => ({
id: e.id,
title: e.title,
start: e.start,
end: e.end
}))
});
},
viewDidMount: (info: any) => {
// viewDidMount fires both on initial mount AND on changeView calls.
// Same round-trip guard pattern as Flatpickr / LeafletMap.
if (suppressViewSync) {
suppressViewSync = false;
return;
}
if (info.view.type !== view) view = info.view.type;
}
};
// Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
// slot, route every cell render through it. The portal helper mounts the
// consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
// Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
// handle is returned to FullCalendar so it cleans up the mounted tree when
// the cell is removed. Consumers that don't fill the slot get FullCalendar's
// default rendering (title text) — guarded by `$slots.event`.
if (event) {
opts.eventContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.event(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
// The 9 remaining *Content portal-slots — wired identically to `event`, one
// per FullCalendar per-cell content hook. Each guarded by its own slot so
// unfilled slots keep FullCalendar's default rendering. (10 portal-slots total
// counting `event` above; allDayContent + slotLaneContent are the two timeGrid
// axis/lane hooks, and noEventsContent is the list-view "no events" hook —
// inert unless the consumer engages @fullcalendar/list via :options.plugins.)
//
// NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
// hook (`nowIndicatorContent`) so it does NOT clash with the boolean
// `nowIndicator` PROP — a slot name that equals a declared prop name is now a
// hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
// unifies snippets and props into one `$props` namespace.
if (dayCell) {
opts.dayCellContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.dayCell(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (dayHeader) {
opts.dayHeaderContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.dayHeader(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (slotLabel) {
opts.slotLabelContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.slotLabel(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (weekNumber) {
opts.weekNumberContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.weekNumber(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (nowIndicatorContent) {
opts.nowIndicatorContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.nowIndicatorContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (moreLink) {
opts.moreLinkContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.moreLink(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (allDayContent) {
opts.allDayContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.allDayContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (slotLaneContent) {
opts.slotLaneContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.slotLaneContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
// noEventsContent — the list-view "no events to display" hook. Pre-declared
// and wired like the other 9 *Content slots, but INERT unless the consumer
// (a) engages @fullcalendar/list via the now-merged :options.plugins AND
// (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
// the bundled-only plugin set there is no list view, so this hook never fires
// — by design, documented, zero bundle cost.
if (noEventsContent) {
opts.noEventsContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.noEventsContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
instance = new Calendar(__rozieRoot!, opts);
instance.render();
return () => instance?.destroy();
});
let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => events)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((v: any) => {
if (!instance) return;
instance.removeAllEvents();
for (const e of v as any) instance.addEvent(normalizeEvent(e));
})(__watchVal); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { const __watchVal = (() => view)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } ((v: any) => {
if (!instance || !v) return;
if (v === instance.view.type) return;
suppressViewSync = true;
instance.changeView(v);
})(__watchVal); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { const __watchVal = (() => weekends)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } ((v: any) => instance?.setOption('weekends', v))(__watchVal); }); });
let __rozieWatchInitial_3 = true;
$effect(() => { const __watchVal = (() => editable)(); untrack(() => { if (__rozieWatchInitial_3) { __rozieWatchInitial_3 = false; return; } ((v: any) => instance?.setOption('editable', v))(__watchVal); }); });
let __rozieWatchInitial_4 = true;
$effect(() => { const __watchVal = (() => selectable)(); untrack(() => { if (__rozieWatchInitial_4) { __rozieWatchInitial_4 = false; return; } ((v: any) => instance?.setOption('selectable', v))(__watchVal); }); });
let __rozieWatchInitial_5 = true;
$effect(() => { const __watchVal = (() => height)(); untrack(() => { if (__rozieWatchInitial_5) { __rozieWatchInitial_5 = false; return; } ((v: any) => instance?.setOption('height', v))(__watchVal); }); });
let __rozieWatchInitial_6 = true;
$effect(() => { const __watchVal = (() => locale)(); untrack(() => { if (__rozieWatchInitial_6) { __rozieWatchInitial_6 = false; return; } ((v: any) => instance?.setOption('locale', v))(__watchVal); }); });
let __rozieWatchInitial_7 = true;
$effect(() => { const __watchVal = (() => firstDay)(); untrack(() => { if (__rozieWatchInitial_7) { __rozieWatchInitial_7 = false; return; } ((v: any) => instance?.setOption('firstDay', v))(__watchVal); }); });
let __rozieWatchInitial_8 = true;
$effect(() => { const __watchVal = (() => slotDuration)(); untrack(() => { if (__rozieWatchInitial_8) { __rozieWatchInitial_8 = false; return; } ((v: any) => instance?.setOption('slotDuration', v))(__watchVal); }); });
let __rozieWatchInitial_9 = true;
$effect(() => { const __watchVal = (() => nowIndicator)(); untrack(() => { if (__rozieWatchInitial_9) { __rozieWatchInitial_9 = false; return; } ((v: any) => instance?.setOption('nowIndicator', v))(__watchVal); }); });
let __rozieWatchInitial_10 = true;
$effect(() => { const __watchVal = (() => headerToolbar)(); untrack(() => { if (__rozieWatchInitial_10) { __rozieWatchInitial_10 = false; return; } ((v: any) => instance?.setOption('headerToolbar', v))(__watchVal); }); });
let __rozieWatchInitial_11 = true;
$effect(() => { const __watchVal = (() => options)(); untrack(() => { if (__rozieWatchInitial_11) { __rozieWatchInitial_11 = false; return; } ((v: any) => {
if (!instance) return;
for (const k in v) instance.setOption(k, v[k]);
})(__watchVal); }); });
</script>
<div class="rozie-fullcalendar" bind:this={__rozieRoot} data-rozie-s-5589629a></div>
<style>
:global {
.rozie-fullcalendar[data-rozie-s-5589629a] {
width: 100%;
font-size: 0.875rem;
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, EmbeddedViewRef, TemplateRef, ViewContainerRef, ViewEncapsulation, contentChild, effect, inject, input, model, output, untracked, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
interface EventCtx {
$implicit: { arg: any };
arg: any;
}
interface DayCellCtx {
$implicit: { arg: any };
arg: any;
}
interface DayHeaderCtx {
$implicit: { arg: any };
arg: any;
}
interface SlotLabelCtx {
$implicit: { arg: any };
arg: any;
}
interface WeekNumberCtx {
$implicit: { arg: any };
arg: any;
}
interface NowIndicatorContentCtx {
$implicit: { arg: any };
arg: any;
}
interface MoreLinkCtx {
$implicit: { arg: any };
arg: any;
}
interface AllDayContentCtx {
$implicit: { arg: any };
arg: any;
}
interface SlotLaneContentCtx {
$implicit: { arg: any };
arg: any;
}
interface NoEventsContentCtx {
$implicit: { arg: any };
arg: any;
}
@Component({
selector: 'rozie-full-calendar',
standalone: true,
imports: [NgTemplateOutlet],
template: `
<div class="rozie-fullcalendar" #__rozieRoot></div>
<ng-container #rozie_portalAnchor></ng-container>
`,
styles: [`
.rozie-fullcalendar {
width: 100%;
font-size: 0.875rem;
}
`],
})
export class FullCalendar {
/**
* The event objects rendered on the calendar. Each event is normalized: a missing `title` falls back to `Event <id>`, and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array reconciles the live calendar via `removeAllEvents` + `addEvent`.
*/
events = input<any[]>((() => [])());
/**
* The two-way active view name (`'dayGridMonth'`, `'timeGridWeek'`, `'timeGridDay'`, …) — the sole `model: true` prop. The calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the view via `changeView`.
* @example
* <FullCalendar r-model:view="view" :events="events" />
*/
view = model<string>('dayGridMonth');
/**
* Show the Saturday/Sunday columns. Runtime-updatable via `setOption`.
*/
weekends = input<boolean>(true);
/**
* Allow events to be dragged and resized. Runtime-updatable via `setOption`.
*/
editable = input<boolean>(true);
/**
* Allow date/time-range selection by click-drag. Runtime-updatable via `setOption`.
*/
selectable = input<boolean>(true);
/**
* Calendar height in pixels. Runtime-updatable via `setOption`.
*/
height = input<number>(480);
/**
* Fallback event color stamped onto events that omit their own `color`.
*/
defaultColor = input<string>('#3b82f6');
/**
* FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through `setOption` via the imperative handle if needed.
*/
locale = input<string>('en');
/**
* First day of the week (`0` = Sunday … `1` = Monday). Runtime-updatable via `setOption`.
*/
firstDay = input<number>(0);
/**
* Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.
*/
slotDuration = input<string>('00:30:00');
/**
* Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.
*/
nowIndicator = input<boolean>(false);
/**
* The toolbar layout (`{ left, center, right }`). A consumer-passed object **fully replaces** the built-in default rather than merging with it. Runtime-updatable via `setOption`.
*/
headerToolbar = input<Record<string, any>>((() => ({
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
}))());
/**
* Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface does not special-case (`businessHours`, `dayMaxEvents`, `*DidMount` hooks, locale objects, …). Spread **first** into the engine config so the curated props/events/slots win on key collision; `:options` only fills gaps. Runtime-updatable per key via `setOption` (no key-removal reset — a removed key keeps its last applied value until remount; use `getApi()` for full imperative control). The `plugins` key is the one exception that **merges** with the baked-in defaults instead of overriding them, making the wrapper consumer-extensible.
*/
options = input<Record<string, any>>((() => ({}))());
__rozieRoot = viewChild<ElementRef<HTMLDivElement>>('__rozieRoot');
eventClick = output<unknown>();
dateClick = output<unknown>();
eventDrop = output<unknown>();
select = output<unknown>();
eventResize = output<unknown>();
datesSet = output<unknown>();
eventMouseEnter = output<unknown>();
eventMouseLeave = output<unknown>();
unselect = output<unknown>();
loading = output<unknown>();
eventsSet = output<unknown>();
@ContentChild('event', { read: TemplateRef }) eventTpl?: TemplateRef<EventCtx>;
@ContentChild('dayCell', { read: TemplateRef }) dayCellTpl?: TemplateRef<DayCellCtx>;
@ContentChild('dayHeader', { read: TemplateRef }) dayHeaderTpl?: TemplateRef<DayHeaderCtx>;
@ContentChild('slotLabel', { read: TemplateRef }) slotLabelTpl?: TemplateRef<SlotLabelCtx>;
@ContentChild('weekNumber', { read: TemplateRef }) weekNumberTpl?: TemplateRef<WeekNumberCtx>;
@ContentChild('nowIndicatorContent', { read: TemplateRef }) nowIndicatorContentTpl?: TemplateRef<NowIndicatorContentCtx>;
@ContentChild('moreLink', { read: TemplateRef }) moreLinkTpl?: TemplateRef<MoreLinkCtx>;
@ContentChild('allDayContent', { read: TemplateRef }) allDayContentTpl?: TemplateRef<AllDayContentCtx>;
@ContentChild('slotLaneContent', { read: TemplateRef }) slotLaneContentTpl?: TemplateRef<SlotLaneContentCtx>;
@ContentChild('noEventsContent', { read: TemplateRef }) noEventsContentTpl?: TemplateRef<NoEventsContentCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
private _portalViews = new Set<EmbeddedViewRef<unknown>>();
private _portalAnchor = viewChild('rozie_portalAnchor', { read: ViewContainerRef });
private _eventTpl = contentChild('event', { read: TemplateRef });
private _dayCellTpl = contentChild('dayCell', { read: TemplateRef });
private _dayHeaderTpl = contentChild('dayHeader', { read: TemplateRef });
private _slotLabelTpl = contentChild('slotLabel', { read: TemplateRef });
private _weekNumberTpl = contentChild('weekNumber', { read: TemplateRef });
private _nowIndicatorContentTpl = contentChild('nowIndicatorContent', { read: TemplateRef });
private _moreLinkTpl = contentChild('moreLink', { read: TemplateRef });
private _allDayContentTpl = contentChild('allDayContent', { read: TemplateRef });
private _slotLaneContentTpl = contentChild('slotLaneContent', { read: TemplateRef });
private _noEventsContentTpl = contentChild('noEventsContent', { read: TemplateRef });
private __rozieDestroyRef = inject(DestroyRef);
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
private __rozieWatchInitial_2 = true;
private __rozieWatchInitial_3 = true;
private __rozieWatchInitial_4 = true;
private __rozieWatchInitial_5 = true;
private __rozieWatchInitial_6 = true;
private __rozieWatchInitial_7 = true;
private __rozieWatchInitial_8 = true;
private __rozieWatchInitial_9 = true;
private __rozieWatchInitial_10 = true;
private __rozieWatchInitial_11 = true;
constructor() {
effect(() => { const __watchVal = (() => this.events())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => {
if (!this.instance) return;
this.instance.removeAllEvents();
for (const e of v as any) this.instance.addEvent(this.normalizeEvent(e));
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.view())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } ((v: any) => {
if (!this.instance || !v) return;
if (v === this.instance.view.type) return;
this.suppressViewSync = true;
this.instance.changeView(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.weekends())(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } ((v: any) => this.instance?.setOption('weekends', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.editable())(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } ((v: any) => this.instance?.setOption('editable', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.selectable())(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } ((v: any) => this.instance?.setOption('selectable', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.height())(); untracked(() => { if (this.__rozieWatchInitial_5) { this.__rozieWatchInitial_5 = false; return; } ((v: any) => this.instance?.setOption('height', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.locale())(); untracked(() => { if (this.__rozieWatchInitial_6) { this.__rozieWatchInitial_6 = false; return; } ((v: any) => this.instance?.setOption('locale', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.firstDay())(); untracked(() => { if (this.__rozieWatchInitial_7) { this.__rozieWatchInitial_7 = false; return; } ((v: any) => this.instance?.setOption('firstDay', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.slotDuration())(); untracked(() => { if (this.__rozieWatchInitial_8) { this.__rozieWatchInitial_8 = false; return; } ((v: any) => this.instance?.setOption('slotDuration', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.nowIndicator())(); untracked(() => { if (this.__rozieWatchInitial_9) { this.__rozieWatchInitial_9 = false; return; } ((v: any) => this.instance?.setOption('nowIndicator', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.headerToolbar())(); untracked(() => { if (this.__rozieWatchInitial_10) { this.__rozieWatchInitial_10 = false; return; } ((v: any) => this.instance?.setOption('headerToolbar', v))(__watchVal); }); });
effect(() => { const __watchVal = (() => this.options())(); untracked(() => { if (this.__rozieWatchInitial_11) { this.__rozieWatchInitial_11 = false; return; } ((v: any) => {
if (!this.instance) return;
for (const k in v) this.instance.setOption(k, v[k]);
})(__watchVal); }); });
}
ngAfterViewInit() {
const portals = {
event: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this._eventTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-event', '5589629a');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return () => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
};
},
dayCell: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this._dayCellTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-dayCell', '5589629a');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return () => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
};
},
dayHeader: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this._dayHeaderTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-dayHeader', '5589629a');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return () => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
};
},
slotLabel: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this._slotLabelTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-slotLabel', '5589629a');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return () => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
};
},
weekNumber: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this._weekNumberTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-weekNumber', '5589629a');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return () => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
};
},
nowIndicatorContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this._nowIndicatorContentTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-nowIndicatorContent', '5589629a');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return () => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
};
},
moreLink: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this._moreLinkTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-moreLink', '5589629a');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return () => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
};
},
allDayContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this._allDayContentTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-allDayContent', '5589629a');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return () => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
};
},
slotLaneContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this._slotLaneContentTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-slotLaneContent', '5589629a');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return () => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
};
},
noEventsContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this._noEventsContentTpl();
const vcr = this._portalAnchor();
if (!tpl || !vcr) return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-noEventsContent', '5589629a');
const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
view.detectChanges();
for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
this._portalViews.add(view as EmbeddedViewRef<unknown>);
return () => {
view.destroy();
this._portalViews.delete(view as EmbeddedViewRef<unknown>);
};
},
};
const __options = this.options();
const opts: Record<string, any> = {
// :options passthrough spread FIRST — the curated keys below + the portal
// *Content handlers added after this object override any colliding key, so
// an explicitly-bound prop (e.g. :height) wins over options.height.
//
// EXCEPTION — `plugins` is the one curated key that AUGMENTS rather than
// overrides: instead of clobbering a consumer-supplied `:options.plugins`,
// it MERGES the always-on baked-in defaults (dayGrid + timeGrid +
// interaction) with any consumer-added plugins. This makes the wrapper
// consumer-extensible (opt-in) — a consumer can engage list/rrule/premium/
// etc. via `:options="{ plugins: [listPlugin] }"` with NO bundle cost and NO
// per-plugin wrapper code. FullCalendar dedupes plugins by identity, so a
// consumer re-passing a default is harmless.
...__options,
plugins: [...this.PLUGINS, ...(__options?.plugins ?? [])],
initialView: this.view(),
weekends: this.weekends(),
editable: this.editable(),
selectable: this.selectable(),
height: this.height(),
locale: this.locale(),
firstDay: this.firstDay(),
slotDuration: this.slotDuration(),
nowIndicator: this.nowIndicator(),
events: this.events().map(this.normalizeEvent),
// D-02: a consumer-passed headerToolbar fully REPLACES the built-in
// toolbar; the built-in default lives in the `headerToolbar` prop default.
headerToolbar: this.headerToolbar(),
eventClick: (info: any) => {
this.eventClick.emit({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
dateClick: (info: any) => {
this.dateClick.emit({
date: info.date,
dateStr: info.dateStr,
allDay: info.allDay
});
},
eventDrop: (info: any) => {
this.eventDrop.emit({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
delta: info.delta
});
},
select: (info: any) => {
this.select.emit({
start: info.start,
end: info.end,
startStr: info.startStr,
endStr: info.endStr,
allDay: info.allDay
});
},
eventResize: (info: any) => {
this.eventResize.emit({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
startDelta: info.startDelta,
endDelta: info.endDelta
});
},
datesSet: (info: any) => {
this.datesSet.emit({
start: info.start,
end: info.end,
view: info.view.type
});
},
eventMouseEnter: (info: any) => {
this.eventMouseEnter.emit({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
eventMouseLeave: (info: any) => {
this.eventMouseLeave.emit({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
unselect: (info: any) => {
this.unselect.emit({
jsEvent: info.jsEvent
});
},
loading: (isLoading: any) => {
// FullCalendar's `loading` callback receives a bare boolean (not an info
// object) — normalize to the structured `{ isLoading }` payload shape.
this.loading.emit({
isLoading
});
},
eventsSet: (events: any) => {
// `eventsSet` receives the array of current EventApi objects — map each to
// the normalized floor shape for persistence/sync consumers.
this.eventsSet.emit({
events: events.map((e: any) => ({
id: e.id,
title: e.title,
start: e.start,
end: e.end
}))
});
},
viewDidMount: (info: any) => {
// viewDidMount fires both on initial mount AND on changeView calls.
// Same round-trip guard pattern as Flatpickr / LeafletMap.
if (this.suppressViewSync) {
this.suppressViewSync = false;
return;
}
if (info.view.type !== this.view()) this.view.set(info.view.type);
}
};
// Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
// slot, route every cell render through it. The portal helper mounts the
// consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
// Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
// handle is returned to FullCalendar so it cleans up the mounted tree when
// the cell is removed. Consumers that don't fill the slot get FullCalendar's
// default rendering (title text) — guarded by `$slots.event`.
// Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
// slot, route every cell render through it. The portal helper mounts the
// consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
// Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
// handle is returned to FullCalendar so it cleans up the mounted tree when
// the cell is removed. Consumers that don't fill the slot get FullCalendar's
// default rendering (title text) — guarded by `$slots.event`.
if ((this.eventTpl ?? this.templates()?.['event'])) {
opts.eventContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.event(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
// The 9 remaining *Content portal-slots — wired identically to `event`, one
// per FullCalendar per-cell content hook. Each guarded by its own slot so
// unfilled slots keep FullCalendar's default rendering. (10 portal-slots total
// counting `event` above; allDayContent + slotLaneContent are the two timeGrid
// axis/lane hooks, and noEventsContent is the list-view "no events" hook —
// inert unless the consumer engages @fullcalendar/list via :options.plugins.)
//
// NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
// hook (`nowIndicatorContent`) so it does NOT clash with the boolean
// `nowIndicator` PROP — a slot name that equals a declared prop name is now a
// hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
// unifies snippets and props into one `$props` namespace.
// The 9 remaining *Content portal-slots — wired identically to `event`, one
// per FullCalendar per-cell content hook. Each guarded by its own slot so
// unfilled slots keep FullCalendar's default rendering. (10 portal-slots total
// counting `event` above; allDayContent + slotLaneContent are the two timeGrid
// axis/lane hooks, and noEventsContent is the list-view "no events" hook —
// inert unless the consumer engages @fullcalendar/list via :options.plugins.)
//
// NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
// hook (`nowIndicatorContent`) so it does NOT clash with the boolean
// `nowIndicator` PROP — a slot name that equals a declared prop name is now a
// hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
// unifies snippets and props into one `$props` namespace.
if ((this.dayCellTpl ?? this.templates()?.['dayCell'])) {
opts.dayCellContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.dayCell(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((this.dayHeaderTpl ?? this.templates()?.['dayHeader'])) {
opts.dayHeaderContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.dayHeader(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((this.slotLabelTpl ?? this.templates()?.['slotLabel'])) {
opts.slotLabelContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.slotLabel(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((this.weekNumberTpl ?? this.templates()?.['weekNumber'])) {
opts.weekNumberContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.weekNumber(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((this.nowIndicatorContentTpl ?? this.templates()?.['nowIndicatorContent'])) {
opts.nowIndicatorContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.nowIndicatorContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((this.moreLinkTpl ?? this.templates()?.['moreLink'])) {
opts.moreLinkContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.moreLink(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((this.allDayContentTpl ?? this.templates()?.['allDayContent'])) {
opts.allDayContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.allDayContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((this.slotLaneContentTpl ?? this.templates()?.['slotLaneContent'])) {
opts.slotLaneContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.slotLaneContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
// noEventsContent — the list-view "no events to display" hook. Pre-declared
// and wired like the other 9 *Content slots, but INERT unless the consumer
// (a) engages @fullcalendar/list via the now-merged :options.plugins AND
// (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
// the bundled-only plugin set there is no list view, so this hook never fires
// — by design, documented, zero bundle cost.
// noEventsContent — the list-view "no events to display" hook. Pre-declared
// and wired like the other 9 *Content slots, but INERT unless the consumer
// (a) engages @fullcalendar/list via the now-merged :options.plugins AND
// (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
// the bundled-only plugin set there is no list view, so this hook never fires
// — by design, documented, zero bundle cost.
if ((this.noEventsContentTpl ?? this.templates()?.['noEventsContent'])) {
opts.noEventsContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.noEventsContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
this.instance = new Calendar(this.__rozieRoot()!.nativeElement, opts);
this.instance.render();
this.__rozieDestroyRef.onDestroy(() => this.instance?.destroy());
this.__rozieDestroyRef.onDestroy(() => {
for (const view of this._portalViews) view.destroy();
this._portalViews.clear();
});
}
instance: any = null;
suppressViewSync = false;
PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];
normalizeEvent = (e: any) => {
// Object spread + template-literal default — common reconcile shape:
// pass user props through, but stamp a sensible title fallback and
// honor the wrapper's defaultColor only when the event omits one.
return {
...e,
title: e.title || `Event ${e.id ?? '(no id)'}`,
color: e.color || this.defaultColor()
};
};
getApi = () => {
return this.instance;
};
changeView = (...a: any[]) => {
return this.instance?.changeView(...a);
};
addEvent = (...a: any[]) => {
return this.instance?.addEvent(...a);
};
removeEvent = (id: any) => {
this.instance?.getEventById(id)?.remove();
};
today = () => {
this.instance?.today();
};
prev = () => {
this.instance?.prev();
};
next = () => {
this.instance?.next();
};
gotoDate = (...a: any[]) => {
this.instance?.gotoDate(...a);
};
getDate = () => {
return this.instance ? this.instance.getDate() : null;
};
getEvents = () => {
return this.instance ? this.instance.getEvents() : [];
};
scrollToTime = (...a: any[]) => {
this.instance?.scrollToTime(...a);
};
updateSize = () => {
this.instance?.updateSize();
};
prevYear = () => {
this.instance?.prevYear();
};
nextYear = () => {
this.instance?.nextYear();
};
selectRange = (...a: any[]) => {
this.instance?.select(...a);
};
clearSelection = () => {
this.instance?.unselect();
};
static ngTemplateContextGuard(
_dir: FullCalendar,
_ctx: unknown,
): _ctx is EventCtx | DayCellCtx | DayHeaderCtx | SlotLabelCtx | WeekNumberCtx | NowIndicatorContentCtx | MoreLinkCtx | AllDayContentCtx | SlotLaneContentCtx | NoEventsContentCtx {
return true;
}
}
export default FullCalendar;tsx
import type { JSX } from 'solid-js';
import { createEffect, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { render } from 'solid-js/web';
import { __rozieInjectStyle, createControllableSignal } from '@rozie/runtime-solid';
import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
__rozieInjectStyle('FullCalendar-5589629a', `.rozie-fullcalendar[data-rozie-s-5589629a] {
width: 100%;
font-size: 0.875rem;
}`);
interface EventSlotCtx { arg: any; }
interface DayCellSlotCtx { arg: any; }
interface DayHeaderSlotCtx { arg: any; }
interface SlotLabelSlotCtx { arg: any; }
interface WeekNumberSlotCtx { arg: any; }
interface NowIndicatorContentSlotCtx { arg: any; }
interface MoreLinkSlotCtx { arg: any; }
interface AllDayContentSlotCtx { arg: any; }
interface SlotLaneContentSlotCtx { arg: any; }
interface NoEventsContentSlotCtx { arg: any; }
interface FullCalendarProps {
/**
* The event objects rendered on the calendar. Each event is normalized: a missing `title` falls back to `Event <id>`, and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array reconciles the live calendar via `removeAllEvents` + `addEvent`.
*/
events?: any[];
/**
* The two-way active view name (`'dayGridMonth'`, `'timeGridWeek'`, `'timeGridDay'`, …) — the sole `model: true` prop. The calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the view via `changeView`.
* @example
* <FullCalendar r-model:view="view" :events="events" />
*/
view?: string;
defaultView?: string;
onViewChange?: (view: string) => void;
/**
* Show the Saturday/Sunday columns. Runtime-updatable via `setOption`.
*/
weekends?: boolean;
/**
* Allow events to be dragged and resized. Runtime-updatable via `setOption`.
*/
editable?: boolean;
/**
* Allow date/time-range selection by click-drag. Runtime-updatable via `setOption`.
*/
selectable?: boolean;
/**
* Calendar height in pixels. Runtime-updatable via `setOption`.
*/
height?: number;
/**
* Fallback event color stamped onto events that omit their own `color`.
*/
defaultColor?: string;
/**
* FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through `setOption` via the imperative handle if needed.
*/
locale?: string;
/**
* First day of the week (`0` = Sunday … `1` = Monday). Runtime-updatable via `setOption`.
*/
firstDay?: number;
/**
* Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.
*/
slotDuration?: string;
/**
* Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.
*/
nowIndicator?: boolean;
/**
* The toolbar layout (`{ left, center, right }`). A consumer-passed object **fully replaces** the built-in default rather than merging with it. Runtime-updatable via `setOption`.
*/
headerToolbar?: Record<string, any>;
/**
* Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface does not special-case (`businessHours`, `dayMaxEvents`, `*DidMount` hooks, locale objects, …). Spread **first** into the engine config so the curated props/events/slots win on key collision; `:options` only fills gaps. Runtime-updatable per key via `setOption` (no key-removal reset — a removed key keeps its last applied value until remount; use `getApi()` for full imperative control). The `plugins` key is the one exception that **merges** with the baked-in defaults instead of overriding them, making the wrapper consumer-extensible.
*/
options?: Record<string, any>;
onEventClick?: (...args: unknown[]) => void;
onDateClick?: (...args: unknown[]) => void;
onEventDrop?: (...args: unknown[]) => void;
onSelect?: (...args: unknown[]) => void;
onEventResize?: (...args: unknown[]) => void;
onDatesSet?: (...args: unknown[]) => void;
onEventMouseEnter?: (...args: unknown[]) => void;
onEventMouseLeave?: (...args: unknown[]) => void;
onUnselect?: (...args: unknown[]) => void;
onLoading?: (...args: unknown[]) => void;
onEventsSet?: (...args: unknown[]) => void;
eventSlot?: (ctx: EventSlotCtx) => JSX.Element;
dayCellSlot?: (ctx: DayCellSlotCtx) => JSX.Element;
dayHeaderSlot?: (ctx: DayHeaderSlotCtx) => JSX.Element;
slotLabelSlot?: (ctx: SlotLabelSlotCtx) => JSX.Element;
weekNumberSlot?: (ctx: WeekNumberSlotCtx) => JSX.Element;
nowIndicatorContentSlot?: (ctx: NowIndicatorContentSlotCtx) => JSX.Element;
moreLinkSlot?: (ctx: MoreLinkSlotCtx) => JSX.Element;
allDayContentSlot?: (ctx: AllDayContentSlotCtx) => JSX.Element;
slotLaneContentSlot?: (ctx: SlotLaneContentSlotCtx) => JSX.Element;
noEventsContentSlot?: (ctx: NoEventsContentSlotCtx) => JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: FullCalendarHandle) => void;
}
export interface FullCalendarHandle {
getApi: (...args: any[]) => any;
changeView: (...args: any[]) => any;
addEvent: (...args: any[]) => any;
removeEvent: (...args: any[]) => any;
today: (...args: any[]) => any;
prev: (...args: any[]) => any;
next: (...args: any[]) => any;
gotoDate: (...args: any[]) => any;
getDate: (...args: any[]) => any;
getEvents: (...args: any[]) => any;
scrollToTime: (...args: any[]) => any;
updateSize: (...args: any[]) => any;
prevYear: (...args: any[]) => any;
nextYear: (...args: any[]) => any;
selectRange: (...args: any[]) => any;
clearSelection: (...args: any[]) => any;
}
export default function FullCalendar(_props: FullCalendarProps): JSX.Element {
const _merged = mergeProps({ events: (() => [])(), weekends: true, editable: true, selectable: true, height: 480, defaultColor: '#3b82f6', locale: 'en', firstDay: 0, slotDuration: '00:30:00', nowIndicator: false, headerToolbar: (() => ({
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
}))(), options: (() => ({}))() }, _props);
const [local, attrs] = splitProps(_merged, ['events', 'view', 'weekends', 'editable', 'selectable', 'height', 'defaultColor', 'locale', 'firstDay', 'slotDuration', 'nowIndicator', 'headerToolbar', 'options', 'ref']);
onMount(() => { local.ref?.({ getApi, changeView, addEvent, removeEvent, today, prev, next, gotoDate, getDate, getEvents, scrollToTime, updateSize, prevYear, nextYear, selectRange, clearSelection }); });
const [view, setView] = createControllableSignal<string>(_props as unknown as Record<string, unknown>, 'view', 'dayGridMonth');
const portalDisposers = new Set<() => void>();
const portals = {
event: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _props.eventSlot ?? _props.slots?.['event'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-event', '5589629a');
const dispose = render(() => slot(scope), container);
portalDisposers.add(dispose);
return () => {
dispose();
portalDisposers.delete(dispose);
};
},
dayCell: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _props.dayCellSlot ?? _props.slots?.['dayCell'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-dayCell', '5589629a');
const dispose = render(() => slot(scope), container);
portalDisposers.add(dispose);
return () => {
dispose();
portalDisposers.delete(dispose);
};
},
dayHeader: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _props.dayHeaderSlot ?? _props.slots?.['dayHeader'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-dayHeader', '5589629a');
const dispose = render(() => slot(scope), container);
portalDisposers.add(dispose);
return () => {
dispose();
portalDisposers.delete(dispose);
};
},
slotLabel: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _props.slotLabelSlot ?? _props.slots?.['slotLabel'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-slotLabel', '5589629a');
const dispose = render(() => slot(scope), container);
portalDisposers.add(dispose);
return () => {
dispose();
portalDisposers.delete(dispose);
};
},
weekNumber: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _props.weekNumberSlot ?? _props.slots?.['weekNumber'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-weekNumber', '5589629a');
const dispose = render(() => slot(scope), container);
portalDisposers.add(dispose);
return () => {
dispose();
portalDisposers.delete(dispose);
};
},
nowIndicatorContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _props.nowIndicatorContentSlot ?? _props.slots?.['nowIndicatorContent'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-nowIndicatorContent', '5589629a');
const dispose = render(() => slot(scope), container);
portalDisposers.add(dispose);
return () => {
dispose();
portalDisposers.delete(dispose);
};
},
moreLink: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _props.moreLinkSlot ?? _props.slots?.['moreLink'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-moreLink', '5589629a');
const dispose = render(() => slot(scope), container);
portalDisposers.add(dispose);
return () => {
dispose();
portalDisposers.delete(dispose);
};
},
allDayContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _props.allDayContentSlot ?? _props.slots?.['allDayContent'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-allDayContent', '5589629a');
const dispose = render(() => slot(scope), container);
portalDisposers.add(dispose);
return () => {
dispose();
portalDisposers.delete(dispose);
};
},
slotLaneContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _props.slotLaneContentSlot ?? _props.slots?.['slotLaneContent'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-slotLaneContent', '5589629a');
const dispose = render(() => slot(scope), container);
portalDisposers.add(dispose);
return () => {
dispose();
portalDisposers.delete(dispose);
};
},
noEventsContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const slot = _props.noEventsContentSlot ?? _props.slots?.['noEventsContent'];
if (typeof slot !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-noEventsContent', '5589629a');
const dispose = render(() => slot(scope), container);
portalDisposers.add(dispose);
return () => {
dispose();
portalDisposers.delete(dispose);
};
},
};
onCleanup(() => {
for (const dispose of portalDisposers) dispose();
portalDisposers.clear();
});
onMount(() => {
const _cleanup = (() => {
const opts: Record<string, any> = {
// :options passthrough spread FIRST — the curated keys below + the portal
// *Content handlers added after this object override any colliding key, so
// an explicitly-bound prop (e.g. :height) wins over options.height.
//
// EXCEPTION — `plugins` is the one curated key that AUGMENTS rather than
// overrides: instead of clobbering a consumer-supplied `:options.plugins`,
// it MERGES the always-on baked-in defaults (dayGrid + timeGrid +
// interaction) with any consumer-added plugins. This makes the wrapper
// consumer-extensible (opt-in) — a consumer can engage list/rrule/premium/
// etc. via `:options="{ plugins: [listPlugin] }"` with NO bundle cost and NO
// per-plugin wrapper code. FullCalendar dedupes plugins by identity, so a
// consumer re-passing a default is harmless.
...local.options,
plugins: [...PLUGINS, ...(local.options?.plugins ?? [])],
initialView: view(),
weekends: local.weekends,
editable: local.editable,
selectable: local.selectable,
height: local.height,
locale: local.locale,
firstDay: local.firstDay,
slotDuration: local.slotDuration,
nowIndicator: local.nowIndicator,
events: local.events.map(normalizeEvent),
// D-02: a consumer-passed headerToolbar fully REPLACES the built-in
// toolbar; the built-in default lives in the `headerToolbar` prop default.
headerToolbar: local.headerToolbar,
eventClick: (info: any) => {
_props.onEventClick?.({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
dateClick: (info: any) => {
_props.onDateClick?.({
date: info.date,
dateStr: info.dateStr,
allDay: info.allDay
});
},
eventDrop: (info: any) => {
_props.onEventDrop?.({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
delta: info.delta
});
},
select: (info: any) => {
_props.onSelect?.({
start: info.start,
end: info.end,
startStr: info.startStr,
endStr: info.endStr,
allDay: info.allDay
});
},
eventResize: (info: any) => {
_props.onEventResize?.({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
startDelta: info.startDelta,
endDelta: info.endDelta
});
},
datesSet: (info: any) => {
_props.onDatesSet?.({
start: info.start,
end: info.end,
view: info.view.type
});
},
eventMouseEnter: (info: any) => {
_props.onEventMouseEnter?.({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
eventMouseLeave: (info: any) => {
_props.onEventMouseLeave?.({
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
});
},
unselect: (info: any) => {
_props.onUnselect?.({
jsEvent: info.jsEvent
});
},
loading: (isLoading: any) => {
// FullCalendar's `loading` callback receives a bare boolean (not an info
// object) — normalize to the structured `{ isLoading }` payload shape.
_props.onLoading?.({
isLoading
});
},
eventsSet: (events: any) => {
// `eventsSet` receives the array of current EventApi objects — map each to
// the normalized floor shape for persistence/sync consumers.
_props.onEventsSet?.({
events: events.map((e: any) => ({
id: e.id,
title: e.title,
start: e.start,
end: e.end
}))
});
},
viewDidMount: (info: any) => {
// viewDidMount fires both on initial mount AND on changeView calls.
// Same round-trip guard pattern as Flatpickr / LeafletMap.
if (suppressViewSync) {
suppressViewSync = false;
return;
}
if (info.view.type !== view()) setView(info.view.type);
}
};
// Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
// slot, route every cell render through it. The portal helper mounts the
// consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
// Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
// handle is returned to FullCalendar so it cleans up the mounted tree when
// the cell is removed. Consumers that don't fill the slot get FullCalendar's
// default rendering (title text) — guarded by `$slots.event`.
if ((_props.eventSlot ?? _props.slots?.["event"])) {
opts.eventContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.event(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
// The 9 remaining *Content portal-slots — wired identically to `event`, one
// per FullCalendar per-cell content hook. Each guarded by its own slot so
// unfilled slots keep FullCalendar's default rendering. (10 portal-slots total
// counting `event` above; allDayContent + slotLaneContent are the two timeGrid
// axis/lane hooks, and noEventsContent is the list-view "no events" hook —
// inert unless the consumer engages @fullcalendar/list via :options.plugins.)
//
// NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
// hook (`nowIndicatorContent`) so it does NOT clash with the boolean
// `nowIndicator` PROP — a slot name that equals a declared prop name is now a
// hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
// unifies snippets and props into one `$props` namespace.
if ((_props.dayCellSlot ?? _props.slots?.["dayCell"])) {
opts.dayCellContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.dayCell(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((_props.dayHeaderSlot ?? _props.slots?.["dayHeader"])) {
opts.dayHeaderContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.dayHeader(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((_props.slotLabelSlot ?? _props.slots?.["slotLabel"])) {
opts.slotLabelContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.slotLabel(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((_props.weekNumberSlot ?? _props.slots?.["weekNumber"])) {
opts.weekNumberContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.weekNumber(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((_props.nowIndicatorContentSlot ?? _props.slots?.["nowIndicatorContent"])) {
opts.nowIndicatorContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.nowIndicatorContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((_props.moreLinkSlot ?? _props.slots?.["moreLink"])) {
opts.moreLinkContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.moreLink(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((_props.allDayContentSlot ?? _props.slots?.["allDayContent"])) {
opts.allDayContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.allDayContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if ((_props.slotLaneContentSlot ?? _props.slots?.["slotLaneContent"])) {
opts.slotLaneContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.slotLaneContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
// noEventsContent — the list-view "no events to display" hook. Pre-declared
// and wired like the other 9 *Content slots, but INERT unless the consumer
// (a) engages @fullcalendar/list via the now-merged :options.plugins AND
// (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
// the bundled-only plugin set there is no list view, so this hook never fires
// — by design, documented, zero bundle cost.
if ((_props.noEventsContentSlot ?? _props.slots?.["noEventsContent"])) {
opts.noEventsContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.noEventsContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
instance = new Calendar(__rozieRootRef!, opts);
instance.render();
})() as unknown;
if (_cleanup) onCleanup(_cleanup as () => void);
onCleanup(() => instance?.destroy());
});
createEffect(on(() => (() => local.events)(), (v) => untrack(() => ((v: any) => {
if (!instance) return;
instance.removeAllEvents();
for (const e of v as any) instance.addEvent(normalizeEvent(e));
})(v)), { defer: true }));
createEffect(on(() => (() => view())(), (v) => untrack(() => ((v: any) => {
if (!instance || !v) return;
if (v === instance.view.type) return;
suppressViewSync = true;
instance.changeView(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.weekends)(), (v) => untrack(() => ((v: any) => instance?.setOption('weekends', v))(v)), { defer: true }));
createEffect(on(() => (() => local.editable)(), (v) => untrack(() => ((v: any) => instance?.setOption('editable', v))(v)), { defer: true }));
createEffect(on(() => (() => local.selectable)(), (v) => untrack(() => ((v: any) => instance?.setOption('selectable', v))(v)), { defer: true }));
createEffect(on(() => (() => local.height)(), (v) => untrack(() => ((v: any) => instance?.setOption('height', v))(v)), { defer: true }));
createEffect(on(() => (() => local.locale)(), (v) => untrack(() => ((v: any) => instance?.setOption('locale', v))(v)), { defer: true }));
createEffect(on(() => (() => local.firstDay)(), (v) => untrack(() => ((v: any) => instance?.setOption('firstDay', v))(v)), { defer: true }));
createEffect(on(() => (() => local.slotDuration)(), (v) => untrack(() => ((v: any) => instance?.setOption('slotDuration', v))(v)), { defer: true }));
createEffect(on(() => (() => local.nowIndicator)(), (v) => untrack(() => ((v: any) => instance?.setOption('nowIndicator', v))(v)), { defer: true }));
createEffect(on(() => (() => local.headerToolbar)(), (v) => untrack(() => ((v: any) => instance?.setOption('headerToolbar', v))(v)), { defer: true }));
createEffect(on(() => (() => local.options)(), (v) => untrack(() => ((v: any) => {
if (!instance) return;
for (const k in v) instance.setOption(k, v[k]);
})(v)), { defer: true }));
let __rozieRootRef: HTMLElement | null = null;
let instance: any = null;
let suppressViewSync = false;
const PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];
function normalizeEvent(e: any) {
// Object spread + template-literal default — common reconcile shape:
// pass user props through, but stamp a sensible title fallback and
// honor the wrapper's defaultColor only when the event omits one.
return {
...e,
title: e.title || `Event ${e.id ?? '(no id)'}`,
color: e.color || local.defaultColor
};
}
// Imperative handle (Phase 21 $expose). The 16 calendar verbs a consumer can't
// drive through props alone — exposed uniformly to all 6 targets
// (Vue defineExpose / React useImperativeHandle / Svelte instance export /
// Angular+Lit public method / Solid callback ref). Each delegates to the
// underlying Calendar instance, which is null before $onMount and after
// destroy — callers handle the pre-mount null.
//
// Collision discipline (the load-bearing flatpickr lesson): no exposed name may
// collide with an emitted event (eventClick/dateClick/eventDrop/eventResize/
// datesSet/eventMouseEnter/eventMouseLeave/eventsSet/loading/select/unselect) or
// a declared prop. This is why the selection verbs are NAMED `selectRange`
// (CalendarApi.select) and `clearSelection` (CalendarApi.unselect) — bare
// `select`/`unselect` collide with the same-named emits (ROZ121), and `select`
// is also Lit-risky. getApi returns the raw Calendar instance (NOT guard-nulled).
//
// Read-back gap closed: getDate (current anchor — the `view` model only carries
// the view TYPE, datesSet only the visible RANGE) and getEvents (synchronous
// event read — eventsSet is push-only). scrollToTime/updateSize cover timeGrid
// scroll + container-resize relayout; prevYear/nextYear mirror prev/next.
function getApi() {
return instance;
}
function changeView(...a: any[]) {
return instance?.changeView(...a);
}
function addEvent(...a: any[]) {
return instance?.addEvent(...a);
}
function removeEvent(id: any) {
instance?.getEventById(id)?.remove();
}
function today() {
instance?.today();
}
function prev() {
instance?.prev();
}
function next() {
instance?.next();
}
function gotoDate(...a: any[]) {
instance?.gotoDate(...a);
}
function getDate() {
return instance ? instance.getDate() : null;
}
function getEvents() {
return instance ? instance.getEvents() : [];
}
function scrollToTime(...a: any[]) {
instance?.scrollToTime(...a);
}
function updateSize() {
instance?.updateSize();
}
function prevYear() {
instance?.prevYear();
}
function nextYear() {
instance?.nextYear();
}
function selectRange(...a: any[]) {
instance?.select(...a);
}
function clearSelection() {
instance?.unselect();
}
return (
<>
<div class={"rozie-fullcalendar"} ref={(el) => { __rozieRootRef = el as HTMLElement; }} data-rozie-s-5589629a="" />
</>
);
}ts
import { LitElement, css, html, nothing, render } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, untracked } from '@lit-labs/preact-signals';
import { adoptDocumentStyles, createLitControllableProperty } from '@rozie/runtime-lit';
import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
interface RozieEventSlotCtx {
arg: unknown;
}
interface RozieDayCellSlotCtx {
arg: unknown;
}
interface RozieDayHeaderSlotCtx {
arg: unknown;
}
interface RozieSlotLabelSlotCtx {
arg: unknown;
}
interface RozieWeekNumberSlotCtx {
arg: unknown;
}
interface RozieNowIndicatorContentSlotCtx {
arg: unknown;
}
interface RozieMoreLinkSlotCtx {
arg: unknown;
}
interface RozieAllDayContentSlotCtx {
arg: unknown;
}
interface RozieSlotLaneContentSlotCtx {
arg: unknown;
}
interface RozieNoEventsContentSlotCtx {
arg: unknown;
}
@customElement('rozie-full-calendar')
export default class FullCalendar extends SignalWatcher(LitElement) {
static styles = css`
.rozie-fullcalendar[data-rozie-s-5589629a] {
width: 100%;
font-size: 0.875rem;
}
`;
/**
* The event objects rendered on the calendar. Each event is normalized: a missing `title` falls back to `Event <id>`, and a missing `color` inherits `defaultColor`. Runtime-updatable — changing the array reconciles the live calendar via `removeAllEvents` + `addEvent`.
*/
@property({ type: Array }) events: any[] = [];
/**
* The two-way active view name (`'dayGridMonth'`, `'timeGridWeek'`, `'timeGridDay'`, …) — the sole `model: true` prop. The calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the view via `changeView`.
* @example
* <FullCalendar r-model:view="view" :events="events" />
*/
@property({ type: String, attribute: 'view' }) _view_attr: string = 'dayGridMonth';
private _viewControllable = createLitControllableProperty<string>({ host: this, eventName: 'view-change', defaultValue: 'dayGridMonth', initialControlledValue: undefined });
/**
* Show the Saturday/Sunday columns. Runtime-updatable via `setOption`.
*/
@property({ type: Boolean, reflect: true }) weekends: boolean = true;
/**
* Allow events to be dragged and resized. Runtime-updatable via `setOption`.
*/
@property({ type: Boolean, reflect: true }) editable: boolean = true;
/**
* Allow date/time-range selection by click-drag. Runtime-updatable via `setOption`.
*/
@property({ type: Boolean, reflect: true }) selectable: boolean = true;
/**
* Calendar height in pixels. Runtime-updatable via `setOption`.
*/
@property({ type: Number, reflect: true }) height: number = 480;
/**
* Fallback event color stamped onto events that omit their own `color`.
*/
@property({ type: String, reflect: true }) defaultColor: string = '#3b82f6';
/**
* FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through `setOption` via the imperative handle if needed.
*/
@property({ type: String, reflect: true }) locale: string = 'en';
/**
* First day of the week (`0` = Sunday … `1` = Monday). Runtime-updatable via `setOption`.
*/
@property({ type: Number, reflect: true }) firstDay: number = 0;
/**
* Time-grid slot length in `HH:mm:ss`. Runtime-updatable via `setOption`.
*/
@property({ type: String, reflect: true }) slotDuration: string = '00:30:00';
/**
* Render the current-time indicator line in time-grid views. Runtime-updatable via `setOption`.
*/
@property({ type: Boolean, reflect: true }) nowIndicator: boolean = false;
/**
* The toolbar layout (`{ left, center, right }`). A consumer-passed object **fully replaces** the built-in default rather than merging with it. Runtime-updatable via `setOption`.
*/
@property({ type: Object }) headerToolbar: any = {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
};
/**
* Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface does not special-case (`businessHours`, `dayMaxEvents`, `*DidMount` hooks, locale objects, …). Spread **first** into the engine config so the curated props/events/slots win on key collision; `:options` only fills gaps. Runtime-updatable per key via `setOption` (no key-removal reset — a removed key keeps its last applied value until remount; use `getApi()` for full imperative control). The `plugins` key is the one exception that **merges** with the baked-in defaults instead of overriding them, making the wrapper consumer-extensible.
*/
@property({ type: Object }) options: any = {};
@query('[data-rozie-ref="__rozieRoot"]') private _ref__rozieRoot!: HTMLElement;
private __rozieWatchInitial_1 = true;
private __rozieFirstUpdateDone = false;
private _portalContainers = new Set<HTMLElement>();
@state() private _hasSlotEvent = false;
@queryAssignedElements({ slot: 'event', flatten: true }) private _slotEventElements!: Element[];
@property({ attribute: false }) event?: (scope: { arg: unknown }) => unknown;
@state() private _hasSlotDayCell = false;
@queryAssignedElements({ slot: 'dayCell', flatten: true }) private _slotDayCellElements!: Element[];
@property({ attribute: false }) dayCell?: (scope: { arg: unknown }) => unknown;
@state() private _hasSlotDayHeader = false;
@queryAssignedElements({ slot: 'dayHeader', flatten: true }) private _slotDayHeaderElements!: Element[];
@property({ attribute: false }) dayHeader?: (scope: { arg: unknown }) => unknown;
@state() private _hasSlotSlotLabel = false;
@queryAssignedElements({ slot: 'slotLabel', flatten: true }) private _slotSlotLabelElements!: Element[];
@property({ attribute: false }) slotLabel?: (scope: { arg: unknown }) => unknown;
@state() private _hasSlotWeekNumber = false;
@queryAssignedElements({ slot: 'weekNumber', flatten: true }) private _slotWeekNumberElements!: Element[];
@property({ attribute: false }) weekNumber?: (scope: { arg: unknown }) => unknown;
@state() private _hasSlotNowIndicatorContent = false;
@queryAssignedElements({ slot: 'nowIndicatorContent', flatten: true }) private _slotNowIndicatorContentElements!: Element[];
@property({ attribute: false }) nowIndicatorContent?: (scope: { arg: unknown }) => unknown;
@state() private _hasSlotMoreLink = false;
@queryAssignedElements({ slot: 'moreLink', flatten: true }) private _slotMoreLinkElements!: Element[];
@property({ attribute: false }) moreLink?: (scope: { arg: unknown }) => unknown;
@state() private _hasSlotAllDayContent = false;
@queryAssignedElements({ slot: 'allDayContent', flatten: true }) private _slotAllDayContentElements!: Element[];
@property({ attribute: false }) allDayContent?: (scope: { arg: unknown }) => unknown;
@state() private _hasSlotSlotLaneContent = false;
@queryAssignedElements({ slot: 'slotLaneContent', flatten: true }) private _slotSlotLaneContentElements!: Element[];
@property({ attribute: false }) slotLaneContent?: (scope: { arg: unknown }) => unknown;
@state() private _hasSlotNoEventsContent = false;
@queryAssignedElements({ slot: 'noEventsContent', flatten: true }) private _slotNoEventsContentElements!: Element[];
@property({ attribute: false }) noEventsContent?: (scope: { arg: unknown }) => unknown;
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
private _armListeners(): void {
{
const slotEl = this.shadowRoot?.querySelector('slot[name="event"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotEvent = this._slotEventElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="dayCell"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotDayCell = this._slotDayCellElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="dayHeader"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotDayHeader = this._slotDayHeaderElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="slotLabel"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotSlotLabel = this._slotSlotLabelElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="weekNumber"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotWeekNumber = this._slotWeekNumberElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="nowIndicatorContent"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotNowIndicatorContent = this._slotNowIndicatorContentElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="moreLink"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotMoreLink = this._slotMoreLinkElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="allDayContent"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotAllDayContent = this._slotAllDayContentElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="slotLaneContent"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotSlotLaneContent = this._slotSlotLaneContentElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="noEventsContent"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotNoEventsContent = this._slotNoEventsContentElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
}
connectedCallback(): void {
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
this._hasSlotEvent = Array.from(this.children).some((el) => el.getAttribute('slot') === 'event');
this._hasSlotDayCell = Array.from(this.children).some((el) => el.getAttribute('slot') === 'dayCell');
this._hasSlotDayHeader = Array.from(this.children).some((el) => el.getAttribute('slot') === 'dayHeader');
this._hasSlotSlotLabel = Array.from(this.children).some((el) => el.getAttribute('slot') === 'slotLabel');
this._hasSlotWeekNumber = Array.from(this.children).some((el) => el.getAttribute('slot') === 'weekNumber');
this._hasSlotNowIndicatorContent = Array.from(this.children).some((el) => el.getAttribute('slot') === 'nowIndicatorContent');
this._hasSlotMoreLink = Array.from(this.children).some((el) => el.getAttribute('slot') === 'moreLink');
this._hasSlotAllDayContent = Array.from(this.children).some((el) => el.getAttribute('slot') === 'allDayContent');
this._hasSlotSlotLaneContent = Array.from(this.children).some((el) => el.getAttribute('slot') === 'slotLaneContent');
this._hasSlotNoEventsContent = Array.from(this.children).some((el) => el.getAttribute('slot') === 'noEventsContent');
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
adoptDocumentStyles(this);
this._armListeners();
const portals = {
event: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this.event;
if (typeof tpl !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-event', '5589629a');
render(tpl(scope), container);
this._portalContainers.add(container);
return () => {
render(nothing, container);
this._portalContainers.delete(container);
};
},
dayCell: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this.dayCell;
if (typeof tpl !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-dayCell', '5589629a');
render(tpl(scope), container);
this._portalContainers.add(container);
return () => {
render(nothing, container);
this._portalContainers.delete(container);
};
},
dayHeader: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this.dayHeader;
if (typeof tpl !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-dayHeader', '5589629a');
render(tpl(scope), container);
this._portalContainers.add(container);
return () => {
render(nothing, container);
this._portalContainers.delete(container);
};
},
slotLabel: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this.slotLabel;
if (typeof tpl !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-slotLabel', '5589629a');
render(tpl(scope), container);
this._portalContainers.add(container);
return () => {
render(nothing, container);
this._portalContainers.delete(container);
};
},
weekNumber: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this.weekNumber;
if (typeof tpl !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-weekNumber', '5589629a');
render(tpl(scope), container);
this._portalContainers.add(container);
return () => {
render(nothing, container);
this._portalContainers.delete(container);
};
},
nowIndicatorContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this.nowIndicatorContent;
if (typeof tpl !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-nowIndicatorContent', '5589629a');
render(tpl(scope), container);
this._portalContainers.add(container);
return () => {
render(nothing, container);
this._portalContainers.delete(container);
};
},
moreLink: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this.moreLink;
if (typeof tpl !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-moreLink', '5589629a');
render(tpl(scope), container);
this._portalContainers.add(container);
return () => {
render(nothing, container);
this._portalContainers.delete(container);
};
},
allDayContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this.allDayContent;
if (typeof tpl !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-allDayContent', '5589629a');
render(tpl(scope), container);
this._portalContainers.add(container);
return () => {
render(nothing, container);
this._portalContainers.delete(container);
};
},
slotLaneContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this.slotLaneContent;
if (typeof tpl !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-slotLaneContent', '5589629a');
render(tpl(scope), container);
this._portalContainers.add(container);
return () => {
render(nothing, container);
this._portalContainers.delete(container);
};
},
noEventsContent: (container: HTMLElement, scope: { arg: unknown }): (() => void) => {
const tpl = this.noEventsContent;
if (typeof tpl !== 'function') return () => {};
// Spike 004: portal-scope attribute injection.
container.setAttribute('data-rozie-portal-noEventsContent', '5589629a');
render(tpl(scope), container);
this._portalContainers.add(container);
return () => {
render(nothing, container);
this._portalContainers.delete(container);
};
},
};
this._disconnectCleanups.push((() => this.instance?.destroy()));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.view)(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } ((v: any) => {
if (!this.instance || !v) return;
if (v === this.instance.view.type) return;
this.suppressViewSync = true;
this.instance.changeView(v);
})(__watchVal); }); }));
const opts: Record<string, any> = {
// :options passthrough spread FIRST — the curated keys below + the portal
// *Content handlers added after this object override any colliding key, so
// an explicitly-bound prop (e.g. :height) wins over options.height.
//
// EXCEPTION — `plugins` is the one curated key that AUGMENTS rather than
// overrides: instead of clobbering a consumer-supplied `:options.plugins`,
// it MERGES the always-on baked-in defaults (dayGrid + timeGrid +
// interaction) with any consumer-added plugins. This makes the wrapper
// consumer-extensible (opt-in) — a consumer can engage list/rrule/premium/
// etc. via `:options="{ plugins: [listPlugin] }"` with NO bundle cost and NO
// per-plugin wrapper code. FullCalendar dedupes plugins by identity, so a
// consumer re-passing a default is harmless.
...this.options,
plugins: [...this.PLUGINS, ...(this.options?.plugins ?? [])],
initialView: this.view,
weekends: this.weekends,
editable: this.editable,
selectable: this.selectable,
height: this.height,
locale: this.locale,
firstDay: this.firstDay,
slotDuration: this.slotDuration,
nowIndicator: this.nowIndicator,
events: this.events.map(this.normalizeEvent),
// D-02: a consumer-passed headerToolbar fully REPLACES the built-in
// toolbar; the built-in default lives in the `headerToolbar` prop default.
headerToolbar: this.headerToolbar,
eventClick: (info: any) => {
this.dispatchEvent(new CustomEvent("eventClick", {
detail: {
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
},
bubbles: true,
composed: true
}));
},
dateClick: (info: any) => {
this.dispatchEvent(new CustomEvent("dateClick", {
detail: {
date: info.date,
dateStr: info.dateStr,
allDay: info.allDay
},
bubbles: true,
composed: true
}));
},
eventDrop: (info: any) => {
this.dispatchEvent(new CustomEvent("eventDrop", {
detail: {
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
delta: info.delta
},
bubbles: true,
composed: true
}));
},
select: (info: any) => {
this.dispatchEvent(new CustomEvent("select", {
detail: {
start: info.start,
end: info.end,
startStr: info.startStr,
endStr: info.endStr,
allDay: info.allDay
},
bubbles: true,
composed: true
}));
},
eventResize: (info: any) => {
this.dispatchEvent(new CustomEvent("eventResize", {
detail: {
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
startDelta: info.startDelta,
endDelta: info.endDelta
},
bubbles: true,
composed: true
}));
},
datesSet: (info: any) => {
this.dispatchEvent(new CustomEvent("datesSet", {
detail: {
start: info.start,
end: info.end,
view: info.view.type
},
bubbles: true,
composed: true
}));
},
eventMouseEnter: (info: any) => {
this.dispatchEvent(new CustomEvent("eventMouseEnter", {
detail: {
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
},
bubbles: true,
composed: true
}));
},
eventMouseLeave: (info: any) => {
this.dispatchEvent(new CustomEvent("eventMouseLeave", {
detail: {
event: {
id: info.event.id,
title: info.event.title,
start: info.event.start,
end: info.event.end
},
jsEvent: info.jsEvent
},
bubbles: true,
composed: true
}));
},
unselect: (info: any) => {
this.dispatchEvent(new CustomEvent("unselect", {
detail: {
jsEvent: info.jsEvent
},
bubbles: true,
composed: true
}));
},
loading: (isLoading: any) => {
// FullCalendar's `loading` callback receives a bare boolean (not an info
// object) — normalize to the structured `{ isLoading }` payload shape.
this.dispatchEvent(new CustomEvent("loading", {
detail: {
isLoading
},
bubbles: true,
composed: true
}));
},
eventsSet: (events: any) => {
// `eventsSet` receives the array of current EventApi objects — map each to
// the normalized floor shape for persistence/sync consumers.
this.dispatchEvent(new CustomEvent("eventsSet", {
detail: {
events: events.map((e: any) => ({
id: e.id,
title: e.title,
start: e.start,
end: e.end
}))
},
bubbles: true,
composed: true
}));
},
viewDidMount: (info: any) => {
// viewDidMount fires both on initial mount AND on changeView calls.
// Same round-trip guard pattern as Flatpickr / LeafletMap.
if (this.suppressViewSync) {
this.suppressViewSync = false;
return;
}
if (info.view.type !== this.view) this._viewControllable.write(info.view.type);
}
};
// Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
// slot, route every cell render through it. The portal helper mounts the
// consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
// Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
// handle is returned to FullCalendar so it cleans up the mounted tree when
// the cell is removed. Consumers that don't fill the slot get FullCalendar's
// default rendering (title text) — guarded by `$slots.event`.
// Portal-slot primitive (Spike 003) — when a consumer supplies an `event`
// slot, route every cell render through it. The portal helper mounts the
// consumer's framework-native fragment (React JSX, Vue VNodes, Svelte
// Snippet, etc.) into a DOM container that FullCalendar owns; the dispose
// handle is returned to FullCalendar so it cleans up the mounted tree when
// the cell is removed. Consumers that don't fill the slot get FullCalendar's
// default rendering (title text) — guarded by `$slots.event`.
if (this.event !== undefined) {
opts.eventContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.event(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
// The 9 remaining *Content portal-slots — wired identically to `event`, one
// per FullCalendar per-cell content hook. Each guarded by its own slot so
// unfilled slots keep FullCalendar's default rendering. (10 portal-slots total
// counting `event` above; allDayContent + slotLaneContent are the two timeGrid
// axis/lane hooks, and noEventsContent is the list-view "no events" hook —
// inert unless the consumer engages @fullcalendar/list via :options.plugins.)
//
// NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
// hook (`nowIndicatorContent`) so it does NOT clash with the boolean
// `nowIndicator` PROP — a slot name that equals a declared prop name is now a
// hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
// unifies snippets and props into one `$props` namespace.
// The 9 remaining *Content portal-slots — wired identically to `event`, one
// per FullCalendar per-cell content hook. Each guarded by its own slot so
// unfilled slots keep FullCalendar's default rendering. (10 portal-slots total
// counting `event` above; allDayContent + slotLaneContent are the two timeGrid
// axis/lane hooks, and noEventsContent is the list-view "no events" hook —
// inert unless the consumer engages @fullcalendar/list via :options.plugins.)
//
// NOTE the `nowIndicatorContent` slot is named for its FullCalendar engine
// hook (`nowIndicatorContent`) so it does NOT clash with the boolean
// `nowIndicator` PROP — a slot name that equals a declared prop name is now a
// hard compile error (ROZ127 SLOT_PROP_NAME_COLLISION), because Svelte 5
// unifies snippets and props into one `$props` namespace.
if (this.dayCell !== undefined) {
opts.dayCellContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.dayCell(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (this.dayHeader !== undefined) {
opts.dayHeaderContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.dayHeader(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (this.slotLabel !== undefined) {
opts.slotLabelContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.slotLabel(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (this.weekNumber !== undefined) {
opts.weekNumberContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.weekNumber(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (this.nowIndicatorContent !== undefined) {
opts.nowIndicatorContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.nowIndicatorContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (this.moreLink !== undefined) {
opts.moreLinkContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.moreLink(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (this.allDayContent !== undefined) {
opts.allDayContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.allDayContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
if (this.slotLaneContent !== undefined) {
opts.slotLaneContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.slotLaneContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
// noEventsContent — the list-view "no events to display" hook. Pre-declared
// and wired like the other 9 *Content slots, but INERT unless the consumer
// (a) engages @fullcalendar/list via the now-merged :options.plugins AND
// (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
// the bundled-only plugin set there is no list view, so this hook never fires
// — by design, documented, zero bundle cost.
// noEventsContent — the list-view "no events to display" hook. Pre-declared
// and wired like the other 9 *Content slots, but INERT unless the consumer
// (a) engages @fullcalendar/list via the now-merged :options.plugins AND
// (b) shows a list view (listWeek/listDay/listMonth) with ZERO events. With
// the bundled-only plugin set there is no list view, so this hook never fires
// — by design, documented, zero bundle cost.
if (this.noEventsContent !== undefined) {
opts.noEventsContent = (arg: any) => {
const node = document.createElement('div');
const dispose = portals.noEventsContent(node, {
arg
});
return {
domNodes: [node],
dispose
};
};
}
this.instance = new Calendar(this._ref__rozieRoot, opts);
this.instance.render();
}
updated(changedProperties: Map<string, unknown>): void {
if (this.__rozieFirstUpdateDone && (changedProperties.has('events'))) { const __watchVal = (() => this.events)(); ((v: any) => {
if (!this.instance) return;
this.instance.removeAllEvents();
for (const e of v as any) this.instance.addEvent(this.normalizeEvent(e));
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('weekends'))) { const __watchVal = (() => this.weekends)(); ((v: any) => this.instance?.setOption('weekends', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('editable'))) { const __watchVal = (() => this.editable)(); ((v: any) => this.instance?.setOption('editable', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('selectable'))) { const __watchVal = (() => this.selectable)(); ((v: any) => this.instance?.setOption('selectable', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('height'))) { const __watchVal = (() => this.height)(); ((v: any) => this.instance?.setOption('height', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('locale'))) { const __watchVal = (() => this.locale)(); ((v: any) => this.instance?.setOption('locale', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('firstDay'))) { const __watchVal = (() => this.firstDay)(); ((v: any) => this.instance?.setOption('firstDay', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('slotDuration'))) { const __watchVal = (() => this.slotDuration)(); ((v: any) => this.instance?.setOption('slotDuration', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('nowIndicator'))) { const __watchVal = (() => this.nowIndicator)(); ((v: any) => this.instance?.setOption('nowIndicator', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('headerToolbar'))) { const __watchVal = (() => this.headerToolbar)(); ((v: any) => this.instance?.setOption('headerToolbar', v))(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('options'))) { const __watchVal = (() => this.options)(); ((v: any) => {
if (!this.instance) return;
for (const k in v) this.instance.setOption(k, v[k]);
})(__watchVal); }
this.__rozieFirstUpdateDone = true;
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
for (const container of this._portalContainers) render(nothing, container);
this._portalContainers.clear();
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'view') this._viewControllable.notifyAttributeChange(value as unknown as string);
}
render() {
return html`
<div class="rozie-fullcalendar" data-rozie-ref="__rozieRoot" data-rozie-s-5589629a></div>
<slot name="event"></slot>
<slot name="dayCell"></slot>
<slot name="dayHeader"></slot>
<slot name="slotLabel"></slot>
<slot name="weekNumber"></slot>
<slot name="nowIndicatorContent"></slot>
<slot name="moreLink"></slot>
<slot name="allDayContent"></slot>
<slot name="slotLaneContent"></slot>
<slot name="noEventsContent"></slot>
`;
}
instance: any = null;
suppressViewSync = false;
PLUGINS = [dayGridPlugin, timeGridPlugin, interactionPlugin];
normalizeEvent = (e: any) => {
// Object spread + template-literal default — common reconcile shape:
// pass user props through, but stamp a sensible title fallback and
// honor the wrapper's defaultColor only when the event omits one.
return {
...e,
title: e.title || `Event ${e.id ?? '(no id)'}`,
color: e.color || this.defaultColor
};
};
getApi() {
return this.instance;
}
changeView(...a: any[]) {
return this.instance?.changeView(...a);
}
addEvent(...a: any[]) {
return this.instance?.addEvent(...a);
}
removeEvent(id: any) {
this.instance?.getEventById(id)?.remove();
}
today() {
this.instance?.today();
}
prev() {
this.instance?.prev();
}
next() {
this.instance?.next();
}
gotoDate(...a: any[]) {
this.instance?.gotoDate(...a);
}
getDate() {
return this.instance ? this.instance.getDate() : null;
}
getEvents() {
return this.instance ? this.instance.getEvents() : [];
}
scrollToTime(...a: any[]) {
this.instance?.scrollToTime(...a);
}
updateSize() {
this.instance?.updateSize();
}
prevYear() {
this.instance?.prevYear();
}
nextYear() {
this.instance?.nextYear();
}
selectRange(...a: any[]) {
this.instance?.select(...a);
}
clearSelection() {
this.instance?.unselect();
}
get view(): string { return this._viewControllable.read(); }
set view(v: string) { this._viewControllable.notifyPropertyWrite(v); }
}Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component, a Solid component, and a Lit custom element. Same props, same events, same imperative handle, all from the one source above.
See also
- FullCalendar — showcase & API — install, quick starts for all six frameworks, the
:optionspassthrough, the opt-in plugin model, and the full reference. - The portal-slot primitive — how the ten
*Contentrender hooks route a consumer fragment through each target's imperative-render API.