diff --git a/README.md b/README.md index f9e9f56..55005d7 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,46 @@ 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. + +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. + +- **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. + +## 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 +`` 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 @@ -72,9 +111,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..08c0248 --- /dev/null +++ b/src/components/EventDetails.js @@ -0,0 +1,171 @@ +import { DOMUtils } from '../utils/DOMUtils.js'; +import { isRecurringEvent } from '../utils/EventUtils.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 isRecurringEvent(event); + } + + _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..9a877ed 100644 --- a/src/components/EventForm.js +++ b/src/components/EventForm.js @@ -1,11 +1,25 @@ 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 + * @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', @@ -29,11 +43,33 @@ 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 === 'open') { + if (name === 'show-color-picker') { + this._updateColorPickerVisibility(); + return; + } + if (name === 'open' && oldValue !== newValue && !this._reflectingOpen) { if (newValue !== null) { this.open(); } else { @@ -120,6 +156,8 @@ export class EventForm extends BaseComponent { gap: var(--fc-spacing-md); } + .form-group[hidden] { display: none; } + .form-group { min-width: 0; display: flex; @@ -231,7 +269,7 @@ export class EventForm extends BaseComponent { return `