Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,46 @@ Rules of the road:

### Recurring events

Rendered chips of a recurring series carry occurrence ids (`<masterId>_<startMs>`). 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 (`<masterId>_<startMs>`). 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
`<forcecal-main show-color-picker="false">` 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

Expand All @@ -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.
Expand Down
171 changes: 171 additions & 0 deletions src/components/EventDetails.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading