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 {
-
@@ -972,21 +981,23 @@ export class ForceCalendar extends BaseComponent { if (createBtn && modal) { this.addListener(createBtn, 'click', () => { - modal.open(new Date()); + if (!this.readOnly) modal.open(new Date()); }); } // Listen for day clicks from the view this.addListener(this.shadowRoot, 'day-click', e => { - if (modal) { + if (modal && !this.readOnly) { modal.open(e.detail.date); } }); // Drag-to-create emits a range: surface it and open the form prefilled this.addListener(this.shadowRoot, 'range-select', e => { + if (this.readOnly) return; this.emit('calendar-range-select', e.detail); - if (modal) { + // A host listener may have toggled readOnly or replaced the view. + if (modal?.isConnected && !this.readOnly) { modal.open(e.detail.start); } }); @@ -994,6 +1005,7 @@ export class ForceCalendar extends BaseComponent { // Handle event saving if (modal) { this.addListener(modal, 'save', e => { + if (this.readOnly || !this._isInitialised()) return; const eventData = e.detail; // Robust Safari support check for randomUUID const id = @@ -1101,6 +1113,22 @@ export class ForceCalendar extends BaseComponent { } // Public API methods + + /** + * Disable user-driven event changes, leaving navigation, selection and host + * data APIs available. Reflects the boolean HTML attribute `readonly`: any + * present value is true. This is a UI option, not an authorization boundary. + * @returns {boolean} + */ + get readOnly() { + return this.hasAttribute('readonly'); + } + + /** @param {boolean} value */ + set readOnly(value) { + this.toggleAttribute('readonly', Boolean(value)); + } + addEvent(event) { return this._isInitialised() ? this.stateManager.addEvent(event) : null; } diff --git a/src/core/BaseComponent.js b/src/core/BaseComponent.js index 002e903..d27c800 100644 --- a/src/core/BaseComponent.js +++ b/src/core/BaseComponent.js @@ -208,13 +208,17 @@ export class BaseComponent extends HTMLElement { _getActiveElementSelector() { const active = this.shadowRoot.activeElement; if (!active || active === this._contentWrapper) return null; - if (active.id) return `[id="${CSS.escape(active.id)}"]`; + const escapeValue = + typeof CSS !== 'undefined' && typeof CSS.escape === 'function' + ? CSS.escape + : value => String(value).replace(/["\\]/g, '\\$&'); + if (active.id) return `[id="${escapeValue(active.id)}"]`; // Prefer data attributes over className to avoid invalid selectors if (active.dataset && active.dataset.action) { - return `[data-action="${CSS.escape(active.dataset.action)}"]`; + return `[data-action="${escapeValue(active.dataset.action)}"]`; } if (active.dataset && active.dataset.view) { - return `[data-view="${CSS.escape(active.dataset.view)}"]`; + return `[data-view="${escapeValue(active.dataset.view)}"]`; } if (active.tagName) { return active.tagName.toLowerCase(); diff --git a/src/core/DragController.js b/src/core/DragController.js index a6301b2..6fdb318 100644 --- a/src/core/DragController.js +++ b/src/core/DragController.js @@ -45,12 +45,15 @@ export class DragController { this.stateManager = renderer.stateManager; this._active = null; this._docListeners = []; + this._clickCleanup = null; + this._destroyed = false; } /** Month view: drag an event chip onto another day cell. */ enableMonthMove() { + if (this._destroyed || this.renderer.readOnly) return; this.renderer.addListener(this.container, 'pointerdown', e => { - if (e.button !== 0) return; + if (e.button !== 0 || this._destroyed || this.renderer.readOnly) return; const eventEl = e.target.closest('.fc-event'); if (!eventEl || !this.container.contains(eventEl)) return; const originCell = eventEl.closest('.fc-month-day'); @@ -71,11 +74,12 @@ export class DragController { * drag the bottom handle to resize, drag empty grid to create. */ enableTimeGrid(columnSelector) { + if (this._destroyed || this.renderer.readOnly) return; this._columnSelector = columnSelector; this._injectResizeHandles(); this.renderer.addListener(this.container, 'pointerdown', e => { - if (e.button !== 0) return; + if (e.button !== 0 || this._destroyed || this.renderer.readOnly) return; const handle = e.target.closest('.fc-resize-handle'); if (handle) { @@ -122,6 +126,8 @@ export class DragController { /** Track from pointerdown; promote to a drag past the threshold. */ _arm(e, spec) { + if (this._destroyed || this.renderer.readOnly) return; + this._cancel(); this._active = { ...spec, startX: e.clientX, @@ -148,6 +154,10 @@ export class DragController { } _onPointerMove(e) { + if (this._destroyed || this.renderer.readOnly) { + this._cancel(); + return; + } const a = this._active; if (!a) return; if (!a.dragging) { @@ -167,29 +177,62 @@ export class DragController { } _onPointerUp(e) { + if (this._destroyed || this.renderer.readOnly) { + this._cancel(); + return; + } const a = this._active; this._teardownDocListeners(); if (!a) return; if (a.dragging) { - // Swallow the click that follows a drag so it doesn't select/open - const doc = this.container.ownerDocument; - const swallow = ev => { - ev.stopPropagation(); - ev.preventDefault(); - }; - doc.addEventListener('click', swallow, { capture: true, once: true }); - setTimeout(() => doc.removeEventListener('click', swallow, { capture: true }), 0); a.onDrop(e); + // A successful update may synchronously render a new controller. Give + // that live owner the trailing-click guard so its next cleanup can + // release it (including a readOnly toggle or detach). + const controller = this._destroyed ? this.renderer._dragController : this; + controller?._suppressNextClick(); } this._cleanupVisuals(a); this._active = null; } + _suppressNextClick() { + if (this._destroyed || this.renderer.readOnly) return; + const doc = this.container.ownerDocument; + this._clickCleanup?.(); + const swallow = ev => { + // Do not consume a click intended for another calendar instance. + if (!ev.composedPath().includes(this.container)) return; + ev.stopPropagation(); + ev.preventDefault(); + this._clickCleanup?.(); + }; + doc.addEventListener('click', swallow, { capture: true }); + const timer = setTimeout(() => this._clickCleanup?.(), 0); + this._clickCleanup = () => { + doc.removeEventListener('click', swallow, { capture: true }); + clearTimeout(timer); + this._clickCleanup = null; + }; + } + _cancel() { const a = this._active; this._teardownDocListeners(); this._active = null; - if (a) this._cleanupVisuals(a); + if (a) { + if (a.mode === 'resize' && a.eventEl) { + a.eventEl.style.height = `${a.originHeight}px`; + } + this._cleanupVisuals(a); + } + } + + /** Cancel active interactions and release all document-level listeners. */ + destroy() { + this._destroyed = true; + this._cancel(); + this._clickCleanup?.(); } _teardownDocListeners() { diff --git a/src/renderers/BaseViewRenderer.js b/src/renderers/BaseViewRenderer.js index e795aef..4170b50 100644 --- a/src/renderers/BaseViewRenderer.js +++ b/src/renderers/BaseViewRenderer.js @@ -21,6 +21,8 @@ export class BaseViewRenderer { this._listeners = []; this._scrolled = false; this._nowIndicatorTimer = null; + /** @type {import('../core/DragController.js').DragController|null} */ + this._dragController = null; } /** @@ -35,6 +37,8 @@ export class BaseViewRenderer { * Clean up event listeners */ cleanup() { + this._dragController?.destroy(); + this._dragController = null; this._listeners.forEach(({ element, event, handler }) => { element.removeEventListener(event, handler); }); @@ -45,6 +49,11 @@ export class BaseViewRenderer { } } + /** Whether user-driven event mutations are disabled for this instance. */ + get readOnly() { + return Boolean(this.stateManager.getState().config.readOnly); + } + /** * Add an event listener with automatic cleanup tracking * @param {HTMLElement} element @@ -346,6 +355,7 @@ export class BaseViewRenderer { const grid = this.container.querySelector('.fc-time-grid'); if (!grid) return; grid.setAttribute('role', 'grid'); + grid.setAttribute('aria-readonly', String(this.readOnly)); grid.setAttribute('aria-label', gridLabel); const gutter = this.container.querySelector('.fc-time-gutter'); if (gutter) gutter.setAttribute('aria-hidden', 'true'); diff --git a/src/renderers/DayViewRenderer.js b/src/renderers/DayViewRenderer.js index 991a89c..3b7bee0 100644 --- a/src/renderers/DayViewRenderer.js +++ b/src/renderers/DayViewRenderer.js @@ -224,7 +224,10 @@ export class DayViewRenderer extends BaseViewRenderer { this._enhanceTimeGridAccessibility('.fc-day-column', label); // Drag to move/resize events and drag empty grid to create - new DragController(this).enableTimeGrid('.fc-day-column'); + if (!this.readOnly) { + this._dragController = new DragController(this); + this._dragController.enableTimeGrid('.fc-day-column'); + } } _scrollToCurrentTime() { diff --git a/src/renderers/MonthViewRenderer.js b/src/renderers/MonthViewRenderer.js index a3c3e92..3cc5ae3 100644 --- a/src/renderers/MonthViewRenderer.js +++ b/src/renderers/MonthViewRenderer.js @@ -57,7 +57,7 @@ export class MonthViewRenderer extends BaseViewRenderer { ); let html = ` -
+
${dayNames.map(d => `
${this.escapeHTML(d)}
`).join('')}
@@ -231,7 +231,10 @@ export class MonthViewRenderer extends BaseViewRenderer { this.attachCommonEventHandlers(); // Drag an event chip onto another day to move it - new DragController(this).enableMonthMove(); + if (!this.readOnly) { + this._dragController = new DragController(this); + this._dragController.enableMonthMove(); + } } /** diff --git a/src/renderers/WeekViewRenderer.js b/src/renderers/WeekViewRenderer.js index 0579bf3..3c18ef0 100644 --- a/src/renderers/WeekViewRenderer.js +++ b/src/renderers/WeekViewRenderer.js @@ -199,7 +199,10 @@ export class WeekViewRenderer extends BaseViewRenderer { this._enhanceTimeGridAccessibility('.fc-week-day-column', label); // Drag to move/resize events and drag empty grid to create - new DragController(this).enableTimeGrid('.fc-week-day-column'); + if (!this.readOnly) { + this._dragController = new DragController(this); + this._dragController.enableTimeGrid('.fc-week-day-column'); + } } _scrollToCurrentTime() { diff --git a/src/types/dom.d.ts b/src/types/dom.d.ts index c7ba976..0d7736d 100644 --- a/src/types/dom.d.ts +++ b/src/types/dom.d.ts @@ -94,6 +94,9 @@ export interface ForceCalendarEventMap { /** Public surface of the `` element. */ export interface ForceCalendarElement extends HTMLElement { + /** Disable built-in user edits. Reflected boolean `readonly` attribute; host APIs remain writable. */ + readOnly: boolean; + /** * Complete snapshot of events; assigning reconciles with * `removeMissing: true`. Reading returns the events the calendar holds (or diff --git a/tests/types/consumer.ts b/tests/types/consumer.ts index 9528492..499047b 100644 --- a/tests/types/consumer.ts +++ b/tests/types/consumer.ts @@ -22,3 +22,9 @@ DateUtils.formatTime(new Date(), 'en-GB', true); new BaseViewRenderer(document.createElement('div'), new StateManager()); void base; void dates; + +element.readOnly = true; +const readOnly: boolean = element.readOnly; +const classReadOnly: boolean = new ForceCalendar().readOnly; +void readOnly; +void classReadOnly; diff --git a/tests/unit/ReadOnly.test.js b/tests/unit/ReadOnly.test.js new file mode 100644 index 0000000..c5a3d13 --- /dev/null +++ b/tests/unit/ReadOnly.test.js @@ -0,0 +1,502 @@ +import { ForceCalendar } from '../../src/components/ForceCalendar.js'; +import StateManager from '../../src/core/StateManager.js'; +import { MonthViewRenderer } from '../../src/renderers/MonthViewRenderer.js'; +import { WeekViewRenderer } from '../../src/renderers/WeekViewRenderer.js'; +import { DayViewRenderer } from '../../src/renderers/DayViewRenderer.js'; + +const seed = (id = 'event-1') => ({ + id, + title: 'Meeting', + start: new Date('2026-07-15T10:00:00'), + end: new Date('2026-07-15T11:00:00') +}); +const pointer = (type, x = 10, y = 600) => + new MouseEvent(type, { bubbles: true, composed: true, clientX: x, clientY: y, button: 0 }); +const key = value => new KeyboardEvent('keydown', { key: value, bubbles: true, composed: true }); +const uiEvent = (type, detail) => new CustomEvent(type, { detail, bubbles: true, composed: true }); +const rect = (left = 0, top = 0) => ({ left, right: left + 100, top, bottom: top + 1440 }); + +function mockLayout(container) { + for (const [index, cell] of [...container.querySelectorAll('.fc-month-day')].entries()) { + cell.getBoundingClientRect = () => ({ + left: (index % 7) * 100, + right: (index % 7) * 100 + 100, + top: Math.floor(index / 7) * 80, + bottom: Math.floor(index / 7) * 80 + 80 + }); + } + for (const [index, column] of [ + ...container.querySelectorAll('.fc-week-day-column, .fc-day-column') + ].entries()) { + column.getBoundingClientRect = () => rect(index * 100); + } +} + +function startDrag(element, mode) { + const container = element.shadowRoot.querySelector('#calendar-view-container'); + mockLayout(container); + const eventEl = container.querySelector('.fc-event'); + if (mode === 'month-move') { + const cells = [...container.querySelectorAll('.fc-month-day')]; + const index = cells.indexOf(eventEl.closest('.fc-month-day')); + const target = cells[index + 1].getBoundingClientRect(); + eventEl.dispatchEvent(pointer('pointerdown', 10, 10)); + document.dispatchEvent(pointer('pointermove', target.left + 20, target.top + 20)); + } else { + const column = eventEl.closest('.fc-week-day-column, .fc-day-column'); + const x = column.getBoundingClientRect().left + 10; + const target = + mode === 'resize' + ? eventEl.querySelector('.fc-resize-handle') + : mode === 'create' + ? column.querySelector('.fc-hour-slot') + : eventEl; + target.dispatchEvent(pointer('pointerdown', x, 600)); + document.dispatchEvent(pointer('pointermove', x, 660)); + } + return eventEl; +} + +describe('ForceCalendar readOnly contract', () => { + const elements = []; + function create(view = 'month', readOnly = false, append = true) { + const element = document.createElement('forcecal-main'); + elements.push(element); + element.setAttribute('view', view); + element.setAttribute('date', '2026-07-15T12:00:00'); + element.readOnly = readOnly; + element.events = [seed()]; + if (append) document.body.appendChild(element); + return element; + } + + beforeEach(() => jest.useFakeTimers()); + afterEach(() => { + for (const element of elements.splice(0)) { + element.destroy(); + element.remove(); + } + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + test('reflects a boolean readonly attribute before mount and while connected', () => { + const element = create('month', false, false); + expect(element.readOnly).toBe(false); + element.setAttribute('readonly', 'false'); + expect(element.readOnly).toBe(true); + document.body.appendChild(element); + expect(element.stateManager.getState().config.readOnly).toBe(true); + expect(element.shadowRoot.querySelector('#create-event-btn').disabled).toBe(true); + element.readOnly = false; + expect(element.hasAttribute('readonly')).toBe(false); + expect(element.stateManager.getState().config.readOnly).toBe(false); + element.readOnly = true; + expect(element.getAttribute('readonly')).toBe(''); + element.removeAttribute('readonly'); + expect(element.readOnly).toBe(false); + }); + + test('honors a property assigned before custom-element upgrade', () => { + const element = document.createElement('forcecal-readonly-upgrade-test'); + element.readOnly = true; + element.setAttribute('date', '2026-07-15'); + document.body.appendChild(element); + customElements.define('forcecal-readonly-upgrade-test', class extends ForceCalendar {}); + elements.push(element); + expect(Object.prototype.hasOwnProperty.call(element, 'readOnly')).toBe(false); + expect(element.readOnly).toBe(true); + expect(element.stateManager.getState().config.readOnly).toBe(true); + expect(element.shadowRoot.querySelector('#create-event-btn').disabled).toBe(true); + }); + + test('host snapshots and imperative CRUD retain their existing behavior and notifications', () => { + const element = create('week', true); + const added = jest.fn(); + const updated = jest.fn(); + const removed = jest.fn(); + const snapshot = jest.fn(); + element.addEventListener('calendar-event-add', added); + element.addEventListener('calendar-event-update', updated); + element.addEventListener('calendar-event-remove', removed); + element.addEventListener('calendar-events-set', snapshot); + element.setEvents([{ ...seed(), title: 'Hydrated' }]); + element.events = [seed(), seed('event-2')]; + expect(snapshot).toHaveBeenCalledTimes(2); + expect(added).not.toHaveBeenCalled(); + expect(updated).not.toHaveBeenCalled(); + expect(removed).not.toHaveBeenCalled(); + expect(element.shadowRoot.querySelectorAll('.fc-event')).toHaveLength(2); + expect(element.addEvent(seed('event-3')).id).toBe('event-3'); + expect(element.updateEvent('event-3', { title: 'Host update' }).title).toBe('Host update'); + expect(element.deleteEvent('event-3')).toBe(true); + expect(added).toHaveBeenCalledTimes(1); + expect(updated).toHaveBeenCalledTimes(1); + expect(removed).toHaveBeenCalledTimes(1); + expect(element.shadowRoot.querySelector('.fc-resize-handle')).toBeNull(); + }); + + test.each(['month', 'week', 'day'])( + '%s keeps selection and navigation without mutation', + view => { + const element = create(view, true); + const shadow = element.shadowRoot; + const dateSelected = jest.fn(); + const eventSelected = jest.fn(); + element.addEventListener('calendar-date-select', dateSelected); + element.stateManager.eventBus.on('event:selected', eventSelected); + const grid = shadow.querySelector('[role="grid"]'); + expect(grid.getAttribute('aria-readonly')).toBe('true'); + const eventEl = shadow.querySelector('.fc-event'); + eventEl.click(); + eventEl.dispatchEvent(key('Enter')); + eventEl.dispatchEvent(key(' ')); + expect(eventSelected).toHaveBeenCalledTimes(3); + expect(element.stateManager.getState().selectedEvent.id).toBe('event-1'); + for (const value of ['Delete', 'Backspace', 'F2']) eventEl.dispatchEvent(key(value)); + eventEl.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, composed: true })); + eventEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, composed: true })); + const cell = shadow.querySelector(view === 'month' ? '.fc-month-day' : '.fc-hour-slot'); + cell.click(); + cell.dispatchEvent(key('Enter')); + cell.dispatchEvent(key(' ')); + expect(dateSelected).toHaveBeenCalledTimes(3); + cell.dispatchEvent(key('ArrowDown')); + expect(shadow.activeElement).not.toBe(cell); + expect(shadow.querySelector('#event-modal').hasAttribute('open')).toBe(false); + expect(element.getEvents()[0].title).toBe('Meeting'); + const navigated = jest.fn(); + element.addEventListener('calendar-navigate', navigated); + shadow.querySelector('[data-action="next"]').click(); + expect(navigated).toHaveBeenCalledTimes(1); + expect(element.readOnly).toBe(true); + shadow.querySelector('[data-view="day"]').click(); + expect(element.stateManager.getView()).toBe('day'); + expect(shadow.querySelector('[role="grid"]').getAttribute('aria-readonly')).toBe('true'); + } + ); + + test('guards button, synthetic day/range events and dialog saves', () => { + const element = create('day', true); + const modal = element.shadowRoot.querySelector('#event-modal'); + const open = jest.spyOn(modal, 'open'); + const added = jest.fn(); + const rangeSelected = jest.fn(); + element.addEventListener('calendar-event-add', added); + element.addEventListener('calendar-range-select', rangeSelected); + element.shadowRoot.querySelector('#create-event-btn').dispatchEvent(new MouseEvent('click')); + element.shadowRoot.dispatchEvent(uiEvent('day-click', { date: new Date() })); + element.shadowRoot.dispatchEvent( + uiEvent('range-select', { start: new Date(), end: new Date() }) + ); + modal.dispatchEvent(uiEvent('save', seed('blocked'))); + expect(open).not.toHaveBeenCalled(); + expect(added).not.toHaveBeenCalled(); + expect(rangeSelected).not.toHaveBeenCalled(); + expect(element.getEvents()).toHaveLength(1); + }); + + test('a host can enable readOnly during range notification without reopening the old form', () => { + const element = create('day'); + const modal = element.shadowRoot.querySelector('#event-modal'); + const open = jest.spyOn(modal, 'open'); + element.addEventListener('calendar-range-select', () => { + element.readOnly = true; + }); + element.shadowRoot.dispatchEvent( + uiEvent('range-select', { start: new Date(), end: new Date() }) + ); + expect(element.readOnly).toBe(true); + expect(open).not.toHaveBeenCalled(); + expect(modal._cleanupFocusTrap).toBeNull(); + expect(element.shadowRoot.querySelector('#event-modal').hasAttribute('open')).toBe(false); + }); + + test('enabling readOnly closes an open form and disables stale editor handlers', () => { + const element = create('day'); + element.shadowRoot.querySelector('#create-event-btn').click(); + const modal = element.shadowRoot.querySelector('#event-modal'); + expect(modal.hasAttribute('open')).toBe(true); + expect(modal._cleanupFocusTrap).not.toBeNull(); + const close = jest.spyOn(modal, 'close'); + element.readOnly = true; + expect(close).toHaveBeenCalled(); + expect(modal.hasAttribute('open')).toBe(false); + expect(modal._cleanupFocusTrap).toBeNull(); + modal.dispatchEvent(uiEvent('save', seed('blocked'))); + expect(element.getEvents()).toHaveLength(1); + element.readOnly = false; + modal.dispatchEvent(uiEvent('save', seed('still-stale'))); + expect(element.getEvents()).toHaveLength(1); + element.shadowRoot.querySelector('#create-event-btn').click(); + expect(element.shadowRoot.querySelector('#event-modal').hasAttribute('open')).toBe(true); + }); + + test.each([ + ['month', 'month-move'], + ['week', 'time-move'], + ['day', 'time-move'], + ['week', 'resize'], + ['day', 'resize'], + ['week', 'create'], + ['day', 'create'] + ])('toggling during %s %s cancels the gesture and all document handlers', (view, mode) => { + const element = create(view); + const renderer = element._currentViewInstance; + const drag = renderer._dragController; + const eventEl = startDrag(element, mode); + expect(drag._active.dragging).toBe(true); + const changed = jest.fn(); + const selected = jest.fn(); + element.addEventListener('calendar-event-update', changed); + element.addEventListener('calendar-range-select', selected); + element.readOnly = true; + expect(drag._active).toBeNull(); + expect(drag._docListeners).toHaveLength(0); + expect(drag._clickCleanup).toBeNull(); + expect(renderer._listeners).toHaveLength(0); + expect(eventEl.classList.contains('fc-dragging')).toBe(false); + expect(eventEl.style.transform).toBe(''); + if (mode === 'resize') expect(eventEl.style.height).toBe('60px'); + document.dispatchEvent(pointer('pointermove', 20, 720)); + document.dispatchEvent(pointer('pointerup', 20, 720)); + expect(changed).not.toHaveBeenCalled(); + expect(selected).not.toHaveBeenCalled(); + expect(element.getEvents()[0].start.getHours()).toBe(10); + expect(element.getEvents()[0].end.getHours()).toBe(11); + expect(element.shadowRoot.querySelector('.fc-drag-selection')).toBeNull(); + expect(element.shadowRoot.querySelector('.fc-resize-handle')).toBeNull(); + }); + + test.each(['month', 'week', 'day'])( + '%s readOnly never arms dragging or injects resize controls', + view => { + const element = create(view, true); + const eventEl = element.shadowRoot.querySelector('.fc-event'); + const mutate = jest.spyOn(element.stateManager, 'updateEvent'); + eventEl.dispatchEvent(pointer('pointerdown')); + document.dispatchEvent(pointer('pointermove', 10, 660)); + document.dispatchEvent(pointer('pointerup', 10, 660)); + const slot = element.shadowRoot.querySelector('.fc-hour-slot, .fc-month-day'); + slot.dispatchEvent(pointer('pointerdown')); + document.dispatchEvent(pointer('pointermove', 10, 660)); + document.dispatchEvent(pointer('pointerup', 10, 660)); + expect(mutate).not.toHaveBeenCalled(); + expect(element._currentViewInstance._dragController).toBeNull(); + expect(element.shadowRoot.querySelector('.fc-resize-handle')).toBeNull(); + expect(element.shadowRoot.querySelector('.fc-drag-selection')).toBeNull(); + } + ); + + test.each(['month', 'week', 'day'])( + '%s restores exactly one editing path after repeated toggles', + view => { + const element = create(view, true); + const changed = jest.fn(); + element.addEventListener('calendar-event-update', changed); + for (let index = 0; index < 3; index++) { + element.readOnly = false; + element.readOnly = true; + } + element.readOnly = false; + expect(element.shadowRoot.querySelector('[role="grid"]').getAttribute('aria-readonly')).toBe( + 'false' + ); + startDrag(element, view === 'month' ? 'month-move' : 'time-move'); + document.dispatchEvent(pointer('pointerup', 10, 660)); + expect(changed).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(0); + const added = jest.fn(); + element.addEventListener('calendar-event-add', added); + element.shadowRoot.querySelector('#event-modal').dispatchEvent(uiEvent('save', seed('new'))); + expect(added).toHaveBeenCalledTimes(1); + } + ); + + test.each(['week', 'day'])( + '%s restores resize and range creation after readOnly is disabled', + view => { + const element = create(view, true); + element.readOnly = false; + const updated = jest.fn(); + const range = jest.fn(); + element.addEventListener('calendar-event-update', updated); + element.addEventListener('calendar-range-select', range); + startDrag(element, 'resize'); + document.dispatchEvent(pointer('pointerup', 10, 660)); + expect(updated).toHaveBeenCalledTimes(1); + expect(element.getEvents()[0].end.getHours()).toBe(12); + jest.advanceTimersByTime(0); + const modal = element.shadowRoot.querySelector('#event-modal'); + const open = jest.spyOn(modal, 'open'); + startDrag(element, 'create'); + document.dispatchEvent(pointer('pointerup', 10, 660)); + expect(range).toHaveBeenCalledTimes(1); + // EventForm.open() reflects its own open attribute, which invokes open() again. + expect(open).toHaveBeenCalledTimes(2); + expect(modal.hasAttribute('open')).toBe(true); + } + ); + + test('changes while detached and after destroy are respected on reattachment', () => { + const element = create('week'); + element.remove(); + element.readOnly = true; + expect(element.stateManager.getState().config.readOnly).toBe(true); + document.body.appendChild(element); + expect(element.getEvents()).toHaveLength(1); + expect(element._currentViewInstance._dragController).toBeNull(); + element.destroy(); + element.readOnly = false; + element.events = [seed('reloaded')]; + element.remove(); + document.body.appendChild(element); + expect(element.readOnly).toBe(false); + expect(element._currentViewInstance._dragController).not.toBeNull(); + expect(element.getEvents()[0].id).toBe('reloaded'); + }); + + test.each(['month', 'week', 'day'])( + '%s keeps trailing-click suppression through a successful re-render', + view => { + const element = create(view); + const other = create('day', true); + const selected = jest.fn(); + const otherSelected = jest.fn(); + element.stateManager.eventBus.on('event:selected', selected); + other.stateManager.eventBus.on('event:selected', otherSelected); + const oldDrag = element._currentViewInstance._dragController; + startDrag(element, view === 'month' ? 'month-move' : 'time-move'); + document.dispatchEvent(pointer('pointerup', 10, 660)); + const liveDrag = element._currentViewInstance._dragController; + expect(oldDrag._destroyed).toBe(true); + expect(liveDrag).not.toBe(oldDrag); + expect(liveDrag._clickCleanup).not.toBeNull(); + other.shadowRoot.querySelector('.fc-event').click(); + expect(otherSelected).toHaveBeenCalledTimes(1); + element.shadowRoot.querySelector('.fc-event').click(); + expect(selected).not.toHaveBeenCalled(); + expect(liveDrag._clickCleanup).toBeNull(); + element.shadowRoot.querySelector('.fc-event').click(); + expect(selected).toHaveBeenCalledTimes(1); + } + ); + + test('toggling readOnly releases the completed-drag click guard held by the replacement renderer', () => { + const element = create('day'); + startDrag(element, 'time-move'); + document.dispatchEvent(pointer('pointerup', 10, 660)); + const liveDrag = element._currentViewInstance._dragController; + expect(liveDrag._clickCleanup).not.toBeNull(); + element.readOnly = true; + expect(liveDrag._clickCleanup).toBeNull(); + const selected = jest.fn(); + element.stateManager.eventBus.on('event:selected', selected); + element.shadowRoot.querySelector('.fc-event').click(); + expect(selected).toHaveBeenCalledTimes(1); + }); + + test('a readOnly calendar does not block another instance or cancel its active interaction', () => { + const readOnly = create('week', true); + const editable = create('day'); + const changed = jest.fn(); + editable.addEventListener('calendar-event-update', changed); + startDrag(editable, 'time-move'); + readOnly.readOnly = false; + readOnly.readOnly = true; + document.dispatchEvent(pointer('pointerup', 10, 660)); + expect(changed).toHaveBeenCalledTimes(1); + expect(editable.getEvents()[0].start.getHours()).toBe(11); + expect(readOnly.getEvents()[0].start.getHours()).toBe(10); + expect(editable.readOnly).toBe(false); + }); +}); + +describe('standalone renderer readOnly and drag cleanup', () => { + const owned = []; + beforeEach(() => jest.useFakeTimers()); + afterEach(() => { + for (const { manager, renderer, container } of owned.splice(0)) { + renderer.cleanup(); + manager.destroy(); + container.remove(); + } + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + function render(Renderer, view, readOnly = false) { + const manager = new StateManager({ view, date: new Date('2026-07-15T12:00:00'), readOnly }); + manager.setEvents([seed()]); + const container = document.createElement('div'); + document.body.appendChild(container); + const renderer = new Renderer(container, manager); + renderer.render(); + const instance = { manager, renderer, container }; + owned.push(instance); + return instance; + } + + test.each([ + ['month', MonthViewRenderer], + ['week', WeekViewRenderer], + ['day', DayViewRenderer] + ])('%s uses the instance config without enabling edits', (view, Renderer) => { + const { renderer, container } = render(Renderer, view, true); + expect(renderer.readOnly).toBe(true); + expect(renderer._dragController).toBeNull(); + expect(container.querySelector('.fc-resize-handle')).toBeNull(); + }); + + test('checks live config when an already-bound pointer interaction starts or ends', () => { + const { manager, renderer, container } = render(DayViewRenderer, 'day'); + const eventEl = container.querySelector('.fc-event'); + const drag = renderer._dragController; + manager.updateConfig({ readOnly: true }); + eventEl.dispatchEvent(pointer('pointerdown')); + expect(drag._active).toBeNull(); + manager.updateConfig({ readOnly: false }); + mockLayout(container); + eventEl.dispatchEvent(pointer('pointerdown')); + document.dispatchEvent(pointer('pointermove', 10, 660)); + expect(drag._active.dragging).toBe(true); + manager.updateConfig({ readOnly: true }); + document.dispatchEvent(pointer('pointerup', 10, 660)); + expect(manager.getEvents()[0].start.getHours()).toBe(10); + expect(drag._active).toBeNull(); + expect(drag._docListeners).toHaveLength(0); + }); + + test('renderer cleanup cancels active drag and its stale pointer handlers cannot re-arm', () => { + const { manager, renderer, container } = render(DayViewRenderer, 'day'); + mockLayout(container); + const eventEl = container.querySelector('.fc-event'); + const drag = renderer._dragController; + eventEl.dispatchEvent(pointer('pointerdown')); + document.dispatchEvent(pointer('pointermove', 10, 660)); + renderer.cleanup(); + eventEl.dispatchEvent(pointer('pointerdown')); + document.dispatchEvent(pointer('pointerup', 10, 660)); + expect(drag._active).toBeNull(); + expect(drag._docListeners).toHaveLength(0); + expect(manager.getEvents()[0].start.getHours()).toBe(10); + }); + + test('post-drag click suppression is instance-scoped and cleanup releases it', () => { + const { renderer, container } = render(DayViewRenderer, 'day'); + const other = render(DayViewRenderer, 'day'); + mockLayout(container); + const eventEl = container.querySelector('.fc-event'); + const drag = renderer._dragController; + eventEl.dispatchEvent(pointer('pointerdown')); + document.dispatchEvent(pointer('pointermove', 10, 660)); + document.dispatchEvent(pointer('pointerup', 10, 660)); + expect(drag._clickCleanup).not.toBeNull(); + const selected = jest.fn(); + other.manager.eventBus.on('event:selected', selected); + other.container.querySelector('.fc-event').click(); + expect(selected).toHaveBeenCalledTimes(1); + renderer.cleanup(); + expect(drag._clickCleanup).toBeNull(); + }); +}); From 902b5c175fcce601596c17db3e2cd9995f118d19 Mon Sep 17 00:00:00 2001 From: thedhanawada <13751641+thedhanawada@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:20:40 +1000 Subject: [PATCH 3/3] chore: resolve compatible development dependency advisories --- package-lock.json | 92 +++++++++++++++++++++++++---------------------- 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index 46d8c2e..36d6c41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1999,9 +1999,9 @@ "license": "Python-2.0" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -2079,9 +2079,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -4004,19 +4004,22 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", - "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -4037,9 +4040,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -4057,11 +4060,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -4108,9 +4111,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001763", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", - "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", "dev": true, "funding": [ { @@ -4463,9 +4466,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", "dev": true, "license": "ISC" }, @@ -4637,9 +4640,9 @@ "license": "Python-2.0" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -6798,11 +6801,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -7368,9 +7374,9 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -7844,9 +7850,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -8176,9 +8182,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ {