From 45ef7a29de4644bda204d7836121c1488fe3c20c Mon Sep 17 00:00:00 2001 From: thedhanawada <13751641+thedhanawada@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:14:32 +1000 Subject: [PATCH 1/5] feat: add accessible event details and guarded edit/delete flows --- README.md | 18 +- src/components/EventDetails.js | 172 +++++++++++ src/components/EventForm.js | 267 +++++++++++------ src/components/ForceCalendar.js | 195 ++++++++++++- src/renderers/BaseViewRenderer.js | 11 + src/utils/DOMUtils.js | 35 ++- tests/types/consumer.ts | 7 + tests/unit/EventDetails.test.js | 467 ++++++++++++++++++++++++++++++ tests/unit/EventForm.test.js | 47 ++- tests/unit/ReadOnly.test.js | 5 +- 10 files changed, 1115 insertions(+), 109 deletions(-) create mode 100644 src/components/EventDetails.js create mode 100644 tests/unit/EventDetails.test.js diff --git a/README.md b/README.md index f9e9f56..99d6ef9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,18 @@ 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. +## Event details and editing + +Click an event, or focus it and press Enter/Space, to open its details: title, the displayed instance's date/time, location and available actions. Times use the browser's local display, as the calendar grid does. The calendar-owned dialog uses ordinary DOM nodes inside the calendar's shadow root, without a portal or the native Popover API. + +- **Edit** prefills the existing event. Save updates its stable id and emits the normal `calendar-event-update` / `calendar-event-updated` pair; it does not add a duplicate. Description, attendees, metadata and other host-owned fields are retained. Unchanged date inputs preserve the original full-precision instants, including DST-overlap times. +- **Delete** first opens an explicit confirmation. Cancel or Escape returns to details; confirming emits the normal remove/deleted pair. Failed saves/deletes leave the draft or confirmation available to retry. +- Close, backdrop click and Escape dismiss details; Tab stays inside the dialog and focus returns to the event (or Today after deletion). Cancelling the editor discards its draft. Navigation, teardown and enabling read-only mode dismiss active interactions. +- If the event changes or disappears while being edited, the draft stays visible with an error and cannot overwrite the newer data. Close and reopen the event to edit its current state. Changes to unrelated events or host-owned metadata do not block a save. +- Recurring details display the clicked occurrence's time, with a clear recurring-event notice. Built-in Edit/Delete are intentionally unavailable for recurring events until an explicit series/occurrence editing workflow is supported; host APIs remain available. + +The standalone `EventForm` also supports `edit(event)` and `open(start, end)` for prefilled editing and creation ranges. Its cancellable `save` event carries editable fields; a host can call `preventDefault()` and `showError(message)` to retain a failed draft. Its `close` event reports `{ restoreFocus }`. The main calendar wires these to its state manager automatically. + ## 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`. @@ -72,9 +84,9 @@ 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. +- Disables New Event, form creation/saves, event dragging, resizing and drag-to-create in month/week/day views. Resize handles and details Edit/Delete actions are omitted and grids expose `aria-readonly`. +- Keeps event details, mouse/keyboard event and date selection, grid focus navigation, view switching and date navigation available. +- Enabling it closes the current details, deletion confirmation or event 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. diff --git a/src/components/EventDetails.js b/src/components/EventDetails.js new file mode 100644 index 0000000..ae266b1 --- /dev/null +++ b/src/components/EventDetails.js @@ -0,0 +1,172 @@ +import { DOMUtils } from '../utils/DOMUtils.js'; + +/** + * Calendar-owned event details. Uses ordinary DOM nodes in the calendar's + * shadow root, without portals, native popover APIs or another custom element. + */ +export class EventDetails { + constructor(container, { onEdit, onDelete, onClose }) { + this.container = container; + this.onEdit = onEdit; + this.onDelete = onDelete; + this.onClose = onClose; + this._cleanupFocusTrap = null; + this._click = e => { + e.stopPropagation(); + if (e.target === this.container) this.close(); + }; + this._keydown = e => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + if (this._confirming) this._render(); + else this.close(); + } + }; + this._resize = () => { + const dialog = this.container.firstElementChild; + if (!this.container.hidden && dialog) this._position(dialog); + }; + container.addEventListener('click', this._click); + container.addEventListener('keydown', this._keydown); + } + + static getStyles() { + return ` + .fc-details-overlay[hidden] { display: none; } + .fc-details-overlay { position: fixed; inset: 0; z-index: var(--fc-z-modal); } + .fc-details-dialog { + position: absolute; width: 360px; max-width: calc(100vw - 24px); + max-height: calc(100vh - 24px); overflow-y: auto; + padding: var(--fc-spacing-lg); background: var(--fc-background); + color: var(--fc-text-color); border: 1px solid var(--fc-border-color); + border-radius: var(--fc-border-radius-lg); box-shadow: var(--fc-shadow-lg); + overflow-wrap: anywhere; + } + .fc-details-dialog h3 { margin: 0 0 12px; font-size: var(--fc-font-size-lg); } + .fc-details-dialog p { margin: 8px 0; } + .fc-details-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 16px; } + .fc-details-note { color: var(--fc-text-secondary); font-size: var(--fc-font-size-sm); } + .fc-details-dialog button:focus-visible { outline: 2px solid var(--fc-primary-color); outline-offset: 2px; } + `; + } + + open(instance, anchor, readOnly = false, locale = 'en-US') { + this.instance = instance; + this.anchor = anchor; + this.readOnly = readOnly; + this.locale = locale; + this.container.hidden = false; + this.container.ownerDocument.defaultView.addEventListener('resize', this._resize); + this._render(); + } + + _button(label, action, id) { + const button = this.container.ownerDocument.createElement('button'); + button.type = 'button'; + button.className = 'fc-btn fc-btn-secondary'; + button.id = id; + button.textContent = label; + button.addEventListener('click', () => { + if (!this.container.hidden && this.container.contains(button)) action(); + }); + return button; + } + + _render(confirming = false) { + this._cleanupFocusTrap?.(); + this._confirming = confirming; + const { event, start, end } = this.instance; + const doc = this.container.ownerDocument; + const dialog = doc.createElement('section'); + dialog.className = 'fc-details-dialog'; + dialog.setAttribute('role', confirming ? 'alertdialog' : 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + dialog.setAttribute('aria-labelledby', 'fc-details-title'); + const title = doc.createElement('h3'); + title.id = 'fc-details-title'; + title.textContent = confirming ? 'Delete event?' : event.title || 'Untitled event'; + dialog.appendChild(title); + const description = doc.createElement('p'); + description.id = 'fc-details-description'; + dialog.setAttribute('aria-describedby', description.id); + if (confirming) { + description.textContent = `Delete “${event.title || 'Untitled event'}”? This cannot be undone in the calendar.`; + } else { + const options = { dateStyle: 'medium', ...(event.allDay ? {} : { timeStyle: 'short' }) }; + const format = new Intl.DateTimeFormat(this.locale, options); + description.textContent = `${format.format(start)} – ${format.format(end)}${event.allDay ? ' · All day' : ' · Local time'}`; + } + dialog.appendChild(description); + if (!confirming && event.location) { + const location = doc.createElement('p'); + location.textContent = `Location: ${event.location}`; + dialog.appendChild(location); + } + const actions = doc.createElement('div'); + actions.className = 'fc-details-actions'; + if (confirming) { + actions.appendChild(this._button('Cancel', () => this._render(), 'fc-delete-cancel')); + actions.appendChild(this._button('Delete event', () => this.onDelete(), 'fc-delete-confirm')); + } else { + actions.appendChild(this._button('Close', () => this.close(), 'fc-details-close')); + if (!this.readOnly && !EventDetails.isRecurring(event)) { + actions.appendChild(this._button('Edit', () => this.onEdit(), 'fc-details-edit')); + actions.appendChild(this._button('Delete', () => this._render(true), 'fc-details-delete')); + } + if (EventDetails.isRecurring(event)) { + const note = doc.createElement('p'); + note.className = 'fc-details-note'; + note.textContent = 'Recurring event. Editing and deleting a series is not available here.'; + dialog.appendChild(note); + } + } + dialog.appendChild(actions); + this.container.replaceChildren(dialog); + this._position(dialog); + this._cleanupFocusTrap = DOMUtils.trapFocus(dialog); + } + + static isRecurring(event) { + return Boolean( + event.recurring || event.recurrenceRule || event.isOccurrence || event.recurringEventId + ); + } + + _position(dialog) { + const bounds = this.anchor?.getBoundingClientRect(); + const win = this.container.ownerDocument.defaultView; + const width = dialog.getBoundingClientRect().width || 360; + const height = dialog.getBoundingClientRect().height || 250; + const left = Math.max(12, Math.min(bounds?.left ?? 12, win.innerWidth - width - 12)); + const top = Math.max(12, Math.min(bounds?.bottom ?? 12, win.innerHeight - height - 12)); + dialog.style.left = `${left}px`; + dialog.style.top = `${top}px`; + } + + showError(message) { + let error = this.container.querySelector('[role="alert"]'); + if (!error) { + error = this.container.ownerDocument.createElement('p'); + error.setAttribute('role', 'alert'); + this.container.firstElementChild?.appendChild(error); + } + error.textContent = message; + } + + close(restoreFocus = true) { + if (this.container.hidden) return; + this._cleanupFocusTrap?.(); + this._cleanupFocusTrap = null; + this.container.hidden = true; + this.container.ownerDocument.defaultView.removeEventListener('resize', this._resize); + this.container.replaceChildren(); + this.onClose(restoreFocus); + } + + destroy() { + this.close(false); + this.container.removeEventListener('click', this._click); + this.container.removeEventListener('keydown', this._keydown); + } +} diff --git a/src/components/EventForm.js b/src/components/EventForm.js index b288825..aa87a88 100644 --- a/src/components/EventForm.js +++ b/src/components/EventForm.js @@ -2,10 +2,23 @@ import { BaseComponent } from '../core/BaseComponent.js'; import { StyleUtils } from '../utils/StyleUtils.js'; import { DOMUtils } from '../utils/DOMUtils.js'; +/** + * @typedef {Object} EditableEvent + * @property {string} id + * @property {Date|string} start + * @property {Date|string} end + * @property {string} [title] + * @property {string} [location] + * @property {boolean} [allDay] + * @property {string|null} [backgroundColor] + */ + export class EventForm extends BaseComponent { constructor() { super(); this._isVisible = false; + /** @type {string|null} */ + this.editingEventId = null; this._cleanupFocusTrap = null; this.config = { title: 'New Event', @@ -33,7 +46,7 @@ export class EventForm extends BaseComponent { } attributeChangedCallback(name, oldValue, newValue) { - if (name === 'open') { + if (name === 'open' && oldValue !== newValue && !this._reflectingOpen) { if (newValue !== null) { this.open(); } else { @@ -231,7 +244,7 @@ export class EventForm extends BaseComponent { return ` + `; @@ -981,13 +1074,17 @@ export class ForceCalendar extends BaseComponent { if (createBtn && modal) { this.addListener(createBtn, 'click', () => { - if (!this.readOnly) modal.open(new Date()); + if (!this.readOnly) { + this._closeEventInteractions(false); + modal.open(new Date()); + } }); } // Listen for day clicks from the view this.addListener(this.shadowRoot, 'day-click', e => { if (modal && !this.readOnly) { + this._closeEventInteractions(false); modal.open(e.detail.date); } }); @@ -998,25 +1095,88 @@ export class ForceCalendar extends BaseComponent { this.emit('calendar-range-select', e.detail); // A host listener may have toggled readOnly or replaced the view. if (modal?.isConnected && !this.readOnly) { - modal.open(e.detail.start); + this._closeEventInteractions(false); + modal.open(e.detail.start, e.detail.end); } }); - // Handle event saving + const detailsContainer = this.$('#event-details'); + if (detailsContainer) { + this._eventDetails = new EventDetails(detailsContainer, { + onEdit: () => { + const event = this._editableInteraction(); + if (!event || !modal) return; + this._eventDetails.close(false); + modal.edit(event); + }, + onDelete: () => this._deleteInteraction(), + onClose: restoreFocus => { + if (restoreFocus) this._restoreEventFocus(); + } + }); + } + this.addListener(this.shadowRoot, 'event-activate', e => { + const { eventId, anchor } = e.detail; + const instance = this.stateManager.resolveEventInstance(eventId); + if (!instance || !anchor?.isConnected || !this._eventDetails) return; + this._closeEventInteractions(false); + this._eventInteraction = { + eventId, + anchor, + masterId: instance.event.id, + fingerprint: this._eventFingerprint(instance.event) + }; + this._eventDetails.open( + instance, + anchor, + this.readOnly, + this.stateManager.getState().config.locale + ); + }); + if (modal) { + this.addListener(modal, 'close', e => { + if (e.detail.restoreFocus) this._restoreEventFocus(); + }); this.addListener(modal, 'save', e => { - if (this.readOnly || !this._isInitialised()) return; - const eventData = e.detail; - // Robust Safari support check for randomUUID - const id = - window.crypto && typeof window.crypto.randomUUID === 'function' - ? window.crypto.randomUUID() - : Math.random().toString(36).substring(2, 15); - - this.stateManager.addEvent({ - id, - ...eventData - }); + if (this.readOnly || !this._isInitialised() || !modal.isConnected) { + e.preventDefault(); + return; + } + const editing = modal.editingEventId !== null; + const existing = editing ? this._editableInteraction() : null; + if (editing && (!existing || existing.id !== modal.editingEventId)) { + e.preventDefault(); + modal.showError( + 'This event changed or was removed. Close this form and reopen the event before editing.' + ); + return; + } + this._savingEvent = true; + try { + // Only pass editable fields. Core retains description, metadata, + // external ids and other host-owned event properties on update. + const { title, start, end, location, allDay, backgroundColor } = e.detail; + const patch = { title, start, end, location, allDay, backgroundColor }; + const result = editing + ? this.stateManager.updateEvent(existing.id, { + ...patch, + endTimeZone: existing.endTimeZone + }) + : this.stateManager.addEvent({ + id: + window.crypto && typeof window.crypto.randomUUID === 'function' + ? window.crypto.randomUUID() + : Math.random().toString(36).substring(2, 15), + ...patch + }); + if (!result) throw new Error('Event could not be saved'); + } catch (_) { + e.preventDefault(); + modal.showError('The event could not be saved. Check its details and try again.'); + } finally { + this._savingEvent = false; + } }); } @@ -1257,6 +1417,9 @@ export class ForceCalendar extends BaseComponent { } _releaseBindings() { + this._closeEventInteractions(false); + this._eventDetails?.destroy(); + this._eventDetails = null; this._cancelInitialRangeAnnouncement(); if (this._stateUnsubscribe) { diff --git a/src/renderers/BaseViewRenderer.js b/src/renderers/BaseViewRenderer.js index 4170b50..6f92f41 100644 --- a/src/renderers/BaseViewRenderer.js +++ b/src/renderers/BaseViewRenderer.js @@ -338,6 +338,17 @@ export class BaseViewRenderer { const event = this.stateManager.findEvent(eventId); if (event) { this.stateManager.selectEvent(event); + // Keep the occurrence id and invoking node: the selected master alone + // loses the clicked instance's date and the keyboard return target. + if (this.container.contains(eventEl)) { + this.container.dispatchEvent( + new CustomEvent('event-activate', { + detail: { eventId, anchor: eventEl }, + bubbles: true, + composed: true + }) + ); + } } } diff --git a/src/utils/DOMUtils.js b/src/utils/DOMUtils.js index bac5020..4d44c31 100644 --- a/src/utils/DOMUtils.js +++ b/src/utils/DOMUtils.js @@ -288,9 +288,38 @@ export class DOMUtils { return element.cloneNode(deep); } - /** - * Focus trap for modals/dialogs - */ + /** Check return-focus targets across light DOM and shadow-host ancestors. */ + static canRestoreFocus(element) { + if ( + !element?.isConnected || + typeof element.focus !== 'function' || + element.matches(':disabled') + ) + return false; + for ( + let node = element; + node; + node = node.assignedSlot || node.parentElement || node.getRootNode().host + ) { + if ( + node.hidden || + node.inert || + node.hasAttribute('inert') || + node.getAttribute('aria-hidden') === 'true' + ) + return false; + const style = node.ownerDocument.defaultView.getComputedStyle(node); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.visibility === 'collapse' + ) + return false; + } + return true; + } + + /** Focus trap for modals/dialogs. */ static trapFocus(container) { const focusableElements = container.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' diff --git a/tests/types/consumer.ts b/tests/types/consumer.ts index 499047b..ef47d6a 100644 --- a/tests/types/consumer.ts +++ b/tests/types/consumer.ts @@ -3,6 +3,7 @@ import { BaseViewRenderer, DateUtils, ForceCalendar, + EventForm, StateManager, type CalendarEvent, type ForceCalendarElement @@ -28,3 +29,9 @@ const readOnly: boolean = element.readOnly; const classReadOnly: boolean = new ForceCalendar().readOnly; void readOnly; void classReadOnly; + +const editor = new EventForm(); +editor.open(new Date(), new Date()); +editor.edit({ id: 'existing', title: 'Existing event', start: new Date(), end: new Date(), backgroundColor: null }); +editor.showError('Please try again'); +editor.close(false); diff --git a/tests/unit/EventDetails.test.js b/tests/unit/EventDetails.test.js new file mode 100644 index 0000000..3849bb0 --- /dev/null +++ b/tests/unit/EventDetails.test.js @@ -0,0 +1,467 @@ +import '../../src/components/ForceCalendar.js'; +import { Event as CoreEvent } from '@forcecalendar/core'; + +const seed = (id = 'meeting') => ({ + id, + title: 'Planning', + location: 'Boardroom', + start: new Date(2026, 6, 15, 10), + end: new Date(2026, 6, 15, 11), + backgroundColor: '#123456', + description: 'Keep this description', + metadata: { salesforceId: 'record-42', source: { name: 'CRM' } }, + attendees: [{ email: 'attendee@example.com', name: 'Attendee' }] +}); +const key = (element, value, shiftKey = false) => + element.dispatchEvent( + new KeyboardEvent('keydown', { + key: value, + shiftKey, + bubbles: true, + composed: true, + cancelable: true + }) + ); +const click = element => + element.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + +describe('built-in event details and editing', () => { + const owned = []; + const create = (view = 'month', events = [seed()]) => { + const calendar = document.createElement('forcecal-main'); + calendar.setAttribute('date', '2026-07-15T12:00:00'); + calendar.setAttribute('view', view); + calendar.events = events; + document.body.appendChild(calendar); + owned.push(calendar); + return calendar; + }; + const q = (calendar, selector) => calendar.shadowRoot.querySelector(selector); + const form = calendar => q(calendar, '#event-modal'); + const activate = (calendar, id = 'meeting') => { + const chip = [...calendar.shadowRoot.querySelectorAll('.fc-event')].find( + el => el.dataset.eventId === id + ); + chip.focus(); + click(chip); + return chip; + }; + const edit = calendar => { + activate(calendar); + click(q(calendar, '#fc-details-edit')); + return form(calendar); + }; + beforeEach(() => jest.useFakeTimers()); + afterEach(() => { + owned.forEach(calendar => { + calendar.destroy(); + calendar.remove(); + }); + owned.length = 0; + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + test.each(['month', 'week', 'day'])( + '%s activation opens accessible details before editing', + view => { + const calendar = create(view); + activate(calendar); + const dialog = q(calendar, '[role="dialog"]'); + expect(dialog).not.toBeNull(); + expect(dialog.getAttribute('aria-modal')).toBe('true'); + expect(q(calendar, '#fc-details-title').textContent).toBe('Planning'); + expect(dialog.textContent).toContain('Boardroom'); + expect(dialog.textContent).toContain('10:00'); + expect(calendar.shadowRoot.activeElement.id).toBe('fc-details-close'); + expect(form(calendar).hasAttribute('open')).toBe(false); + expect(calendar.getEvents()).toHaveLength(1); + } + ); + + test.each(['Enter', ' '])('%s opens details from a focused event', value => { + const calendar = create(); + const chip = q(calendar, '.fc-event'); + chip.focus(); + key(chip, value); + expect(q(calendar, '#event-details').hidden).toBe(false); + expect(calendar.stateManager.getState().selectedEvent.id).toBe('meeting'); + }); + + test('details text cannot introduce markup or script', () => { + const hostile = ''; + const calendar = create('month', [{ ...seed(), title: hostile, location: hostile }]); + activate(calendar); + const dialog = q(calendar, '[role="dialog"]'); + expect(dialog.textContent).toContain(hostile); + expect(dialog.querySelector('img')).toBeNull(); + click(q(calendar, '#fc-details-delete')); + expect(q(calendar, '[role="alertdialog"]').textContent).toContain(hostile); + expect(q(calendar, '[role="alertdialog"] img')).toBeNull(); + }); + + test('Tab and Shift+Tab wrap inside details, Escape restores the invoking event', () => { + const calendar = create(); + const chip = activate(calendar); + const first = q(calendar, '#fc-details-close'); + const last = q(calendar, '#fc-details-delete'); + key(first, 'Tab', true); + expect(calendar.shadowRoot.activeElement).toBe(last); + key(last, 'Tab'); + expect(calendar.shadowRoot.activeElement).toBe(first); + key(first, 'Escape'); + expect(q(calendar, '#event-details').hidden).toBe(true); + expect(calendar.shadowRoot.activeElement).toBe(chip); + }); + + test.each(['close', 'backdrop'])('%s dismisses without mutation and restores focus', action => { + const calendar = create(); + const chip = activate(calendar); + const changed = jest.fn(); + calendar.addEventListener('calendar-event-update', changed); + click(q(calendar, action === 'close' ? '#fc-details-close' : '#event-details')); + expect(calendar.shadowRoot.activeElement).toBe(chip); + expect(q(calendar, '#event-details').hidden).toBe(true); + expect(changed).not.toHaveBeenCalled(); + }); + + test('inside clicks do not dismiss across a nested shadow boundary', () => { + const wrapper = document.createElement('div'); + wrapper.attachShadow({ mode: 'open' }); + document.body.appendChild(wrapper); + const calendar = create(); + wrapper.shadowRoot.appendChild(calendar); + try { + activate(calendar); + click(q(calendar, '#fc-details-title')); + expect(q(calendar, '#event-details').hidden).toBe(false); + } finally { + document.body.appendChild(calendar); + wrapper.remove(); + } + }); + + test('editing prefills and updates once without adding or losing host-owned fields', () => { + const calendar = create(); + const previous = calendar.getEvents()[0]; + const added = jest.fn(), + updated = jest.fn(); + calendar.addEventListener('calendar-event-add', added); + calendar.addEventListener('calendar-event-update', updated); + const editor = edit(calendar); + expect(q(calendar, '#event-details').hidden).toBe(true); + expect(editor.titleInput.value).toBe('Planning'); + expect(editor.locationInput.value).toBe('Boardroom'); + expect(editor.startInput.value).toBe('2026-07-15T10:00'); + expect(editor._formData.color).toBe('#123456'); + editor.titleInput.value = 'Updated planning'; + editor.locationInput.value = 'Room 2'; + editor.endInput.value = '2026-07-15T12:30'; + expect(previous.title).toBe('Planning'); + click(editor.$('#save-btn')); + expect(added).not.toHaveBeenCalled(); + expect(updated).toHaveBeenCalledTimes(1); + expect(calendar.getEvents()).toHaveLength(1); + const event = calendar.getEvents()[0]; + expect(event.id).toBe('meeting'); + expect(event.title).toBe('Updated planning'); + expect(event.location).toBe('Room 2'); + expect(event.end.getHours()).toBe(12); + expect(event.backgroundColor).toBe('#123456'); + expect(event.description).toBe(previous.description); + expect(event.metadata).toEqual(previous.metadata); + expect(event.attendees).toEqual(previous.attendees); + expect(editor.hasAttribute('open')).toBe(false); + expect(calendar.shadowRoot.activeElement.dataset.eventId).toBe('meeting'); + }); + + test.each(['cancel', 'escape', 'backdrop'])( + '%s discards editing without modifying the event', + action => { + const calendar = create(); + const previous = calendar.getEvents()[0]; + const editor = edit(calendar); + editor.titleInput.value = 'Unsaved'; + if (action === 'escape') key(editor.titleInput, 'Escape'); + else click(action === 'cancel' ? editor.$('#cancel-btn') : editor); + expect(editor.hasAttribute('open')).toBe(false); + expect(calendar.getEvents()[0]).toBe(previous); + expect(calendar.shadowRoot.activeElement.dataset.eventId).toBe('meeting'); + } + ); + + test('title-only edit preserves sub-minute instants and nullable color', () => { + const input = { + ...seed(), + start: new Date(2026, 6, 15, 10, 0, 1, 123), + end: new Date(2026, 6, 15, 10, 0, 59, 987), + backgroundColor: null, + timeZone: 'UTC', + endTimeZone: 'UTC' + }; + const calendar = create('month', [input]); + const editor = edit(calendar); + expect(editor.startInput.value).toBe(editor.endInput.value); + editor.titleInput.value = 'Precise event'; + click(editor.$('#save-btn')); + const saved = calendar.getEvents()[0]; + expect(editor.hasAttribute('open')).toBe(false); + expect(saved.start.getTime()).toBe(input.start.getTime()); + expect(saved.end.getTime()).toBe(input.end.getTime()); + expect(saved.backgroundColor).toBeNull(); + expect(saved.timeZone).toBe('UTC'); + expect(saved.endTimeZone).toBe('UTC'); + }); + + test('title-only edit preserves the distinct destination time zone of a flight', () => { + const input = { + ...seed(), + end: new Date(2026, 6, 15, 18), + timeZone: 'America/New_York', + endTimeZone: 'Europe/London' + }; + const calendar = create('month', [input]); + const previousEndUTC = calendar.getEvents()[0].endUTC; + const editor = edit(calendar); + editor.titleInput.value = 'Flight details'; + click(editor.$('#save-btn')); + expect(calendar.getEvents()[0].timeZone).toBe('America/New_York'); + expect(calendar.getEvents()[0].endTimeZone).toBe('Europe/London'); + expect(calendar.getEvents()[0].endUTC).toEqual(previousEndUTC); + }); + + test('title-only edit retains exact instants through a DST fall-back hour', () => { + // Run this suite under TZ=America/New_York as well as the default zone. + const input = { + ...seed(), + start: new Date('2026-11-01T05:45:00Z'), + end: new Date('2026-11-01T06:15:00Z') + }; + const calendar = create('month', []); + calendar.setDate(new Date(2026, 10, 1, 12)); + calendar.events = [input]; + const editor = edit(calendar); + editor.titleInput.value = 'DST event'; + click(editor.$('#save-btn')); + expect(editor.hasAttribute('open')).toBe(false); + expect(calendar.getEvents()[0].start.getTime()).toBe(input.start.getTime()); + expect(calendar.getEvents()[0].end.getTime()).toBe(input.end.getTime()); + }); + + test.each(['hidden', 'inert', 'aria-hidden', 'display', 'visibility'])( + 'does not restore focus into a %s calendar', + state => { + const calendar = create(); + const chip = activate(calendar); + const focus = jest.spyOn(chip, 'focus'); + if (state === 'display') calendar.style.display = 'none'; + else if (state === 'visibility') calendar.style.visibility = 'hidden'; + else calendar.setAttribute(state, state === 'aria-hidden' ? 'true' : ''); + click(q(calendar, '#fc-details-close')); + expect(focus).not.toHaveBeenCalled(); + } + ); + + test('creation after editing has no stale id, location, all-day or color', () => { + const calendar = create('month', [{ ...seed(), allDay: true }]); + const editor = edit(calendar); + expect(editor.allDayInput.checked).toBe(true); + click(editor.$('#cancel-btn')); + click(q(calendar, '#create-event-btn')); + expect(editor.editingEventId).toBeNull(); + expect(editor.titleInput.value).toBe(''); + expect(editor.locationInput.value).toBe(''); + expect(editor.allDayInput.checked).toBe(false); + expect(editor._formData.color).toBe(editor.config.colors[0].color); + editor.titleInput.value = 'New event'; + click(editor.$('#save-btn')); + expect(calendar.getEvents()).toHaveLength(2); + expect(calendar.getEvents()[0].id).toBe('meeting'); + }); + + test('delete requires a confirmation, Cancel and Escape both retain the event', () => { + const calendar = create(); + activate(calendar); + click(q(calendar, '#fc-details-delete')); + expect(calendar.getEvents()).toHaveLength(1); + expect(q(calendar, '[role="alertdialog"]')).not.toBeNull(); + expect(calendar.shadowRoot.activeElement.id).toBe('fc-delete-cancel'); + click(q(calendar, '#fc-delete-cancel')); + expect(q(calendar, '[role="dialog"]')).not.toBeNull(); + click(q(calendar, '#fc-details-delete')); + key(q(calendar, '#fc-delete-cancel'), 'Escape'); + expect(q(calendar, '[role="dialog"]')).not.toBeNull(); + expect(calendar.getEvents()).toHaveLength(1); + }); + + test('confirmed delete removes once and restores a valid fallback focus target', () => { + const calendar = create(); + const deleted = jest.fn(); + calendar.addEventListener('calendar-event-remove', deleted); + activate(calendar); + click(q(calendar, '#fc-details-delete')); + const confirm = q(calendar, '#fc-delete-confirm'); + click(confirm); + click(confirm); + expect(calendar.getEvents()).toHaveLength(0); + expect(deleted).toHaveBeenCalledTimes(1); + expect(deleted.mock.calls[0][0].detail.eventId).toBe('meeting'); + expect(q(calendar, '#event-details').hidden).toBe(true); + expect(calendar.shadowRoot.activeElement.dataset.action).toBe('today'); + }); + + test('stale confirmation controls cannot delete a later selected event', () => { + const calendar = create('month', [seed(), seed('another')]); + activate(calendar); + click(q(calendar, '#fc-details-delete')); + const oldConfirm = q(calendar, '#fc-delete-confirm'); + click(q(calendar, '#event-details')); + activate(calendar, 'another'); + click(oldConfirm); + expect(calendar.getEvents()).toHaveLength(2); + }); + + test.each(['update', 'remove'])( + 'external %s leaves edits visible but prevents stale writes', + action => { + const calendar = create(); + const editor = edit(calendar); + editor.titleInput.value = 'Unsaved work'; + if (action === 'update') calendar.updateEvent('meeting', { title: 'Changed on server' }); + else calendar.deleteEvent('meeting'); + const mutate = jest.spyOn(calendar.stateManager, 'updateEvent'); + expect(editor.titleInput.value).toBe('Unsaved work'); + expect(editor.$('#save-error').hidden).toBe(false); + click(editor.$('#save-btn')); + expect(mutate).not.toHaveBeenCalled(); + expect(editor.hasAttribute('open')).toBe(true); + } + ); + + test('unrelated hydration does not prevent editing and preserves updated metadata', () => { + const calendar = create(); + const editor = edit(calendar); + calendar.updateEvent('meeting', { metadata: { source: 'refreshed' } }); + calendar.addEvent(seed('another')); + editor.titleInput.value = 'My change'; + click(editor.$('#save-btn')); + expect(calendar.getEvents().find(event => event.id === 'meeting').metadata).toEqual({ + source: 'refreshed' + }); + expect(calendar.getEvents().find(event => event.id === 'meeting').title).toBe('My change'); + }); + + test('save failure preserves the draft and allows retry without adding an event', () => { + const calendar = create(); + const editor = edit(calendar); + editor.titleInput.value = 'Retry me'; + const update = jest.spyOn(calendar.stateManager, 'updateEvent').mockImplementationOnce(() => { + throw new Error('offline'); + }); + click(editor.$('#save-btn')); + expect(editor.hasAttribute('open')).toBe(true); + expect(editor.$('#save-error').textContent).toContain('could not be saved'); + click(editor.$('#save-btn')); + expect(update).toHaveBeenCalledTimes(2); + expect(editor.hasAttribute('open')).toBe(false); + expect(calendar.getEvents()).toHaveLength(1); + expect(calendar.getEvents()[0].title).toBe('Retry me'); + }); + + test('delete failure preserves the confirmation and supports retry', () => { + const calendar = create(); + activate(calendar); + click(q(calendar, '#fc-details-delete')); + jest.spyOn(calendar.stateManager, 'deleteEvent').mockImplementationOnce(() => { + throw new Error('offline'); + }); + click(q(calendar, '#fc-delete-confirm')); + expect(q(calendar, '[role="alert"] ').textContent).toContain('could not be deleted'); + expect(calendar.getEvents()).toHaveLength(1); + click(q(calendar, '#fc-delete-confirm')); + expect(calendar.getEvents()).toHaveLength(0); + }); + + test.each(['month', 'week', 'day'])('%s readOnly permits details and blocks mutations', view => { + const calendar = create(view); + calendar.readOnly = true; + activate(calendar); + expect(q(calendar, '#fc-details-title').textContent).toBe('Planning'); + expect(q(calendar, '#fc-details-edit')).toBeNull(); + expect(q(calendar, '#fc-details-delete')).toBeNull(); + }); + + test.each(['edit', 'delete'])('readOnly toggle cancels an active %s flow', action => { + const calendar = create(); + activate(calendar); + click(q(calendar, action === 'edit' ? '#fc-details-edit' : '#fc-details-delete')); + const oldConfirm = q(calendar, '#fc-delete-confirm'); + const oldForm = form(calendar); + calendar.readOnly = true; + if (oldConfirm) click(oldConfirm); + oldForm.save(); + expect(calendar.getEvents()).toHaveLength(1); + expect(form(calendar).hasAttribute('open')).toBe(false); + activate(calendar); + expect(q(calendar, '#fc-details-edit')).toBeNull(); + }); + + test.each(['next', 'setView', 'detach', 'destroy'])( + '%s closes interactions and releases old traps', + action => { + const calendar = create(); + activate(calendar); + const details = calendar._eventDetails; + if (action === 'next') calendar.next(); + if (action === 'setView') calendar.setView('week'); + if (action === 'detach') calendar.remove(); + if (action === 'destroy') calendar.destroy(); + expect(details._cleanupFocusTrap).toBeNull(); + expect(details.container.hidden).toBe(true); + if (action === 'detach' || action === 'destroy') { + calendar.remove(); + document.body.appendChild(calendar); + } + expect(form(calendar).hasAttribute('open')).toBe(false); + } + ); + + test('Escape is scoped to its own calendar and repeated open/close cycles do not duplicate updates', () => { + const calendar = create(); + const other = create(); + activate(other); + for (let cycle = 0; cycle < 3; cycle++) { + activate(calendar); + key(q(calendar, '#fc-details-close'), 'Escape'); + } + expect(q(other, '#event-details').hidden).toBe(false); + const update = jest.fn(); + calendar.addEventListener('calendar-event-update', update); + const editor = edit(calendar); + editor.titleInput.value = 'Final'; + click(editor.$('#save-btn')); + expect(update).toHaveBeenCalledTimes(1); + expect(q(other, '#event-details').hidden).toBe(false); + }); + + test.each(['month', 'week', 'day'])( + '%s recurring details use the clicked occurrence and withhold unsafe mutations', + view => { + const recurring = { + ...seed(), + start: new Date(2026, 6, 1, 10), + end: new Date(2026, 6, 1, 11), + recurrenceRule: 'FREQ=WEEKLY;COUNT=5' + }; + const calendar = create(view, [recurring]); + const id = CoreEvent.occurrenceId('meeting', new Date(2026, 6, 15, 10)); + activate(calendar, id); + expect(q(calendar, '#fc-details-description').textContent).toContain('Jul 15, 2026'); + expect(q(calendar, '#fc-details-description').textContent).not.toContain('Jul 1, 2026'); + expect(q(calendar, '[role="dialog"]').textContent).toContain('Recurring event'); + expect(q(calendar, '#fc-details-edit')).toBeNull(); + expect(q(calendar, '#fc-details-delete')).toBeNull(); + expect(calendar.getEvents()[0].start).toEqual(new Date(2026, 6, 1, 10)); + } + ); +}); diff --git a/tests/unit/EventForm.test.js b/tests/unit/EventForm.test.js index 463e32d..c8fe3ff 100644 --- a/tests/unit/EventForm.test.js +++ b/tests/unit/EventForm.test.js @@ -153,4 +153,49 @@ describe('EventForm', () => { }); }); -}); \ No newline at end of file +}); +describe('EventForm editing validation and lifecycle', () => { + let form; + beforeEach(() => { + form = document.createElement('forcecal-event-form'); + document.body.appendChild(form); + }); + afterEach(() => form.remove()); + + test.each(['#event-start', '#event-end'])('rejects a missing or invalid %s without emitting save', selector => { + form.open(new Date(2026, 6, 15, 10)); + form.titleInput.value = 'Valid title'; + form.$(selector).value = ''; + const saved = jest.fn(); + form.addEventListener('save', saved); + form.save(); + expect(saved).not.toHaveBeenCalled(); + expect(form.$(selector).getAttribute('aria-invalid')).toBe('true'); + expect(form.shadowRoot.activeElement).toBe(form.$(selector)); + expect(form.hasAttribute('open')).toBe(true); + }); + + test('attribute opening before attachment and reattachment do not duplicate or preserve stale drafts', () => { + form.remove(); + expect(() => form.setAttribute('open', '')).not.toThrow(); + document.body.appendChild(form); + expect(form.hasAttribute('open')).toBe(true); + form.titleInput.value = 'Unsaved'; + form.remove(); + document.body.appendChild(form); + expect(form.hasAttribute('open')).toBe(false); + expect(form.titleInput.value).toBe(''); + }); + + test('open reflects the attribute without recursive reset or duplicate close events', () => { + const open = jest.spyOn(form, 'open'); + const close = jest.fn(); + form.addEventListener('close', close); + form.open(new Date(2026, 6, 15, 10)); + expect(open).toHaveBeenCalledTimes(1); + expect(form.startInput.value).toBe('2026-07-15T10:00'); + form.close(); + form.close(); + expect(close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/ReadOnly.test.js b/tests/unit/ReadOnly.test.js index c5a3d13..e39b7de 100644 --- a/tests/unit/ReadOnly.test.js +++ b/tests/unit/ReadOnly.test.js @@ -332,8 +332,9 @@ describe('ForceCalendar readOnly contract', () => { 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); + // Reflecting the attribute must not recursively reset the form. + expect(open).toHaveBeenCalledTimes(1); + expect(modal.endInput.value).toBe(modal.formatDateForInput(range.mock.calls[0][0].detail.end)); expect(modal.hasAttribute('open')).toBe(true); } ); From 7c53c9d69df16b29ad9ebd5db6a7956c7e163639 Mon Sep 17 00:00:00 2001 From: thedhanawada <13751641+thedhanawada@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:36:11 +1000 Subject: [PATCH 2/5] feat: [minor] consolidate safe editing and prevent unscoped series mutations --- README.md | 2 +- src/components/EventDetails.js | 5 +- src/core/BaseComponent.js | 24 +++- src/core/DragController.js | 40 ++++-- src/core/EventBus.js | 18 ++- src/utils/EventUtils.js | 7 + tests/browser/focus-restoration.html | 40 ++++++ tests/unit/BaseComponentFocus.test.js | 62 +++++++++ tests/unit/EventBus.test.js | 139 ++++++++++++------- tests/unit/RecurringOccurrences.test.js | 177 +++++++++++------------- 10 files changed, 352 insertions(+), 162 deletions(-) create mode 100644 src/utils/EventUtils.js create mode 100644 tests/browser/focus-restoration.html create mode 100644 tests/unit/BaseComponentFocus.test.js diff --git a/README.md b/README.md index 99d6ef9..0b5e93f 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Rules of the road: ### Recurring events -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. +Rendered chips of a recurring series carry occurrence ids (`_`). Clicking or selecting an occurrence resolves to its master (`stateManager.findEvent(id)`), while details retain the clicked occurrence's time. Built-in drag, resize, Edit and Delete are unavailable for recurring events: changing one occurrence or a whole series requires an explicit scope workflow, which is not supported yet. Host APIs remain available for applications that implement their own scoped editing. ## Event details and editing diff --git a/src/components/EventDetails.js b/src/components/EventDetails.js index ae266b1..08c0248 100644 --- a/src/components/EventDetails.js +++ b/src/components/EventDetails.js @@ -1,4 +1,5 @@ import { DOMUtils } from '../utils/DOMUtils.js'; +import { isRecurringEvent } from '../utils/EventUtils.js'; /** * Calendar-owned event details. Uses ordinary DOM nodes in the calendar's @@ -128,9 +129,7 @@ export class EventDetails { } static isRecurring(event) { - return Boolean( - event.recurring || event.recurrenceRule || event.isOccurrence || event.recurringEventId - ); + return isRecurringEvent(event); } _position(dialog) { diff --git a/src/core/BaseComponent.js b/src/core/BaseComponent.js index d27c800..9a6ac79 100644 --- a/src/core/BaseComponent.js +++ b/src/core/BaseComponent.js @@ -234,7 +234,7 @@ export class BaseComponent extends HTMLElement { if (!selector || !this._contentWrapper) return; try { const el = this._contentWrapper.querySelector(selector); - if (el && typeof el.focus === 'function') { + if (el && typeof el.focus === 'function' && this._canRestoreFocus(el)) { el.focus(); } } catch (_) { @@ -242,6 +242,28 @@ export class BaseComponent extends HTMLElement { } } + /** Avoid restoring focus into hidden, inert, disabled, or detached content. */ + _canRestoreFocus(element) { + if (!element.isConnected || element.matches(':disabled')) return false; + const view = element.ownerDocument.defaultView; + const style = view.getComputedStyle(element); + if (style.visibility === 'hidden' || style.visibility === 'collapse') return false; + + // Walk through shadow hosts too: a visible child can still be inside a + // display:none or inert host. offsetParent is unsuitable for fixed elements. + for (let node = element; node; node = node.parentElement || node.getRootNode().host) { + if ( + node.hidden || + node.hasAttribute('inert') || + node.getAttribute('aria-hidden') === 'true' || + view.getComputedStyle(node).display === 'none' + ) { + return false; + } + } + return true; + } + template() { // Override in child classes to provide component template return ''; diff --git a/src/core/DragController.js b/src/core/DragController.js index 6fdb318..e7e8d3f 100644 --- a/src/core/DragController.js +++ b/src/core/DragController.js @@ -9,6 +9,8 @@ * (arrow keys + Enter) that every view already implements. */ +import { isRecurringEvent } from '../utils/EventUtils.js'; + const DRAG_THRESHOLD_PX = 4; const SNAP_MINUTES = 15; const PX_PER_MINUTE = 1; // time grids render 60px per hour @@ -128,6 +130,7 @@ export class DragController { _arm(e, spec) { if (this._destroyed || this.renderer.readOnly) return; this._cancel(); + if (spec.mode !== 'create' && !this._canMutateInstance(spec.eventId)) return; this._active = { ...spec, startX: e.clientX, @@ -153,8 +156,21 @@ export class DragController { ]; } + _canMutateInstance(eventId) { + const target = this.stateManager.resolveEventInstance(eventId); + return Boolean(target && !isRecurringEvent(target.event)); + } + + _activeMutationBlocked() { + return ( + this._active && + this._active.mode !== 'create' && + !this._canMutateInstance(this._active.eventId) + ); + } + _onPointerMove(e) { - if (this._destroyed || this.renderer.readOnly) { + if (this._destroyed || this.renderer.readOnly || this._activeMutationBlocked()) { this._cancel(); return; } @@ -177,7 +193,7 @@ export class DragController { } _onPointerUp(e) { - if (this._destroyed || this.renderer.readOnly) { + if (this._destroyed || this.renderer.readOnly || this._activeMutationBlocked()) { this._cancel(); return; } @@ -281,7 +297,7 @@ export class DragController { const cell = a.dropCell; if (!cell) return; const target = this.stateManager.resolveEventInstance(a.eventId); - if (!target) return; + if (!target || isRecurringEvent(target.event)) return; const oldStart = target.start; const newStart = moveDatePreservingTime(new Date(cell.dataset.date), oldStart); const delta = newStart.getTime() - oldStart.getTime(); @@ -290,15 +306,14 @@ export class DragController { } /** - * Apply a move to the stored event. Dragging an occurrence of a recurring - * series shifts the whole series by the same delta (there is no - * per-occurrence edit yet), which is why the delta is computed against the - * dragged instance's own times and applied to the master. + * Apply a move only to a non-recurring event. Series/occurrence mutation + * requires an explicit scope UI and is intentionally unavailable here. * @param {import('@forcecalendar/core').Event} event - Stored event (master for an occurrence) * @param {number} delta - Milliseconds to shift start and end by * @private */ _shiftEvent(event, delta) { + if (isRecurringEvent(event)) return; this.stateManager.updateEvent(event.id, { start: new Date(new Date(event.start).getTime() + delta), end: new Date(new Date(event.end).getTime() + delta) @@ -328,7 +343,12 @@ export class DragController { _timeMoveDrop() { const a = this._active ?? {}; const target = this.stateManager.resolveEventInstance(a.eventId); - if (!target || (!a.deltaMinutes && a.dropColumn === a.originColumn)) return; + if ( + !target || + isRecurringEvent(target.event) || + (!a.deltaMinutes && a.dropColumn === a.originColumn) + ) + return; const oldStart = target.start; const duration = target.end.getTime() - oldStart.getTime(); @@ -358,8 +378,7 @@ export class DragController { const a = this._active ?? {}; if (!a.newHeight || a.newHeight === a.originHeight) return; const target = this.stateManager.resolveEventInstance(a.eventId); - if (!target) return; - // Resizing an occurrence changes the duration of the whole series + if (!target || isRecurringEvent(target.event)) return; const { event } = target; const newEnd = new Date(new Date(event.start).getTime() + a.newHeight * 60000); this.stateManager.updateEvent(event.id, { end: newEnd }); @@ -405,6 +424,7 @@ export class DragController { /** Append a resize handle to every timed event (idempotent per render). */ _injectResizeHandles() { for (const el of this.container.querySelectorAll('.fc-timed-event')) { + if (!this._canMutateInstance(el.dataset.eventId)) continue; if (el.querySelector('.fc-resize-handle')) continue; const handle = this.container.ownerDocument.createElement('div'); handle.className = 'fc-resize-handle'; diff --git a/src/core/EventBus.js b/src/core/EventBus.js index 48bd71c..b7b806b 100644 --- a/src/core/EventBus.js +++ b/src/core/EventBus.js @@ -23,7 +23,13 @@ class EventBus { // Handle wildcard subscriptions if (eventName.includes('*')) { - const subscription = { pattern: eventName, handler, once, priority }; + const subscription = { + pattern: eventName, + regex: this.compilePattern(eventName), + handler, + once, + priority + }; this.wildcardHandlers.add(subscription); return () => this.wildcardHandlers.delete(subscription); } @@ -147,7 +153,7 @@ class EventBus { // Handle wildcard subscriptions (copy Set to avoid mutation during iteration) const toRemove = []; for (const subscription of [...this.wildcardHandlers]) { - if (this.matchesPattern(eventName, subscription.pattern)) { + if (subscription.regex.test(eventName)) { const { handler, once } = subscription; if (once) { @@ -170,9 +176,13 @@ class EventBus { * Only `*` acts as a wildcard; all other characters match literally */ matchesPattern(eventName, pattern) { + return this.compilePattern(pattern).test(eventName); + } + + /** Compile a subscription pattern once; only * is a wildcard. */ + compilePattern(pattern) { const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp('^' + escaped.replace(/\*/g, '.*') + '$'); - return regex.test(eventName); + return new RegExp('^' + escaped.replace(/\*/g, '.*') + '$'); } /** diff --git a/src/utils/EventUtils.js b/src/utils/EventUtils.js new file mode 100644 index 0000000..1d7e66b --- /dev/null +++ b/src/utils/EventUtils.js @@ -0,0 +1,7 @@ +/** Whether an event belongs to a recurring series and needs scoped editing. */ +export function isRecurringEvent(event) { + return Boolean( + event && + (event.recurring || event.recurrenceRule || event.isOccurrence || event.recurringEventId) + ); +} diff --git a/tests/browser/focus-restoration.html b/tests/browser/focus-restoration.html new file mode 100644 index 0000000..6508a08 --- /dev/null +++ b/tests/browser/focus-restoration.html @@ -0,0 +1,40 @@ + + + +Focus restoration browser regression +

Focus restoration browser regression

+
Running…
+ + diff --git a/tests/unit/BaseComponentFocus.test.js b/tests/unit/BaseComponentFocus.test.js new file mode 100644 index 0000000..e424e6f --- /dev/null +++ b/tests/unit/BaseComponentFocus.test.js @@ -0,0 +1,62 @@ +import { BaseComponent } from '../../src/core/BaseComponent.js'; + +class FocusFixture extends BaseComponent { + template() { + return '
'; + } +} +customElements.define('fc-focus-fixture', FocusFixture); + +describe('BaseComponent focus restoration', () => { + let component; + let target; + let focus; + beforeEach(() => { + component = document.createElement('fc-focus-fixture'); + document.body.appendChild(component); + target = component.shadowRoot.querySelector('#target'); + focus = jest.spyOn(target, 'focus'); + }); + afterEach(() => { + component.remove(); + }); + + test('restores a visible fixed-position target', () => { + target.style.position = 'fixed'; + component._restoreFocus('#target'); + expect(focus).toHaveBeenCalledTimes(1); + expect(component.shadowRoot.activeElement).toBe(target); + }); + test.each(['display: none', 'visibility: hidden', 'visibility: collapse'])( + 'does not focus a target styled %s', + style => { + target.style.cssText = style; + component._restoreFocus('#target'); + expect(focus).not.toHaveBeenCalled(); + } + ); + test.each(['hidden', 'inert', 'aria-hidden', 'display'])('honors ancestor %s', kind => { + const parent = target.parentElement; + if (kind === 'display') parent.style.display = 'none'; + else parent.setAttribute(kind, kind === 'aria-hidden' ? 'true' : ''); + component._restoreFocus('#target'); + expect(focus).not.toHaveBeenCalled(); + }); + test('honors hidden shadow hosts', () => { + component.style.display = 'none'; + component._restoreFocus('#target'); + expect(focus).not.toHaveBeenCalled(); + }); + test('does not focus disabled or removed targets', () => { + target.disabled = true; + component._restoreFocus('#target'); + target.disabled = false; + target.remove(); + component._restoreFocus('#target'); + expect(focus).not.toHaveBeenCalled(); + }); + test('ignores invalid selectors', () => { + expect(() => component._restoreFocus('[')).not.toThrow(); + expect(focus).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/EventBus.test.js b/tests/unit/EventBus.test.js index 1ef4310..0d89b2c 100644 --- a/tests/unit/EventBus.test.js +++ b/tests/unit/EventBus.test.js @@ -1,56 +1,99 @@ import { EventBus } from '../../src/core/EventBus.js'; describe('EventBus', () => { - let bus; + let bus; - beforeEach(() => { - bus = new EventBus(); + beforeEach(() => { + bus = new EventBus(); + }); + + describe('matchesPattern', () => { + test('matches exact event names', () => { + expect(bus.matchesPattern('event.add', 'event.add')).toBe(true); + }); + + test('treats dots as literals, not regex wildcards', () => { + expect(bus.matchesPattern('event_add', 'event.add')).toBe(false); + expect(bus.matchesPattern('eventXadd', 'event.add')).toBe(false); + }); + + test('supports * as a wildcard', () => { + expect(bus.matchesPattern('event.add', 'event.*')).toBe(true); + expect(bus.matchesPattern('event.remove', 'event.*')).toBe(true); + expect(bus.matchesPattern('view.change', 'event.*')).toBe(false); + }); + + test('supports * in the middle of a pattern', () => { + expect(bus.matchesPattern('event.user.add', 'event.*.add')).toBe(true); + expect(bus.matchesPattern('event.user.remove', 'event.*.add')).toBe(false); }); - describe('matchesPattern', () => { - test('matches exact event names', () => { - expect(bus.matchesPattern('event.add', 'event.add')).toBe(true); - }); - - test('treats dots as literals, not regex wildcards', () => { - expect(bus.matchesPattern('event_add', 'event.add')).toBe(false); - expect(bus.matchesPattern('eventXadd', 'event.add')).toBe(false); - }); - - test('supports * as a wildcard', () => { - expect(bus.matchesPattern('event.add', 'event.*')).toBe(true); - expect(bus.matchesPattern('event.remove', 'event.*')).toBe(true); - expect(bus.matchesPattern('view.change', 'event.*')).toBe(false); - }); - - test('supports * in the middle of a pattern', () => { - expect(bus.matchesPattern('event.user.add', 'event.*.add')).toBe(true); - expect(bus.matchesPattern('event.user.remove', 'event.*.add')).toBe(false); - }); - - test('escapes regex metacharacters in patterns', () => { - expect(bus.matchesPattern('a+b', 'a+b')).toBe(true); - expect(bus.matchesPattern('aab', 'a+b')).toBe(false); - expect(bus.matchesPattern('event(1)', 'event(1)')).toBe(true); - expect(bus.matchesPattern('event1', 'event(1)')).toBe(false); - expect(bus.matchesPattern('a|b', 'a|b')).toBe(true); - expect(bus.matchesPattern('a', 'a|b')).toBe(false); - expect(bus.matchesPattern('item[0]', 'item[0]')).toBe(true); - expect(bus.matchesPattern('item0', 'item[0]')).toBe(false); - expect(bus.matchesPattern('cost$', 'cost$')).toBe(true); - expect(bus.matchesPattern('x?y', 'x?y')).toBe(true); - expect(bus.matchesPattern('xy', 'x?y')).toBe(false); - }); - - test('wildcard subscriptions only fire for literal matches', () => { - const handler = jest.fn(); - bus.on('event.*', handler); - - bus.emit('event.add', { id: 1 }); - bus.emit('eventXadd', { id: 2 }); - - expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith({ id: 1 }, 'event.add'); - }); + test('escapes regex metacharacters in patterns', () => { + expect(bus.matchesPattern('a+b', 'a+b')).toBe(true); + expect(bus.matchesPattern('aab', 'a+b')).toBe(false); + expect(bus.matchesPattern('event(1)', 'event(1)')).toBe(true); + expect(bus.matchesPattern('event1', 'event(1)')).toBe(false); + expect(bus.matchesPattern('a|b', 'a|b')).toBe(true); + expect(bus.matchesPattern('a', 'a|b')).toBe(false); + expect(bus.matchesPattern('item[0]', 'item[0]')).toBe(true); + expect(bus.matchesPattern('item0', 'item[0]')).toBe(false); + expect(bus.matchesPattern('cost$', 'cost$')).toBe(true); + expect(bus.matchesPattern('x?y', 'x?y')).toBe(true); + expect(bus.matchesPattern('xy', 'x?y')).toBe(false); }); + + test('wildcard subscriptions only fire for literal matches', () => { + const handler = jest.fn(); + bus.on('event.*', handler); + + bus.emit('event.add', { id: 1 }); + bus.emit('eventXadd', { id: 2 }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ id: 1 }, 'event.add'); + }); + }); +}); + +describe('compiled wildcard subscriptions', () => { + test('compiles once per subscription rather than per emitted event', () => { + const bus = new EventBus(); + const compile = jest.spyOn(bus, 'compilePattern'); + const handler = jest.fn(); + const unsubscribe = bus.on('event.*', handler); + expect(compile).toHaveBeenCalledTimes(1); + bus.emit('event.add'); + bus.emit('event.remove'); + bus.emit('unrelated'); + expect(compile).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledTimes(2); + unsubscribe(); + bus.emit('event.add'); + expect(handler).toHaveBeenCalledTimes(2); + expect(bus.getWildcardHandlerCount()).toBe(0); + }); + test.each(['off', 'offWildcard', 'offAll', 'clear'])( + '%s removes compiled subscriptions', + method => { + const bus = new EventBus(); + const handler = jest.fn(); + bus.on('event.*', handler); + if (method === 'off') bus.off('event.*', handler); + if (method === 'offWildcard') bus.offWildcard('event.*'); + if (method === 'offAll') bus.offAll(handler); + if (method === 'clear') bus.clear(); + bus.emit('event.add'); + expect(handler).not.toHaveBeenCalled(); + expect(bus.getWildcardHandlerCount()).toBe(0); + } + ); + test('once subscriptions retain their existing one-shot behavior', () => { + const bus = new EventBus(); + const handler = jest.fn(); + bus.once('event.*', handler); + bus.emit('event.add'); + bus.emit('event.add'); + expect(handler).toHaveBeenCalledTimes(1); + expect(bus.getWildcardHandlerCount()).toBe(0); + }); }); diff --git a/tests/unit/RecurringOccurrences.test.js b/tests/unit/RecurringOccurrences.test.js index b35c9e3..ca0c984 100644 --- a/tests/unit/RecurringOccurrences.test.js +++ b/tests/unit/RecurringOccurrences.test.js @@ -1,17 +1,12 @@ 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'; import { Event as CoreEvent } from '@forcecalendar/core'; const pointer = (type, x, y) => new MouseEvent(type, { bubbles: true, composed: true, clientX: x, clientY: y, button: 0 }); -const mockRects = (els, rectFor) => { - els.forEach((el, i) => { - el.getBoundingClientRect = () => rectFor(el, i); - }); -}; - // Weekly series starting Wed 1 Jul 2026 10:00-11:00: 1, 8, 15, 22, 29 July const series = () => ({ id: 'standup', @@ -99,104 +94,96 @@ describe('Clicking a recurring occurrence chip', () => { }); }); -describe('Dragging a recurring occurrence', () => { +describe('Recurring drag/resize safety', () => { let manager, container, renderer; - - afterEach(() => { - renderer.cleanup(); - container.remove(); - manager.destroy(); - }); - - test('in the month view shifts the series by the dragged delta via a resolvable id', () => { - manager = new StateManager({ view: 'month', date: new Date(2026, 6, 15, 12) }); + const mount = (Renderer, view) => { + manager = new StateManager({ view, date: new Date(2026, 6, 15, 12) }); manager.addEvent(series()); container = document.createElement('div'); document.body.appendChild(container); - renderer = new MonthViewRenderer(container, manager); + renderer = new Renderer(container, manager); renderer.render(); - const cells = Array.from(container.querySelectorAll('.fc-month-day')); - mockRects(cells, (el, i) => { - const col = i % 7; - const row = Math.floor(i / 7); - return { left: col * 100, right: col * 100 + 100, top: row * 80, bottom: row * 80 + 80 }; - }); - const updateSpy = jest.spyOn(manager, 'updateEvent'); - - const chip = container.querySelector(`.fc-event[data-event-id="${occurrenceId(15)}"]`); - const originIdx = cells.indexOf(chip.closest('.fc-month-day')); - const targetIdx = originIdx + 2; - const x = (targetIdx % 7) * 100 + 50; - const y = Math.floor(targetIdx / 7) * 80 + 40; - - expect(() => { - chip.dispatchEvent(pointer('pointerdown', 10, 10)); - document.dispatchEvent(pointer('pointermove', x, y)); - document.dispatchEvent(pointer('pointerup', x, y)); - }).not.toThrow(); - - expect(updateSpy).toHaveBeenCalledTimes(1); - const [calledId] = updateSpy.mock.calls[0]; - expect(manager.findEvent(calledId)).toBe(manager.getEvents()[0]); - const master = manager.getEvents()[0]; - expect(master.id).toBe('standup'); - expect(master.recurring).toBe(true); - expect(new Date(master.start)).toEqual(new Date(2026, 6, 3, 10, 0)); - expect(new Date(master.end)).toEqual(new Date(2026, 6, 3, 11, 0)); - expect(container.querySelectorAll('.fc-event[data-event-id^="standup_"]')).toHaveLength(5); - }); - - const mountWeek = () => { - manager = new StateManager({ view: 'week', date: new Date(2026, 6, 15, 12) }); - manager.addEvent(series()); - container = document.createElement('div'); - document.body.appendChild(container); - renderer = new WeekViewRenderer(container, manager); - renderer.render(); - mockRects(Array.from(container.querySelectorAll('.fc-week-day-column')), (el, i) => ({ - left: i * 120, - right: i * 120 + 120, - top: 0, - bottom: 1440 - })); return jest.spyOn(manager, 'updateEvent'); }; - const chipSelector = `.fc-timed-event[data-event-id="${occurrenceId(15)}"]`; - - test('in the time grid moves the series by the snapped delta', () => { - const updateSpy = mountWeek(); - const chip = container.querySelector(chipSelector); + afterEach(() => { + renderer.cleanup(); + container.remove(); + manager.destroy(); + }); + test.each([ + ['month', MonthViewRenderer], + ['week', WeekViewRenderer], + ['day', DayViewRenderer] + ])('%s prevents pointer mutation while keeping selection available', (view, Renderer) => { + const update = mount(Renderer, view); + const before = manager.getEvents()[0].toObject(); + const chip = container.querySelector(`.fc-event[data-event-id="${occurrenceId(15)}"]`); expect(chip).not.toBeNull(); - const startX = chip.closest('.fc-week-day-column').getBoundingClientRect().left + 10; - - expect(() => { - chip.dispatchEvent(pointer('pointerdown', startX, 600)); - document.dispatchEvent(pointer('pointermove', startX, 600 + 34)); // ~30 min snap - document.dispatchEvent(pointer('pointerup', startX, 600 + 34)); - }).not.toThrow(); - - const master = manager.getEvents()[0]; - expect(new Date(master.start)).toEqual(new Date(2026, 6, 1, 10, 30)); - expect(new Date(master.end)).toEqual(new Date(2026, 6, 1, 11, 30)); - expect(updateSpy).toHaveBeenCalledTimes(1); - expect(manager.findEvent(updateSpy.mock.calls[0][0])).toBe(master); + expect(chip.querySelector('.fc-resize-handle')).toBeNull(); + chip.dispatchEvent(pointer('pointerdown', 10, 600)); + document.dispatchEvent(pointer('pointermove', 130, 690)); + document.dispatchEvent(pointer('pointerup', 130, 690)); + expect(renderer._dragController._active).toBeNull(); + expect(renderer._dragController._docListeners).toHaveLength(0); + expect(chip.classList.contains('fc-dragging')).toBe(false); + expect(update).not.toHaveBeenCalled(); + expect(manager.getEvents()[0].toObject()).toEqual(before); + chip.click(); + expect(manager.getState().selectedEvent.id).toBe('standup'); }); - - test('in the time grid resizing changes the duration of the series', () => { - const updateSpy = mountWeek(); - const handle = container.querySelector(`${chipSelector} .fc-resize-handle`); - expect(handle).not.toBeNull(); - - expect(() => { - handle.dispatchEvent(pointer('pointerdown', 10, 660)); - document.dispatchEvent(pointer('pointermove', 10, 660 + 29)); // ~30 min snap - document.dispatchEvent(pointer('pointerup', 10, 660 + 29)); - }).not.toThrow(); - + test('commit methods reject a recurring master even with stale interaction state', () => { + const update = mount(WeekViewRenderer, 'week'); + const controller = renderer._dragController; const master = manager.getEvents()[0]; - expect(new Date(master.start)).toEqual(new Date(2026, 6, 1, 10, 0)); - expect(new Date(master.end)).toEqual(new Date(2026, 6, 1, 11, 30)); - expect(updateSpy).toHaveBeenCalledTimes(1); - expect(manager.findEvent(updateSpy.mock.calls[0][0])).toBe(master); + const column = container.querySelector('.fc-week-day-column'); + controller._active = { + eventId: occurrenceId(15), + dropCell: { dataset: { date: '2026-07-18' } } + }; + controller._monthDrop(); + controller._active = { eventId: occurrenceId(15), deltaMinutes: 30, dropColumn: column }; + controller._timeMoveDrop(); + controller._active = { eventId: occurrenceId(15), newHeight: 90, originHeight: 60 }; + controller._resizeDrop(); + controller._shiftEvent(master, 60000); + expect(update).not.toHaveBeenCalled(); + controller._active = null; }); + test.each(['pointermove', 'pointerup'])( + 'rechecks recurrence at %s and cancels an armed resize', + type => { + const update = mount(WeekViewRenderer, 'week'); + const event = manager.addEvent({ + id: 'single', + title: 'Single', + start: new Date(2026, 6, 15, 12), + end: new Date(2026, 6, 15, 13) + }); + renderer.render(); + update.mockClear(); + const chip = container.querySelector('.fc-event[data-event-id="single"]'); + const handle = chip.querySelector('.fc-resize-handle'); + expect(handle).not.toBeNull(); + const height = chip.style.height; + handle.dispatchEvent(pointer('pointerdown', 10, 780)); + document.dispatchEvent(pointer('pointermove', 10, 810)); + event.recurring = true; // Host mutates state while the pointer gesture is active. + document.dispatchEvent(pointer(type, 10, 830)); + expect(renderer._dragController._active).toBeNull(); + expect(chip.style.height).toBe(height); + expect(update).not.toHaveBeenCalled(); + } + ); + test.each(['recurring', 'recurrenceRule', 'isOccurrence', 'recurringEventId'])( + 'shared guard recognizes %s', + flag => { + const update = mount(WeekViewRenderer, 'week'); + const event = { + id: 'single', + [flag]: flag === 'recurring' || flag === 'isOccurrence' ? true : 'series' + }; + renderer._dragController._shiftEvent(event, 60000); + expect(update).not.toHaveBeenCalled(); + } + ); }); From f15161d3477e77d8c20413e887af813f0ad4af53 Mon Sep 17 00:00:00 2001 From: thedhanawada <13751641+thedhanawada@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:43:50 +1000 Subject: [PATCH 3/5] feat: [minor] allow hosts to hide unsupported editor color controls --- README.md | 9 +++ src/components/EventForm.js | 30 ++++++++- src/components/ForceCalendar.js | 36 ++++++++++- src/types/dom.d.ts | 3 + tests/types/consumer.ts | 5 ++ tests/unit/ColorPickerOption.test.js | 96 ++++++++++++++++++++++++++++ 6 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 tests/unit/ColorPickerOption.test.js diff --git a/README.md b/README.md index 0b5e93f..001b635 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,15 @@ Click an event, or focus it and press Enter/Space, to open its details: title, t The standalone `EventForm` also supports `edit(event)` and `open(start, end)` for prefilled editing and creation ranges. Its cancellable `save` event carries editable fields; a host can call `preventDefault()` and `showError(message)` to retain a failed draft. Its `close` event reports `{ restoreFocus }`. The main calendar wires these to its state manager automatically. +## Optional editor color control + +The color picker remains visible by default. Hosts that cannot persist colors can use +`` or `calendar.showColorPicker = false`. +The explicit attribute value `false` hides it; `true`, an empty attribute, or removing +the attribute shows it. Changing this option while editing keeps the unsaved draft. +Hidden controls do not reset existing event colors. Standalone `EventForm` supports +the same attribute and property. + ## 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`. diff --git a/src/components/EventForm.js b/src/components/EventForm.js index aa87a88..279a83d 100644 --- a/src/components/EventForm.js +++ b/src/components/EventForm.js @@ -42,10 +42,32 @@ export class EventForm extends BaseComponent { } static get observedAttributes() { - return ['open']; + return ['open', 'show-color-picker']; + } + + get showColorPicker() { + return this.getAttribute('show-color-picker')?.trim().toLowerCase() !== 'false'; + } + + /** @param {boolean} value */ + set showColorPicker(value) { + this.setAttribute('show-color-picker', String(Boolean(value))); + } + + _updateColorPickerVisibility() { + const group = this.$('#color-group'); + if (!group) return; + group.hidden = !this.showColorPicker; + group.querySelectorAll('button').forEach(button => { + button.disabled = !this.showColorPicker; + }); } attributeChangedCallback(name, oldValue, newValue) { + if (name === 'show-color-picker') { + this._updateColorPickerVisibility(); + return; + } if (name === 'open' && oldValue !== newValue && !this._reflectingOpen) { if (newValue !== null) { this.open(); @@ -133,6 +155,8 @@ export class EventForm extends BaseComponent { gap: var(--fc-spacing-md); } + .form-group[hidden] { display: none; } + .form-group { min-width: 0; display: flex; @@ -280,7 +304,7 @@ export class EventForm extends BaseComponent { -
+
${this.config.colors @@ -318,6 +342,7 @@ export class EventForm extends BaseComponent { this.startInput = this.$('#event-start'); this.endInput = this.$('#event-end'); this.colorContainer = this.$('#color-picker'); + this._updateColorPickerVisibility(); this.titleGroup = this.$('#title-group'); this.startGroup = this.$('#start-group'); @@ -330,6 +355,7 @@ export class EventForm extends BaseComponent { this.colorContainer.querySelectorAll('.color-btn').forEach(btn => { this.addListener(btn, 'click', e => { + if (!this.showColorPicker) return; this._formData.color = e.currentTarget.dataset.color; this._colorChanged = true; this.updateColorSelection(); diff --git a/src/components/ForceCalendar.js b/src/components/ForceCalendar.js index fef3d94..710afd9 100644 --- a/src/components/ForceCalendar.js +++ b/src/components/ForceCalendar.js @@ -27,7 +27,28 @@ export class ForceCalendar extends BaseComponent { }; static get observedAttributes() { - return ['view', 'date', 'locale', 'timezone', 'week-starts-on', 'height', 'theme', 'readonly']; + return [ + 'view', + 'date', + 'locale', + 'timezone', + 'week-starts-on', + 'height', + 'theme', + 'readonly', + 'show-color-picker' + ]; + } + + /** Update this presentational option without replacing an open editor. */ + attributeChangedCallback(name, oldValue, newValue) { + if (name === 'show-color-picker') { + this.setProp(name, newValue); + const form = this.$('#event-modal'); + if (form) form.showColorPicker = this.showColorPicker; + return; + } + super.attributeChangedCallback(name, oldValue, newValue); } /** @@ -120,6 +141,7 @@ export class ForceCalendar extends BaseComponent { initialize() { // Frameworks may set the property before customElements.define(). this._upgradeProperty('readOnly'); + this._upgradeProperty('showColorPicker'); // Initialize state manager with config from attributes const config = { @@ -990,7 +1012,7 @@ export class ForceCalendar extends BaseComponent {
- +
`; } @@ -1280,6 +1302,16 @@ export class ForceCalendar extends BaseComponent { * present value is true. This is a UI option, not an authorization boundary. * @returns {boolean} */ + /** Whether the built-in event editor offers color selection. Defaults to true. */ + get showColorPicker() { + return this.getAttribute('show-color-picker')?.trim().toLowerCase() !== 'false'; + } + + /** @param {boolean} value */ + set showColorPicker(value) { + this.setAttribute('show-color-picker', String(Boolean(value))); + } + get readOnly() { return this.hasAttribute('readonly'); } diff --git a/src/types/dom.d.ts b/src/types/dom.d.ts index 0d7736d..eb7dc35 100644 --- a/src/types/dom.d.ts +++ b/src/types/dom.d.ts @@ -97,6 +97,9 @@ export interface ForceCalendarElement extends HTMLElement { /** Disable built-in user edits. Reflected boolean `readonly` attribute; host APIs remain writable. */ readOnly: boolean; + /** Show the editor color picker (default true); hides controls without resetting saved colors. */ + showColorPicker: 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 ef47d6a..81fd163 100644 --- a/tests/types/consumer.ts +++ b/tests/types/consumer.ts @@ -24,6 +24,11 @@ new BaseViewRenderer(document.createElement('div'), new StateManager()); void base; void dates; +element.showColorPicker = false; +const showColorPicker: boolean = element.showColorPicker; +const classShowColorPicker: boolean = new ForceCalendar().showColorPicker; +void showColorPicker; +void classShowColorPicker; element.readOnly = true; const readOnly: boolean = element.readOnly; const classReadOnly: boolean = new ForceCalendar().readOnly; diff --git a/tests/unit/ColorPickerOption.test.js b/tests/unit/ColorPickerOption.test.js new file mode 100644 index 0000000..d61b247 --- /dev/null +++ b/tests/unit/ColorPickerOption.test.js @@ -0,0 +1,96 @@ +import '../../src/components/ForceCalendar.js'; + +const event = backgroundColor => ({ + id: 'event', + title: 'Original', + backgroundColor, + start: new Date(2026, 6, 15, 10), + end: new Date(2026, 6, 15, 11) +}); +const click = node => + node.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); +let calendar; +const q = selector => calendar.shadowRoot.querySelector(selector); +beforeEach(() => { + jest.useFakeTimers(); + calendar = document.createElement('forcecal-main'); + calendar.setAttribute('date', '2026-07-15T12:00:00'); +}); +afterEach(() => { + calendar.destroy(); + calendar.remove(); + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +test.each([ + [null, true], + ['true', true], + ['false', false], + ['', true], + [' FALSE ', false] +])('parses show-color-picker=%s and forwards it', (value, expected) => { + if (value !== null) calendar.setAttribute('show-color-picker', value); + document.body.appendChild(calendar); + const form = q('#event-modal'); + expect(calendar.showColorPicker).toBe(expected); + expect(form.showColorPicker).toBe(expected); + expect(form.$('#color-group').hidden).toBe(!expected); + expect([...form.$$('.color-btn')].every(button => button.disabled === !expected)).toBe(true); +}); +test.each(['#123456', null])('hidden picker preserves original color %s through edits', color => { + calendar.showColorPicker = false; + calendar.setEvents([event(color)]); + document.body.appendChild(calendar); + click(q('.fc-event')); + click(q('#fc-details-edit')); + const form = q('#event-modal'); + form.titleInput.value = 'Edited'; + click(form.$('.color-btn')); // Even stale/programmatic interaction must not change hidden controls. + click(form.$('#save-btn')); + expect(calendar.getEvents()[0].title).toBe('Edited'); + expect(calendar.getEvents()[0].backgroundColor).toBe(color); +}); +test('observed attribute and property toggle without discarding an active draft', () => { + document.body.appendChild(calendar); + click(q('#create-event-btn')); + const form = q('#event-modal'); + form.titleInput.value = 'Unsaved'; + calendar.setAttribute('show-color-picker', 'false'); + expect(q('#event-modal')).toBe(form); + expect(form.hasAttribute('open')).toBe(true); + expect(form.titleInput.value).toBe('Unsaved'); + expect(form.$('#color-group').hidden).toBe(true); + calendar.showColorPicker = true; + expect(form.titleInput.value).toBe('Unsaved'); + expect(form.$('#color-group').hidden).toBe(false); + calendar.showColorPicker = false; + calendar.removeAttribute('show-color-picker'); + expect(form.showColorPicker).toBe(true); + expect(form.$('#color-group').hidden).toBe(false); +}); +test('upgrades an own property assigned before connection', () => { + Object.defineProperty(calendar, 'showColorPicker', { + value: false, + configurable: true, + writable: true + }); + document.body.appendChild(calendar); + expect(calendar.getAttribute('show-color-picker')).toBe('false'); + expect(q('#event-modal').showColorPicker).toBe(false); +}); +test('standalone EventForm supports the same option before and after attachment', () => { + const form = document.createElement('forcecal-event-form'); + form.showColorPicker = false; + document.body.appendChild(form); + try { + expect(form.$('#color-group').hidden).toBe(true); + form.open(new Date()); + form.titleInput.value = 'Draft'; + form.setAttribute('show-color-picker', 'true'); + expect(form.$('#color-group').hidden).toBe(false); + expect(form.titleInput.value).toBe('Draft'); + } finally { + form.remove(); + } +}); From cd2c52be0c835e35c032d882dec8f390a2ace5da Mon Sep 17 00:00:00 2001 From: thedhanawada <13751641+thedhanawada@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:49:10 +1000 Subject: [PATCH 4/5] feat: [minor] protect native recurring instances in safe editing flows --- README.md | 8 +++ src/components/EventForm.js | 5 ++ src/utils/EventUtils.js | 6 +- tests/unit/NativeRecurrence.test.js | 88 +++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 tests/unit/NativeRecurrence.test.js diff --git a/README.md b/README.md index 001b635..b1e3261 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,14 @@ Rules of the road: Rendered chips of a recurring series carry occurrence ids (`_`). Clicking or selecting an occurrence resolves to its master (`stateManager.findEvent(id)`), while details retain the clicked occurrence's time. Built-in drag, resize, Edit and Delete are unavailable for recurring events: changing one occurrence or a whole series requires an explicit scope workflow, which is not supported yet. Host APIs remain available for applications that implement their own scoped editing. +For instances already expanded by an external service (for example Salesforce), +keep `recurring`/`recurrenceRule` unset and add +`metadata: { forceCalendarRecurring: true }`. This explicit host marker disables +built-in Edit/Delete/drag/resize while rendering the ordinary supplied event once; +it does not generate occurrences or invent an RRULE. Hosts remain responsible for +server-side recurrence mutation permissions. The standalone editor also declines +recurring events; imperative host CRUD APIs retain their documented behavior. + ## Event details and editing Click an event, or focus it and press Enter/Space, to open its details: title, the displayed instance's date/time, location and available actions. Times use the browser's local display, as the calendar grid does. The calendar-owned dialog uses ordinary DOM nodes inside the calendar's shadow root, without a portal or the native Popover API. diff --git a/src/components/EventForm.js b/src/components/EventForm.js index 279a83d..e292fb4 100644 --- a/src/components/EventForm.js +++ b/src/components/EventForm.js @@ -1,6 +1,7 @@ import { BaseComponent } from '../core/BaseComponent.js'; import { StyleUtils } from '../utils/StyleUtils.js'; import { DOMUtils } from '../utils/DOMUtils.js'; +import { isRecurringEvent } from '../utils/EventUtils.js'; /** * @typedef {Object} EditableEvent @@ -411,6 +412,10 @@ export class EventForm extends BaseComponent { * @param {EditableEvent} event */ edit(event) { + if (isRecurringEvent(event)) { + this.close(); + return; + } this.editingEventId = event.id; this._show(event); } diff --git a/src/utils/EventUtils.js b/src/utils/EventUtils.js index 1d7e66b..58cec4f 100644 --- a/src/utils/EventUtils.js +++ b/src/utils/EventUtils.js @@ -2,6 +2,10 @@ export function isRecurringEvent(event) { return Boolean( event && - (event.recurring || event.recurrenceRule || event.isOccurrence || event.recurringEventId) + (event.recurring || + event.recurrenceRule || + event.isOccurrence || + event.recurringEventId || + event.metadata?.forceCalendarRecurring === true) ); } diff --git a/tests/unit/NativeRecurrence.test.js b/tests/unit/NativeRecurrence.test.js new file mode 100644 index 0000000..a006ec7 --- /dev/null +++ b/tests/unit/NativeRecurrence.test.js @@ -0,0 +1,88 @@ +import '../../src/components/ForceCalendar.js'; + +const seed = { + id: 'native-instance', + title: 'Native recurring instance', + start: new Date(2026, 6, 15, 10), + end: new Date(2026, 6, 15, 11), + metadata: { forceCalendarRecurring: true } +}; +const click = node => + node.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); +const pointer = (type, y = 600) => + new MouseEvent(type, { bubbles: true, composed: true, button: 0, clientX: 10, clientY: y }); +let calendar; +const q = s => calendar.shadowRoot.querySelector(s); +beforeEach(() => { + jest.useFakeTimers(); + calendar = document.createElement('forcecal-main'); + calendar.setAttribute('date', '2026-07-15T12:00:00'); +}); +afterEach(() => { + calendar.destroy(); + calendar.remove(); + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +test.each(['month', 'week', 'day'])( + '%s displays externally expanded recurrence exactly once without mutation controls', + view => { + calendar.setAttribute('view', view); + calendar.setEvents([seed]); + document.body.appendChild(calendar); + expect(calendar.getEvents()[0].recurrenceRule).toBeNull(); + expect(calendar.getEvents()[0].recurring).toBe(false); + const chips = calendar.shadowRoot.querySelectorAll('.fc-event'); + expect(chips).toHaveLength(1); + const chip = chips[0]; + expect(chip.dataset.eventId).toBe(seed.id); + expect(chip.querySelector('.fc-resize-handle')).toBeNull(); + const updated = jest.fn(); + calendar.addEventListener('calendar-event-update', updated); + chip.dispatchEvent(pointer('pointerdown')); + document.dispatchEvent(pointer('pointermove', 690)); + document.dispatchEvent(pointer('pointerup', 690)); + expect(updated).not.toHaveBeenCalled(); + expect(calendar.getEvents()[0].start).toEqual(seed.start); + click(chip); + expect(q('#fc-details-title').textContent).toBe(seed.title); + expect(q('#fc-details-edit')).toBeNull(); + expect(q('#fc-details-delete')).toBeNull(); + expect(q('[role="dialog"]').textContent).toContain('Recurring event'); + } +); +test('direct standalone editor entry declines native recurring instances', () => { + const form = document.createElement('forcecal-event-form'); + document.body.appendChild(form); + try { + const saved = jest.fn(); + form.addEventListener('save', saved); + form.edit(seed); + form.save(); + expect(form.hasAttribute('open')).toBe(false); + expect(saved).not.toHaveBeenCalled(); + } finally { + form.remove(); + } +}); +test('host marking an open edit recurring prevents stale UI save', () => { + calendar.setEvents([{ ...seed, metadata: {} }]); + document.body.appendChild(calendar); + click(q('.fc-event')); + click(q('#fc-details-edit')); + const form = q('#event-modal'); + form.titleInput.value = 'Unsafe'; + calendar.setEvents([seed]); + const updated = jest.fn(); + calendar.addEventListener('calendar-event-update', updated); + form.save(); + expect(updated).not.toHaveBeenCalled(); + expect(calendar.getEvents()[0].title).toBe(seed.title); +}); +test('only boolean true enables the external recurrence marker', () => { + calendar.setEvents([{ ...seed, metadata: { forceCalendarRecurring: false } }]); + document.body.appendChild(calendar); + click(q('.fc-event')); + expect(q('#fc-details-edit')).not.toBeNull(); +}); From 3dbfee020791ea82a457381cf3b0dc415c88ff55 Mon Sep 17 00:00:00 2001 From: thedhanawada <13751641+thedhanawada@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:38:15 -0700 Subject: [PATCH 5/5] feat: [minor] use explicit civil dates for safe all-day editing --- README.md | 10 +++ src/components/EventForm.js | 117 ++++++++++++++++++++++++----- tests/unit/AllDayEditor.test.js | 127 ++++++++++++++++++++++++++++++++ tests/unit/EventDetails.test.js | 2 +- 4 files changed, 235 insertions(+), 21 deletions(-) create mode 100644 tests/unit/AllDayEditor.test.js diff --git a/README.md b/README.md index b1e3261..55005d7 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,16 @@ Click an event, or focus it and press Enter/Space, to open its details: title, t The standalone `EventForm` also supports `edit(event)` and `open(start, end)` for prefilled editing and creation ranges. Its cancellable `save` event carries editable fields; a host can call `preventDefault()` and `showError(message)` to retain a failed draft. Its `close` event reports `{ restoreFocus }`. The main calendar wires these to its state manager automatically. +## All-day date boundaries + +All-day editing uses date-only **Start date** and **Last day (inclusive)** fields. +A same-day event uses the same date in both fields. This preserves Core's existing +contract: local start-of-day through local 23:59:59.999 on the last included day. +Dates are parsed as local civil dates, not UTC strings; DST days may span 23 or 25 +hours. Timed events retain exact instants, including unchanged DST-overlap times. +Adapters for systems with an exclusive end date must convert that boundary using +calendar-day arithmetic rather than a fixed 24-hour duration. + ## Optional editor color control The color picker remains visible by default. Hosts that cannot persist colors can use diff --git a/src/components/EventForm.js b/src/components/EventForm.js index e292fb4..9a877ed 100644 --- a/src/components/EventForm.js +++ b/src/components/EventForm.js @@ -353,6 +353,7 @@ export class EventForm extends BaseComponent { this.addListener(this.$('#close-x'), 'click', () => this.close()); this.addListener(this.$('#cancel-btn'), 'click', () => this.close()); this.addListener(this.$('#save-btn'), 'click', () => this.save()); + this.addListener(this.allDayInput, 'change', () => this._syncDateInputMode()); this.colorContainer.querySelectorAll('.color-btn').forEach(btn => { this.addListener(btn, 'click', e => { @@ -442,8 +443,17 @@ export class EventForm extends BaseComponent { this.titleInput.value = this._formData.title; this.locationInput.value = this._formData.location; this.allDayInput.checked = this._formData.allDay; - this.startInput.value = this.formatDateForInput(this._formData.start); - this.endInput.value = this.formatDateForInput(this._formData.end); + this._timedDraft = null; + this.startInput.type = this.endInput.type = this._formData.allDay ? 'date' : 'datetime-local'; + this.startInput.value = this.formatDateForInput(this._formData.start).slice( + 0, + this._formData.allDay ? 10 : undefined + ); + this.endInput.value = this.formatDateForInput(this._formData.end).slice( + 0, + this._formData.allDay ? 10 : undefined + ); + this._updateDateLabels(); this._initialStartInput = this.startInput.value; this._initialEndInput = this.endInput.value; this._resetErrors(); @@ -488,16 +498,92 @@ export class EventForm extends BaseComponent { error.hidden = false; } + _updateDateLabels() { + this.$('label[for="event-start"]').textContent = this.allDayInput.checked + ? 'Start date' + : 'Start'; + this.$('label[for="event-end"]').textContent = this.allDayInput.checked + ? 'Last day (inclusive)' + : 'End'; + this.$('#end-error').textContent = this.allDayInput.checked + ? 'Choose a last day on or after the start date' + : 'Enter a valid end time after the start time'; + } + + _syncDateInputMode() { + const allDay = this.allDayInput.checked; + const startValue = this.startInput.value; + const endValue = this.endInput.value; + if (allDay && this.startInput.type !== 'date') { + this._timedDraft = { start: startValue, end: endValue }; + this.startInput.type = this.endInput.type = 'date'; + this.startInput.value = startValue.slice(0, 10); + this.endInput.value = endValue.slice(0, 10); + } else if (!allDay && this.startInput.type === 'date') { + this.startInput.type = this.endInput.type = 'datetime-local'; + // Restore the previous timed draft when toggling back. For an existing + // all-day event, default to sensible local working hours instead. + this.startInput.value = startValue + ? `${startValue}T${this._timedDraft?.start.slice(11) || '09:00'}` + : ''; + this.endInput.value = endValue + ? `${endValue}T${this._timedDraft?.end.slice(11) || '10:00'}` + : ''; + if ( + this.startInput.value && + this.endInput.value && + new Date(this.endInput.value) <= new Date(this.startInput.value) + ) { + this.endInput.value = this.formatDateForInput( + new Date(new Date(this.startInput.value).getTime() + this.config.defaultDuration * 60000) + ); + } + } + this._updateDateLabels(); + } + + _readInputDates() { + const allDay = this.allDayInput.checked; + const read = (input, field, initial) => { + // Unchanged timed inputs preserve sub-minute precision and DST-fold + // identity. All-day values are civil dates with inclusive end dates. + if ( + !allDay && + this.editingEventId !== null && + allDay === this._formData.allDay && + input.value === initial + ) { + return new Date(this._formData[field]); + } + if (!allDay) return new Date(input.value); + const match = /^(\d{4,})-(\d{2})-(\d{2})$/.exec(input.value); + if (!match) return new Date(NaN); + const date = new Date(0); + date.setFullYear(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + date.setHours( + field === 'end' ? 23 : 0, + field === 'end' ? 59 : 0, + field === 'end' ? 59 : 0, + field === 'end' ? 999 : 0 + ); + if ( + date.getFullYear() !== Number(match[1]) || + date.getMonth() !== Number(match[2]) - 1 || + date.getDate() !== Number(match[3]) + ) + return new Date(NaN); + return date; + }; + return { + start: read(this.startInput, 'start', this._initialStartInput), + end: read(this.endInput, 'end', this._initialEndInput) + }; + } + validate() { + this._syncDateInputMode(); this._resetErrors(); - const start = - this.editingEventId !== null && this.startInput.value === this._initialStartInput - ? new Date(this._formData.start) - : new Date(this.startInput.value); - const end = - this.editingEventId !== null && this.endInput.value === this._initialEndInput - ? new Date(this._formData.end) - : new Date(this.endInput.value); + const { start, end } = this._readInputDates(); let firstInvalid = null; const checks = [ [this.titleGroup, this.titleInput, !this.titleInput.value.trim()], @@ -521,16 +607,7 @@ export class EventForm extends BaseComponent { title: this.titleInput.value.trim(), location: this.locationInput.value.trim(), allDay: this.allDayInput.checked, - // Minute inputs cannot represent seconds, milliseconds or the second - // instance of a repeated DST hour. Untouched inputs retain exact instants. - start: - this.editingEventId !== null && this.startInput.value === this._initialStartInput - ? new Date(this._formData.start) - : new Date(this.startInput.value), - end: - this.editingEventId !== null && this.endInput.value === this._initialEndInput - ? new Date(this._formData.end) - : new Date(this.endInput.value), + ...this._readInputDates(), backgroundColor: this.editingEventId !== null && !this._colorChanged ? this._originalBackgroundColor diff --git a/tests/unit/AllDayEditor.test.js b/tests/unit/AllDayEditor.test.js new file mode 100644 index 0000000..eac86bf --- /dev/null +++ b/tests/unit/AllDayEditor.test.js @@ -0,0 +1,127 @@ +import '../../src/components/ForceCalendar.js'; +const click = node => + node.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); +let calendar; +const q = s => calendar.shadowRoot.querySelector(s); +const form = () => q('#event-modal'); +const toggle = checked => { + form().allDayInput.checked = checked; + form().allDayInput.dispatchEvent(new Event('change', { bubbles: true })); +}; +beforeEach(() => { + jest.useFakeTimers(); + calendar = document.createElement('forcecal-main'); + calendar.setAttribute('date', '2026-10-15T12:00:00'); + document.body.appendChild(calendar); +}); +afterEach(() => { + calendar.destroy(); + calendar.remove(); + jest.clearAllTimers(); + jest.useRealTimers(); +}); +const create = (start, end) => { + click(q('#create-event-btn')); + form().titleInput.value = 'All-day'; + toggle(true); + form().startInput.value = start; + form().endInput.value = end; + click(form().$('#save-btn')); + return calendar.getEvents()[0]; +}; +const localDate = date => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + +test('uses clearly inclusive date-only fields and allows one-day creation', () => { + const event = create('2026-10-05', '2026-10-05'); + expect(form().startInput.type).toBe('date'); + expect(form().endInput.type).toBe('date'); + expect(form().$('label[for="event-start"]').textContent).toBe('Start date'); + expect(form().$('label[for="event-end"]').textContent).toBe('Last day (inclusive)'); + expect(localDate(event.start)).toBe('2026-10-05'); + expect(localDate(event.end)).toBe('2026-10-05'); + expect(event.start.getHours()).toBe(0); + expect(event.end.getHours()).toBe(23); + expect(event.end.getMilliseconds()).toBe(999); + expect(calendar.shadowRoot.querySelectorAll('.fc-event')).toHaveLength(1); +}); +test('inclusive multi-day creation and title-only roundtrip retain exactly three civil days', () => { + const event = create('2026-10-05', '2026-10-07'); + expect(calendar.shadowRoot.querySelectorAll('.fc-event')).toHaveLength(3); + click(q('.fc-event')); + click(q('#fc-details-edit')); + expect(form().startInput.value).toBe('2026-10-05'); + expect(form().endInput.value).toBe('2026-10-07'); + form().titleInput.value = 'Updated'; + click(form().$('#save-btn')); + const updated = calendar.getEvents()[0]; + expect(updated.start.getTime()).toBe(event.start.getTime()); + expect(updated.end.getTime()).toBe(event.end.getTime()); + calendar.setEvents([updated.toObject()]); + expect(calendar.shadowRoot.querySelectorAll('.fc-event')).toHaveLength(3); +}); +test('rejects a last day before the start, but does not reject same day', () => { + create('2026-10-07', '2026-10-05'); + expect(calendar.getEvents()).toHaveLength(0); + expect(form().hasAttribute('open')).toBe(true); + form().endInput.value = '2026-10-07'; + click(form().$('#save-btn')); + expect(calendar.getEvents()).toHaveLength(1); +}); +test.each(['2026-03-08', '2026-11-01', '2026-04-05', '2026-09-27'])( + 'DST civil day %s uses local boundaries, never fixed duration', + day => { + const event = create(day, day); + expect(localDate(event.start)).toBe(day); + expect(localDate(event.end)).toBe(day); + const following = new Date(event.start); + following.setDate(following.getDate() + 1); + expect(event.end.getTime() + 1).toBe(following.getTime()); + } +); +test('timed/all-day/timed toggle restores original precise instants including DST folds', () => { + const start = new Date('2026-11-01T06:30:12.345Z'), + end = new Date('2026-11-01T07:00:45.678Z'); + calendar.setDate(new Date(2026, 10, 1, 12)); + calendar.setEvents([{ id: 'timed', title: 'Timed', start, end }]); + click(q('.fc-event')); + click(q('#fc-details-edit')); + const startText = form().startInput.value, + endText = form().endInput.value; + toggle(true); + expect(form().startInput.type).toBe('date'); + toggle(false); + expect(form().startInput.value).toBe(startText); + expect(form().endInput.value).toBe(endText); + form().titleInput.value = 'Retitled'; + click(form().$('#save-btn')); + expect(calendar.getEvents()[0].start.getTime()).toBe(start.getTime()); + expect(calendar.getEvents()[0].end.getTime()).toBe(end.getTime()); +}); +test('existing all-day toggles to a valid local timed interval and back', () => { + create('2026-10-05', '2026-10-05'); + click(q('.fc-event')); + click(q('#fc-details-edit')); + toggle(false); + expect(form().startInput.value).toBe('2026-10-05T09:00'); + expect(form().endInput.value).toBe('2026-10-05T10:00'); + toggle(true); + expect(form().startInput.value).toBe('2026-10-05'); + expect(form().endInput.value).toBe('2026-10-05'); + click(form().$('#save-btn')); + expect(calendar.getEvents()).toHaveLength(1); + expect(calendar.getEvents()[0].allDay).toBe(true); +}); + +test('standalone same-day all-day input is normalized to inclusive local boundaries', () => { + const editor = form(); + const date = new Date(2026, 9, 5); + editor.edit({ id: 'single-day', title: 'Single day', start: date, end: date, allDay: true }); + const saved = jest.fn(); + editor.addEventListener('save', saved); + editor.save(); + expect(saved).toHaveBeenCalledTimes(1); + expect(localDate(saved.mock.calls[0][0].detail.start)).toBe('2026-10-05'); + expect(localDate(saved.mock.calls[0][0].detail.end)).toBe('2026-10-05'); + expect(saved.mock.calls[0][0].detail.end.getHours()).toBe(23); +}); diff --git a/tests/unit/EventDetails.test.js b/tests/unit/EventDetails.test.js index 3849bb0..d7dd07a 100644 --- a/tests/unit/EventDetails.test.js +++ b/tests/unit/EventDetails.test.js @@ -238,7 +238,7 @@ describe('built-in event details and editing', () => { end: new Date('2026-11-01T06:15:00Z') }; const calendar = create('month', []); - calendar.setDate(new Date(2026, 10, 1, 12)); + calendar.setDate(new Date(input.start)); calendar.events = [input]; const editor = edit(calendar); editor.titleInput.value = 'DST event';