From 8dc511cf9f30ba22464da9cf52bc24dc69d9bb17 Mon Sep 17 00:00:00 2001
From: thedhanawada <13751641+thedhanawada@users.noreply.github.com>
Date: Fri, 2 Oct 2026 16:08:38 +1000
Subject: [PATCH 1/3] fix: destroy owned calendar resources and correct public
declarations
---
README.md | 4 +-
package-lock.json | 8 +--
package.json | 7 +-
src/components/ForceCalendar.js | 13 ++--
src/core/BaseComponent.js | 1 +
src/core/StateManager.js | 5 ++
src/renderers/BaseViewRenderer.js | 2 +
src/utils/DateUtils.js | 11 +++-
tests/types/consumer.ts | 24 +++++++
tests/types/tsconfig.json | 12 ++++
tests/unit/DateUtils.test.js | 24 +++++++
tests/unit/LifecycleCleanup.test.js | 99 +++++++++++++++++++++++++++++
12 files changed, 196 insertions(+), 14 deletions(-)
create mode 100644 tests/types/consumer.ts
create mode 100644 tests/types/tsconfig.json
create mode 100644 tests/unit/DateUtils.test.js
create mode 100644 tests/unit/LifecycleCleanup.test.js
diff --git a/README.md b/README.md
index 2f33143..04ea4f7 100644
--- a/README.md
+++ b/README.md
@@ -68,9 +68,9 @@ calendar.addEventListener('calendar-range-change', async e => {
## Lifecycle: detach and destroy
-Removing the element from the document releases its rendered tree, DOM listeners and timers but keeps its state (view, date, events) and keeps dispatching `calendar-*` events for API calls, so a re-attach (framework reconciliation, portals, StrictMode double-mount) picks up where it left off. Attribute changes made while detached are applied to state and rendered on the next attach.
+Removing the element from the document releases its rendered tree, DOM listeners and view timers but keeps its state (view, date, events) and keeps dispatching `calendar-*` events for API calls, so a re-attach (framework reconciliation, portals, StrictMode double-mount) picks up where it left off. Attribute changes made while detached are applied to state and rendered on the next attach.
-`destroy()` tears the state manager down. Afterwards the public API no-ops or queues instead of throwing (`events` is `[]`, `getVisibleRange()` is `null`, `setEvents()` queues) and the next attach initialises a fresh calendar from the attributes and any queued snapshot.
+`destroy()` tears the state manager and its owned Core Calendar down, including background maintenance timers (use Core 2.5.4 or later for full timer cleanup). Call it when you are finished with an element permanently; detaching alone intentionally preserves the calendar. Repeated calls are safe. Afterwards the public API no-ops or queues instead of throwing (`events` and `getEvents()` read the queued snapshot, `getVisibleRange()` is `null`, `setEvents()` queues, CRUD mutations return `null`/`false`, and navigation no-ops) and the next attach initialises a fresh calendar from the attributes and any queued snapshot.
## Events
diff --git a/package-lock.json b/package-lock.json
index 7856b91..46d8c2e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,7 @@
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",
- "@forcecalendar/core": "^2.5.1",
+ "@forcecalendar/core": "^2.5.4",
"babel-jest": "^30.2.0",
"eslint": "^8.57.1",
"jest": "^30.2.0",
@@ -2056,9 +2056,9 @@
}
},
"node_modules/@forcecalendar/core": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@forcecalendar/core/-/core-2.5.1.tgz",
- "integrity": "sha512-iyARiEzY7L+4KM3nUcj7HFO8TDzJ0iyiDYUF0KJGguX/j5NDu2KlRmwXn1LitScQsRQbvhMGBUOxCJNHD1TA1w==",
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@forcecalendar/core/-/core-2.5.4.tgz",
+ "integrity": "sha512-pgv473xcwv3Oo6UunhkkwBJc6QClzKifHhIZC6iqMbOBzdohBLuFFsUVznHJ+Mx9zDPfzJjGcZdQLAuBINi2Ug==",
"dev": true,
"license": "MIT"
},
diff --git a/package.json b/package.json
index dc03792..4ac7ee1 100644
--- a/package.json
+++ b/package.json
@@ -28,9 +28,10 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
- "test": "jest --testTimeout=10000",
+ "test": "npm run test:types && jest --testTimeout=10000",
"build:types": "tsc -p tsconfig.types.json && node -e \"import('node:fs').then(fs => fs.cpSync('src/types', 'types/types', { recursive: true }))\"",
- "prepublishOnly": "npm run build && npm run build:types"
+ "prepublishOnly": "npm run build && npm run build:types",
+ "test:types": "npm run build:types && tsc -p tests/types/tsconfig.json"
},
"repository": {
"type": "git",
@@ -58,7 +59,7 @@
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",
- "@forcecalendar/core": "^2.5.1",
+ "@forcecalendar/core": "^2.5.4",
"babel-jest": "^30.2.0",
"eslint": "^8.57.1",
"jest": "^30.2.0",
diff --git a/src/components/ForceCalendar.js b/src/components/ForceCalendar.js
index 815a961..44b8e6b 100644
--- a/src/components/ForceCalendar.js
+++ b/src/components/ForceCalendar.js
@@ -1102,19 +1102,19 @@ export class ForceCalendar extends BaseComponent {
// Public API methods
addEvent(event) {
- return this.stateManager.addEvent(event);
+ return this._isInitialised() ? this.stateManager.addEvent(event) : null;
}
updateEvent(eventId, updates) {
- return this.stateManager.updateEvent(eventId, updates);
+ return this._isInitialised() ? this.stateManager.updateEvent(eventId, updates) : null;
}
deleteEvent(eventId) {
- return this.stateManager.deleteEvent(eventId);
+ return this._isInitialised() ? this.stateManager.deleteEvent(eventId) : false;
}
getEvents() {
- return this.stateManager.getEvents();
+ return this.events;
}
/**
@@ -1185,22 +1185,27 @@ export class ForceCalendar extends BaseComponent {
}
setView(view) {
+ if (!this._isInitialised()) return;
this.stateManager.setView(this._resolveView(view, this.stateManager.getView()));
}
setDate(date) {
+ if (!this._isInitialised()) return;
this.stateManager.setDate(date);
}
next() {
+ if (!this._isInitialised()) return;
this.stateManager.next();
}
previous() {
+ if (!this._isInitialised()) return;
this.stateManager.previous();
}
today() {
+ if (!this._isInitialised()) return;
this.stateManager.today();
}
diff --git a/src/core/BaseComponent.js b/src/core/BaseComponent.js
index 629a43f..002e903 100644
--- a/src/core/BaseComponent.js
+++ b/src/core/BaseComponent.js
@@ -257,6 +257,7 @@ export class BaseComponent extends HTMLElement {
}
// Attribute observation
+ /** @returns {string[]} */
static get observedAttributes() {
return [];
}
diff --git a/src/core/StateManager.js b/src/core/StateManager.js
index d65f61f..9eaf52c 100644
--- a/src/core/StateManager.js
+++ b/src/core/StateManager.js
@@ -913,6 +913,11 @@ class StateManager {
// Destroy
destroy() {
+ // This manager owns the Calendar and its background maintenance timers.
+ // Release it before dropping the reference, including on repeated teardown.
+ if (this.calendar) {
+ this.calendar.destroy();
+ }
this.subscribers.clear();
if (this._subscriberIds) {
this._subscriberIds.clear();
diff --git a/src/renderers/BaseViewRenderer.js b/src/renderers/BaseViewRenderer.js
index 2e7dfd7..e795aef 100644
--- a/src/renderers/BaseViewRenderer.js
+++ b/src/renderers/BaseViewRenderer.js
@@ -8,6 +8,8 @@
import { DOMUtils } from '../utils/DOMUtils.js';
import { StyleUtils } from '../utils/StyleUtils.js';
+/** @typedef {import('../core/StateManager.js').default} StateManager */
+
export class BaseViewRenderer {
/**
* @param {HTMLElement} container - The DOM element to render into
diff --git a/src/utils/DateUtils.js b/src/utils/DateUtils.js
index 676b2f8..9edad7f 100644
--- a/src/utils/DateUtils.js
+++ b/src/utils/DateUtils.js
@@ -37,10 +37,19 @@ export class DateUtils extends CoreDateUtils {
}
/**
- * Format time for display
+ * Format time for display. A locale string in the second position also
+ * supports the inherited Core DateUtils signature.
+ * @param {Date} date
+ * @param {boolean|string} [showMinutes=true] - Show minutes, or a Core-style locale
+ * @param {boolean} [use24Hour=false]
+ * @param {string} [locale='en-US']
+ * @returns {string}
*/
static formatTime(date, showMinutes = true, use24Hour = false, locale = 'en-US') {
if (!date) return '';
+ if (typeof showMinutes === 'string') {
+ return CoreDateUtils.formatTime(date, showMinutes, use24Hour);
+ }
const options = {
hour: 'numeric',
diff --git a/tests/types/consumer.ts b/tests/types/consumer.ts
new file mode 100644
index 0000000..9528492
--- /dev/null
+++ b/tests/types/consumer.ts
@@ -0,0 +1,24 @@
+import {
+ BaseComponent,
+ BaseViewRenderer,
+ DateUtils,
+ ForceCalendar,
+ StateManager,
+ type CalendarEvent,
+ type ForceCalendarElement
+} from '../../types/index.js';
+import { DateUtils as CoreDateUtils } from '@forcecalendar/core';
+
+const element: ForceCalendarElement = document.createElement('forcecal-main');
+element.addEventListener('calendar-events-set', event => {
+ const added: CalendarEvent[] = event.detail.added;
+ const ids: string[] = added.map(item => item.id);
+ void ids;
+});
+const base: typeof BaseComponent = ForceCalendar;
+const dates: typeof CoreDateUtils = DateUtils;
+DateUtils.formatTime(new Date(), true, false, 'en-US');
+DateUtils.formatTime(new Date(), 'en-GB', true);
+new BaseViewRenderer(document.createElement('div'), new StateManager());
+void base;
+void dates;
diff --git a/tests/types/tsconfig.json b/tests/types/tsconfig.json
new file mode 100644
index 0000000..be3b471
--- /dev/null
+++ b/tests/types/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022", "DOM"],
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": false
+ },
+ "include": ["consumer.ts"]
+}
diff --git a/tests/unit/DateUtils.test.js b/tests/unit/DateUtils.test.js
new file mode 100644
index 0000000..83c6be4
--- /dev/null
+++ b/tests/unit/DateUtils.test.js
@@ -0,0 +1,24 @@
+import { DateUtils as CoreDateUtils } from '@forcecalendar/core';
+import { DateUtils } from '../../src/utils/DateUtils.js';
+
+describe('DateUtils.formatTime signatures', () => {
+ const date = new Date(2026, 9, 2, 13, 45);
+ test('preserves the interface minute and locale options', () => {
+ expect(DateUtils.formatTime(date, false, true, 'en-GB')).toBe(
+ new Intl.DateTimeFormat('en-GB', { hour: 'numeric', hour12: false }).format(date)
+ );
+ expect(DateUtils.formatTime(date, true, false, 'en-US')).toBe(
+ new Intl.DateTimeFormat('en-US', {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true
+ }).format(date)
+ );
+ });
+ test('accepts the inherited Core locale signature', () => {
+ expect(DateUtils.formatTime(date, 'de-DE', true)).toBe(
+ CoreDateUtils.formatTime(date, 'de-DE', true)
+ );
+ expect(DateUtils.formatTime(date, 'en-US')).toBe(CoreDateUtils.formatTime(date, 'en-US'));
+ });
+});
diff --git a/tests/unit/LifecycleCleanup.test.js b/tests/unit/LifecycleCleanup.test.js
new file mode 100644
index 0000000..41ae8f1
--- /dev/null
+++ b/tests/unit/LifecycleCleanup.test.js
@@ -0,0 +1,99 @@
+import '../../src/components/ForceCalendar.js';
+import StateManager from '../../src/core/StateManager.js';
+
+const event = id => ({
+ id,
+ title: id,
+ start: new Date('2026-10-02T10:00:00Z'),
+ end: new Date('2026-10-02T11:00:00Z')
+});
+
+describe('owned calendar lifecycle', () => {
+ let element;
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ element = document.createElement('forcecal-main');
+ element.setAttribute('date', '2026-10-02');
+ });
+
+ afterEach(() => {
+ element.destroy();
+ element.remove();
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ });
+
+ test('StateManager destroys its owned Calendar exactly once', () => {
+ const manager = new StateManager();
+ const calendar = manager.calendar;
+ const destroy = jest.spyOn(calendar, 'destroy');
+ manager.destroy();
+ manager.destroy();
+ expect(destroy).toHaveBeenCalledTimes(1);
+ expect(manager.calendar).toBeNull();
+ expect(manager.state).toBeNull();
+ expect(jest.getTimerCount()).toBe(0);
+ });
+
+ test.each(['before mounting', 'after destroy'])('%s the complete public API is safe', state => {
+ if (state === 'after destroy') {
+ document.body.appendChild(element);
+ element.destroy();
+ }
+ const navigated = jest.fn();
+ element.addEventListener('calendar-navigate', navigated);
+ expect(element.getEvents()).toEqual([]);
+ expect(element.addEvent(event('ignored'))).toBeNull();
+ expect(element.updateEvent('ignored', { title: 'ignored' })).toBeNull();
+ expect(element.deleteEvent('ignored')).toBe(false);
+ expect(() => element.setView('week')).not.toThrow();
+ expect(() => element.setDate(new Date())).not.toThrow();
+ expect(() => element.next()).not.toThrow();
+ expect(() => element.previous()).not.toThrow();
+ expect(() => element.today()).not.toThrow();
+ expect(element.getVisibleRange()).toBeNull();
+ expect(navigated).not.toHaveBeenCalled();
+ expect(jest.getTimerCount()).toBe(0);
+ expect(element.setEvents([event('queued')])).toBeNull();
+ expect(element.getEvents().map(item => item.id)).toEqual(['queued']);
+ });
+
+ test('repeated destroy and reattach release every owned timer and replay queued snapshots', () => {
+ document.body.appendChild(element);
+ const firstCalendar = element.stateManager.calendar;
+ jest.advanceTimersByTime(0);
+ expect(jest.getTimerCount()).toBeGreaterThan(0);
+ for (let cycle = 0; cycle < 3; cycle++) {
+ element.destroy();
+ element.destroy();
+ expect(jest.getTimerCount()).toBe(0);
+ element.setEvents([event(`queued-${cycle}`)]);
+ element.remove();
+ document.body.appendChild(element);
+ jest.advanceTimersByTime(0);
+ expect(element.getEvents().map(item => item.id)).toEqual([`queued-${cycle}`]);
+ expect(element.stateManager.calendar).not.toBe(firstCalendar);
+ expect(element.stateManager.subscribers.size).toBe(1);
+ expect(element.shadowRoot.querySelectorAll('#fc-root')).toHaveLength(1);
+ }
+ element.destroy();
+ expect(jest.getTimerCount()).toBe(0);
+ });
+
+ test('detach retains the Calendar while explicit destroy releases it', () => {
+ document.body.appendChild(element);
+ jest.advanceTimersByTime(0);
+ const calendar = element.stateManager.calendar;
+ const destroy = jest.spyOn(calendar, 'destroy');
+ element.addEvent(event('retained'));
+ element.remove();
+ expect(destroy).not.toHaveBeenCalled();
+ expect(element.getEvents().map(item => item.id)).toEqual(['retained']);
+ document.body.appendChild(element);
+ expect(element.stateManager.calendar).toBe(calendar);
+ element.destroy();
+ expect(destroy).toHaveBeenCalledTimes(1);
+ expect(jest.getTimerCount()).toBe(0);
+ });
+});
From 5fc952f7aa79ab329f095a4e0403d36ada5fa938 Mon Sep 17 00:00:00 2001
From: thedhanawada <13751641+thedhanawada@users.noreply.github.com>
Date: Fri, 2 Oct 2026 16:10:15 +1000
Subject: [PATCH 2/3] feat: add read-only calendar interactions with safe
gesture cleanup
---
README.md | 27 +-
src/components/ForceCalendar.js | 42 ++-
src/core/BaseComponent.js | 10 +-
src/core/DragController.js | 65 +++-
src/renderers/BaseViewRenderer.js | 10 +
src/renderers/DayViewRenderer.js | 5 +-
src/renderers/MonthViewRenderer.js | 7 +-
src/renderers/WeekViewRenderer.js | 5 +-
src/types/dom.d.ts | 3 +
tests/types/consumer.ts | 6 +
tests/unit/ReadOnly.test.js | 502 +++++++++++++++++++++++++++++
11 files changed, 656 insertions(+), 26 deletions(-)
create mode 100644 tests/unit/ReadOnly.test.js
diff --git a/README.md b/README.md
index 04ea4f7..f9e9f56 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ import '@forcecalendar/interface'; // registers and
```
-Attributes: `view` (`month` | `week` | `day`), `date`, `locale`, `timezone`, `week-starts-on`, `height`, `theme`. Views without a renderer fall back with a console warning.
+Attributes: `view` (`month` | `week` | `day`), `date`, `locale`, `timezone`, `week-starts-on`, `height`, `theme`, `readonly`. Views without a renderer fall back with a console warning.
## Loading events
@@ -54,6 +54,31 @@ Rules of the road:
Rendered chips of a recurring series carry occurrence ids (`_`). Clicking, selecting, dragging or resizing an occurrence resolves to the series master (`stateManager.findEvent(id)`), so `calendar-event-update` and the selection always carry the master event. There is no per-occurrence edit yet: dragging an occurrence shifts the whole series by the dragged delta (a change of date and time of day for every occurrence) and resizing one changes the duration of every occurrence.
+## Read-only interaction mode
+
+Set the boolean HTML attribute `readonly`, or the reflected JavaScript property `readOnly`, to disable built-in user editing. The default is `false`.
+
+```html
+
+```
+
+```js
+const calendar = document.createElement('forcecal-main');
+calendar.readOnly = true; // Can be set before registration/connection
+calendar.events = rows; // Snapshot hydration still works
+host.appendChild(calendar);
+calendar.readOnly = false; // Re-enable editing at any time
+```
+
+Attribute spelling is `readonly` (no hyphen); property spelling is `readOnly`. Like native boolean attributes, `readonly="false"` still enables it: remove the attribute or assign `calendar.readOnly = false` to turn it off. Framework adapters, including LWC, should assign the boolean property before inserting the element and whenever their option changes.
+
+- Disables New Event, form creation/saves, event dragging, resizing and drag-to-create in month/week/day views. Resize handles are omitted and grids expose `aria-readonly`. The current built-in UI has no separate edit/delete dialog or context-menu action.
+- Keeps mouse/keyboard event and date selection, grid focus navigation, view switching and date navigation available.
+- Enabling it closes the current creation form, discards its unsaved edits, cancels an active drag/resize/creation gesture and releases the gesture's document listeners. Disabling it restores editing without duplicating listeners. Instances remain independent.
+- Host code can still call `setEvents()`, assign `events`, and call `addEvent()`, `updateEvent()` or `deleteEvent()`. Imperative CRUD retains its usual mutation notifications; snapshots still emit only `calendar-events-set`.
+
+This is a UI interaction option, **not a security or authorization boundary**. Host-provided editors/context menus must also honor the option, and applications must enforce permissions and validate all writes on the server.
+
## Visible range
`getVisibleRange()` returns the `{ start, end }` window the current view covers, including the leading and trailing other-month days of the month grid. `end` is inclusive (the last millisecond of the window), so the pair can be passed straight to a range query. The window is expressed in the browser's local time zone regardless of the `timezone` attribute, and it is computed from the date, view and week start alone, so it is cheap to call.
diff --git a/src/components/ForceCalendar.js b/src/components/ForceCalendar.js
index 44b8e6b..60b45f1 100644
--- a/src/components/ForceCalendar.js
+++ b/src/components/ForceCalendar.js
@@ -26,7 +26,7 @@ export class ForceCalendar extends BaseComponent {
};
static get observedAttributes() {
- return ['view', 'date', 'locale', 'timezone', 'week-starts-on', 'height', 'theme'];
+ return ['view', 'date', 'locale', 'timezone', 'week-starts-on', 'height', 'theme', 'readonly'];
}
/**
@@ -87,6 +87,11 @@ export class ForceCalendar extends BaseComponent {
if (!this._isInitialised() || oldValue === newValue) return;
switch (name) {
+ case 'readonly':
+ // Close the editor before replacing its DOM and release its focus trap.
+ if (this.readOnly) this.$('#event-modal')?.close();
+ this.stateManager.updateConfig({ readOnly: this.readOnly });
+ break;
case 'view':
if (newValue) {
this.stateManager.setView(this._resolveView(newValue, this.stateManager.getView()));
@@ -112,13 +117,17 @@ export class ForceCalendar extends BaseComponent {
}
initialize() {
+ // Frameworks may set the property before customElements.define().
+ this._upgradeProperty('readOnly');
+
// Initialize state manager with config from attributes
const config = {
view: this._resolveView(this.getAttribute('view')),
date: this.getAttribute('date') ? new Date(this.getAttribute('date')) : new Date(),
locale: this.getAttribute('locale') || 'en-US',
timeZone: this.getAttribute('timezone') || Intl.DateTimeFormat().resolvedOptions().timeZone,
- weekStartsOn: parseInt(this.getAttribute('week-starts-on') || '0')
+ weekStartsOn: parseInt(this.getAttribute('week-starts-on') || '0'),
+ readOnly: this.readOnly
};
this.stateManager = new StateManager(config);
@@ -501,7 +510,7 @@ export class ForceCalendar extends BaseComponent {
outline-offset: -2px;
}
- .fc-event { touch-action: none; }
+ .fc-event { touch-action: ${this.readOnly ? 'auto' : 'none'}; }
.fc-dragging {
opacity: 0.6;
@@ -861,7 +870,7 @@ export class ForceCalendar extends BaseComponent {