@zeyos/zx 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/DESIGN-SYSTEM.md +33 -3
  3. package/README.md +17 -5
  4. package/dist/types/components/app-icon/app-icon.d.ts +8 -1
  5. package/dist/types/components/aurora/aurora.d.ts +72 -0
  6. package/dist/types/components/calendar/calendar-model.d.ts +202 -0
  7. package/dist/types/components/calendar/calendar.d.ts +500 -0
  8. package/dist/types/components/card-view/card-view.d.ts +303 -0
  9. package/dist/types/components/card-view/record-card.d.ts +242 -0
  10. package/dist/types/components/kanban-view/kanban-view.d.ts +572 -0
  11. package/dist/types/components/table/table.d.ts +120 -31
  12. package/dist/types/components/table-view/table-view.d.ts +192 -0
  13. package/dist/types/components/view/record-view.d.ts +464 -0
  14. package/dist/types/index.d.ts +7 -0
  15. package/dist/types/zeyos/calendar.d.ts +91 -0
  16. package/dist/types/zeyos/icons.d.ts +19 -3
  17. package/dist/types/zeyos/index.d.ts +4 -0
  18. package/dist/types/zeyos/legacy-saved-views.d.ts +66 -0
  19. package/dist/types/zeyos/saved-views.d.ts +331 -0
  20. package/dist/types/zeyos/view.d.ts +305 -0
  21. package/dist/zx-zeyos.esm.js +4627 -1460
  22. package/dist/zx-zeyos.esm.min.js +1 -1
  23. package/dist/zx.css +2492 -410
  24. package/dist/zx.esm.js +4891 -115
  25. package/dist/zx.esm.min.js +3 -3
  26. package/dist/zx.global.js +4891 -115
  27. package/dist/zx.global.min.js +3 -3
  28. package/dist/zx.min.css +1 -1
  29. package/docs/api.json +1702 -191
  30. package/docs/llms.md +207 -20
  31. package/docs/llms.txt +16 -6
  32. package/package.json +1 -1
  33. package/src/components/app-icon/app-icon.css +140 -30
  34. package/src/components/app-icon/app-icon.js +32 -5
  35. package/src/components/app-rail/app-rail.css +11 -2
  36. package/src/components/app-sidebar/app-sidebar.css +11 -2
  37. package/src/components/aurora/aurora.css +143 -0
  38. package/src/components/aurora/aurora.js +146 -0
  39. package/src/components/avatar/avatar.css +1 -1
  40. package/src/components/button/button.css +34 -2
  41. package/src/components/calendar/calendar-model.js +402 -0
  42. package/src/components/calendar/calendar.css +567 -0
  43. package/src/components/calendar/calendar.js +1524 -0
  44. package/src/components/card/card.css +25 -3
  45. package/src/components/card-view/card-view.css +389 -0
  46. package/src/components/card-view/card-view.js +527 -0
  47. package/src/components/card-view/record-card.js +392 -0
  48. package/src/components/context-menu/context-menu.css +2 -2
  49. package/src/components/date-picker/date-picker.css +15 -3
  50. package/src/components/datebox/date-range-box.css +15 -2
  51. package/src/components/datebox/datebox.css +16 -3
  52. package/src/components/dropdown/dropdown.css +15 -3
  53. package/src/components/kanban-view/kanban-view.css +216 -0
  54. package/src/components/kanban-view/kanban-view.js +1228 -0
  55. package/src/components/launcher/launcher.css +18 -5
  56. package/src/components/master-panel/master-panel.css +14 -3
  57. package/src/components/menu-button/menu-button.css +2 -2
  58. package/src/components/message/message.css +15 -10
  59. package/src/components/modal/modal.css +15 -3
  60. package/src/components/navigation-bar/navigation-bar.css +17 -5
  61. package/src/components/panel/panel.css +10 -2
  62. package/src/components/select/select.css +20 -4
  63. package/src/components/sheet/sheet.css +4 -1
  64. package/src/components/table/table.css +80 -4
  65. package/src/components/table/table.js +322 -37
  66. package/src/components/table-view/table-view.css +18 -0
  67. package/src/components/table-view/table-view.js +347 -0
  68. package/src/components/tag-picker/tag-picker.css +15 -3
  69. package/src/components/tooltip/tooltip.css +15 -3
  70. package/src/components/view/record-view.css +114 -0
  71. package/src/components/view/record-view.js +770 -0
  72. package/src/index.js +15 -0
  73. package/src/zeyos/calendar.js +115 -0
  74. package/src/zeyos/icons.js +15 -8
  75. package/src/zeyos/index.js +13 -0
  76. package/src/zeyos/launcher.js +2 -2
  77. package/src/zeyos/legacy-saved-views.js +155 -0
  78. package/src/zeyos/saved-views.js +815 -0
  79. package/src/zeyos/table.js +3 -1
  80. package/src/zeyos/view.js +543 -0
  81. package/styles/icons.css +3 -10
  82. package/styles/tokens/semantic.css +74 -2
  83. package/styles/zx.css +6 -0
@@ -0,0 +1,1524 @@
1
+ import { Component } from '../../core/component.js';
2
+ import { h } from '../../core/dom.js';
3
+ import { icon } from '../../core/icons.js';
4
+ import {
5
+ CALENDAR_VIEWS, addCalendarDays, calendarDayDifference, calendarDayKey,
6
+ calendarEventIntersects, calendarEventSpansDays, calendarRange, cloneCalendarEvent,
7
+ layoutCalendarSpans, layoutTimedCalendarEvents, normalizeCalendarEvents,
8
+ shiftCalendarEvent, startOfCalendarDay
9
+ } from './calendar-model.js';
10
+
11
+ /** @typedef {import('./calendar-model.js').CalendarEvent} CalendarEvent */
12
+ /** @typedef {import('./calendar-model.js').CalendarView} CalendarView */
13
+ /** @typedef {import('./calendar-model.js').CalendarReader} CalendarReader */
14
+
15
+ /**
16
+ * @typedef {Object} CalendarOptions
17
+ * @property {Record<string, any>[]} [events=[]] Event records.
18
+ * @property {Date|string|number|null} [date=null] Initial local anchor date; null uses today.
19
+ * @property {CalendarView} [view='month'] Initial view.
20
+ * @property {CalendarView[]} [views=['agenda','day','week','month','year']] Allowed views.
21
+ * @property {string|string[]} [locale] Intl locale override.
22
+ * @property {number} [weekStart=1] First weekday, 0 (Sunday) through 6 (Saturday).
23
+ * @property {boolean} [weekNumbers=true] Show ISO week numbers in month view.
24
+ * @property {boolean} [workweek=false] Show Monday–Friday in the week time grid.
25
+ * @property {number} [agendaDays=14] Number of days in agenda view.
26
+ * @property {number} [slotDuration=30] Time-grid snap interval in minutes.
27
+ * @property {number} [slotMinTime=0] First visible minute of the day.
28
+ * @property {number} [slotMaxTime=1440] Exclusive last visible minute of the day.
29
+ * @property {number} [scrollTime=480] Minute initially scrolled into view.
30
+ * @property {number} [eventLimit=3] Maximum visible month lanes before a More button.
31
+ * @property {boolean} [selectable=false] Enable pointer time-range selection.
32
+ * @property {boolean} [editable=false] Enable event moving and resizing.
33
+ * @property {boolean|null} [eventStartEditable=null] Global move override; null inherits editable.
34
+ * @property {boolean|null} [eventDurationEditable=null] Global resize override; null inherits editable.
35
+ * @property {boolean} [optimistic=true] Apply event proposals locally before persistence confirms.
36
+ * @property {boolean} [nowIndicator=true] Show the current-time line in day/week views.
37
+ * @property {Date|(() => Date)|null} [now=null] Current-time override for deterministic rendering.
38
+ * @property {boolean} [disabled=false] Disable toolbar, date, selection, and editing controls.
39
+ * @property {CalendarReader} [eventId='id'] Event ID reader.
40
+ * @property {CalendarReader} [eventTitle='title'] Event title reader.
41
+ * @property {CalendarReader} [eventStart='start'] Event start reader.
42
+ * @property {CalendarReader} [eventEnd='end'] Event end reader.
43
+ * @property {CalendarReader} [eventAllDay='allDay'] Event all-day reader.
44
+ * @property {CalendarReader} [eventColor='color'] Event colour reader.
45
+ * @property {CalendarReader} [eventLocation='location'] Event location reader.
46
+ * @property {CalendarReader} [eventEditable='editable'] Per-event move reader.
47
+ * @property {CalendarReader} [eventDurationEditable='durationEditable'] Per-event resize reader.
48
+ * @property {'milliseconds'|'seconds'} [dateUnit='milliseconds'] Unit for numeric event dates.
49
+ * @property {Record<string, string>} [msg] Localized component messages.
50
+ * @property {(event: CalendarEvent) => Node|string|null} [renderEvent] Optional safe event-content renderer.
51
+ * @property {(event: CustomEvent<{events: CalendarEvent[]}>) => void} [oneventschange] Programmatic event-data listener.
52
+ * @property {(event: CustomEvent<{view: CalendarView, oldView: CalendarView}>) => void} [onviewchange] View listener.
53
+ * @property {(event: CustomEvent<{view: CalendarView, start: Date, end: Date}>) => void} [ondateschange] Visible-range listener.
54
+ * @property {(event: CustomEvent<{event: CalendarEvent, jsEvent: Event}>) => void} [oneventclick] Event activation listener.
55
+ * @property {(event: CustomEvent<{event: CalendarEvent, jsEvent: Event}>) => void} [oneventdblclick] Event double-activation listener.
56
+ * @property {(event: CustomEvent<{date: Date, allDay: boolean, view: CalendarView, jsEvent: Event}>) => void} [ondateclick] Date/time activation listener.
57
+ * @property {(event: CustomEvent<{start: Date, end: Date, allDay: boolean, view: CalendarView, jsEvent: Event}>) => void} [onselect] Range-selection listener.
58
+ * @property {(event: CustomEvent<CalendarEventChangeDetail>) => void} [oneventchange] Move/resize proposal listener.
59
+ * @property {(event: CustomEvent<{date: Date, view: CalendarView, jsEvent: Event}>) => void} [onnew] New-event command listener.
60
+ * @property {(event: CustomEvent<{date: Date, count: number, jsEvent: Event}>) => void} [onmoreclick] Month-overflow listener.
61
+ */
62
+
63
+ /**
64
+ * @typedef {Object} CalendarEventChangeDetail
65
+ * @property {CalendarEvent} event Proposed event.
66
+ * @property {CalendarEvent} oldEvent Previous event.
67
+ * @property {'move'|'resize'} action Edit action.
68
+ * @property {{milliseconds: number, days: number, minutes: number, endMilliseconds: number}} delta Edit delta.
69
+ * @property {Event} jsEvent Originating pointer or keyboard event.
70
+ * @property {() => boolean} revert Idempotent optimistic revert callback.
71
+ */
72
+
73
+ /** @typedef {{silent?: boolean}} CalendarSetOptions */
74
+
75
+ const DEFAULT_MESSAGES = {
76
+ today: 'Today',
77
+ new: 'New',
78
+ agenda: 'Agenda',
79
+ day: 'Day',
80
+ week: 'Week',
81
+ month: 'Month',
82
+ year: 'Year',
83
+ previous: 'Previous period',
84
+ next: 'Next period',
85
+ allDay: 'All day',
86
+ noEvents: 'No events',
87
+ more: '+%1 more',
88
+ loading: 'Loading calendar…',
89
+ move: 'Move %1',
90
+ resize: 'Resize %1',
91
+ moveStarted: 'Moving %1. Use arrow keys, Space to drop, or Escape to cancel.',
92
+ resizeStarted: 'Resizing %1. Use arrow keys, Space to drop, or Escape to cancel.',
93
+ editPreview: '%1, %2 to %3',
94
+ editCancelled: 'Calendar edit cancelled.',
95
+ editCommitted: '%1 updated.',
96
+ calendar: 'Calendar',
97
+ weekNumber: 'Week %1'
98
+ };
99
+
100
+ /**
101
+ * Accessible, dependency-free agenda/day/week/month/year scheduling surface.
102
+ * @fires Calendar#viewchange
103
+ * @fires Calendar#dateschange
104
+ * @fires Calendar#eventclick
105
+ * @fires Calendar#eventdblclick
106
+ * @fires Calendar#dateclick
107
+ * @fires Calendar#select
108
+ * @fires Calendar#eventchange
109
+ * @fires Calendar#eventschange
110
+ * @fires Calendar#new
111
+ * @fires Calendar#moreclick
112
+ * @extends {Component<CalendarOptions>}
113
+ */
114
+ export class Calendar extends Component {
115
+ static cssName = 'calendar';
116
+
117
+ /** @type {CalendarOptions} */
118
+ static defaults = {
119
+ events: [],
120
+ date: null,
121
+ view: 'month',
122
+ views: [...CALENDAR_VIEWS],
123
+ weekStart: 1,
124
+ weekNumbers: true,
125
+ workweek: false,
126
+ agendaDays: 14,
127
+ slotDuration: 30,
128
+ slotMinTime: 0,
129
+ slotMaxTime: 1440,
130
+ scrollTime: 480,
131
+ eventLimit: 3,
132
+ selectable: false,
133
+ editable: false,
134
+ eventStartEditable: null,
135
+ eventDurationEditable: null,
136
+ optimistic: true,
137
+ nowIndicator: true,
138
+ now: null,
139
+ disabled: false,
140
+ eventId: 'id',
141
+ eventTitle: 'title',
142
+ eventStart: 'start',
143
+ eventEnd: 'end',
144
+ eventAllDay: 'allDay',
145
+ eventColor: 'color',
146
+ eventLocation: 'location',
147
+ eventEditable: 'editable',
148
+ eventDurationEditable: 'durationEditable',
149
+ dateUnit: 'milliseconds',
150
+ msg: DEFAULT_MESSAGES
151
+ };
152
+
153
+ /** @returns {HTMLElement} */
154
+ render() {
155
+ const createdRoot = !this.el;
156
+ const root = /** @type {HTMLElement} */ (this.el ?? h('section'));
157
+ if (!this.el) this.el = root;
158
+ this._createdRoot = createdRoot;
159
+ this._original = this._createdRoot ? null : {
160
+ attributes: Array.from(root.attributes).map(({ name, value }) => [name, value]),
161
+ children: Array.from(root.childNodes)
162
+ };
163
+ this._views = normalizeViews(this.options.views);
164
+ this._view = this._views.includes(this.options.view) ? this.options.view : this._views[0];
165
+ this._date = normalizeAnchorDate(this.options.date, this.options.dateUnit, this._now());
166
+ this._events = normalizeCalendarEvents(this.options.events, this._normalizeOptions());
167
+ this._loading = false;
168
+ this._destroyed = false;
169
+ this._pointer = null;
170
+ this._keyboardEdit = null;
171
+ this._mutationVersions = new Map();
172
+ this._mutationSerial = 0;
173
+ this._suppressClickId = null;
174
+ this._suppressDateClick = false;
175
+ this._focusedSlot = null;
176
+ this._selection = null;
177
+ this._nowTimer = null;
178
+
179
+ const navigation = h('div', { class: 'zx-calendar__navigation' },
180
+ this._toolbarButton('previous', this.msg('previous'), 'chevron-left'),
181
+ this._toolbarButton('today', this.msg('today')),
182
+ this._toolbarButton('next', this.msg('next'), 'chevron-right'));
183
+ const heading = h('h2', {
184
+ ref: 'heading',
185
+ class: 'zx-calendar__heading',
186
+ ariaLive: 'polite'
187
+ });
188
+ const views = h('div', {
189
+ ref: 'views',
190
+ class: 'zx-calendar__views',
191
+ role: 'group',
192
+ ariaLabel: 'Calendar view'
193
+ }, this._views.map((view) => h('button', {
194
+ type: 'button',
195
+ class: 'zx-calendar__view-button',
196
+ dataset: { calendarAction: 'view', view },
197
+ disabled: this.options.disabled
198
+ }, this.msg(view))));
199
+ const commands = h('div', { class: 'zx-calendar__commands' }, views,
200
+ h('button', {
201
+ type: 'button',
202
+ class: 'zx-calendar__new',
203
+ dataset: { calendarAction: 'new' },
204
+ disabled: this.options.disabled
205
+ }, icon('plus'), h('span', {}, this.msg('new'))));
206
+ const toolbar = h('header', { class: 'zx-calendar__toolbar' }, navigation, heading, commands);
207
+ const body = h('div', { ref: 'body', class: 'zx-calendar__body' });
208
+ const loading = h('div', {
209
+ ref: 'loading',
210
+ class: 'zx-calendar__loading',
211
+ role: 'status',
212
+ hidden: true
213
+ }, this.msg('loading'));
214
+ const status = h('div', {
215
+ ref: 'status',
216
+ class: 'zx-calendar__status',
217
+ role: 'status',
218
+ ariaLive: 'polite',
219
+ ariaAtomic: 'true'
220
+ });
221
+ root.replaceChildren(toolbar, body, loading, status);
222
+ root.setAttribute('aria-label', this.msg('calendar'));
223
+ root.dataset.view = this._view;
224
+ root.dataset.state = 'ready';
225
+
226
+ this.listen(root, 'click', (event) => this._onClick(event));
227
+ this.listen(root, 'dblclick', (event) => this._onDoubleClick(event));
228
+ this.listen(root, 'keydown', (event) => this._onKeydown(event));
229
+ this.listen(root, 'pointerdown', (event) => this._onPointerDown(event));
230
+ this.listen(root, 'pointermove', (event) => this._onPointerMove(event));
231
+ this.listen(root, 'pointerup', (event) => this._onPointerUp(event));
232
+ this.listen(root, 'pointercancel', (event) => this._cancelPointer(event));
233
+ this.listen(root, 'dragstart', (event) => event.preventDefault());
234
+
235
+ this._renderView({ emitDates: true, scroll: true });
236
+ if (this.options.nowIndicator) {
237
+ this._nowTimer = setInterval(() => this._refreshNowIndicator(), 60_000);
238
+ }
239
+ return root;
240
+ }
241
+
242
+ /** Returns defensive copies of every normalized event. @returns {CalendarEvent[]} */
243
+ getEvents() {
244
+ return this._events.map(cloneCalendarEvent);
245
+ }
246
+
247
+ /** Replaces all source records atomically. @param {Record<string, any>[]} events @param {CalendarSetOptions} [options={}] @returns {this} */
248
+ setEvents(events, options = {}) {
249
+ const normalized = normalizeCalendarEvents(events, this._normalizeOptions());
250
+ this._events = normalized;
251
+ this._mutationVersions.clear();
252
+ this._renderView({ preserveFocus: true });
253
+ if (!options.silent) this.emit('eventschange', { events: this.getEvents() });
254
+ return this;
255
+ }
256
+
257
+ /** Adds one source event. @param {Record<string, any>} event @param {CalendarSetOptions} [options={}] @returns {this} */
258
+ addEvent(event, options = {}) {
259
+ const [normalized] = normalizeCalendarEvents([event], this._normalizeOptions());
260
+ if (this._eventById(normalized.id)) throw new RangeError(`Duplicate Calendar event id: ${normalized.id}`);
261
+ this._events = [...this._events, normalized];
262
+ this._renderView({ preserveFocus: true });
263
+ if (!options.silent) this.emit('eventschange', { events: this.getEvents() });
264
+ return this;
265
+ }
266
+
267
+ /**
268
+ * Updates one normalized event. Date changes accept valid Date objects, ISO strings, or numeric
269
+ * values in the configured date unit.
270
+ * @param {string|number} id Event id.
271
+ * @param {Partial<CalendarEvent>} changes Normalized event changes.
272
+ * @param {CalendarSetOptions} [options={}] Update behavior.
273
+ * @returns {this}
274
+ */
275
+ updateEvent(id, changes, options = {}) {
276
+ const index = this._eventIndex(id);
277
+ if (index < 0) throw new RangeError(`Unknown Calendar event id: ${id}`);
278
+ const current = this._events[index];
279
+ const source = {
280
+ id: current.id,
281
+ title: changes.title ?? current.title,
282
+ start: changes.start ?? current.start,
283
+ end: changes.end ?? current.end,
284
+ allDay: changes.allDay ?? current.allDay,
285
+ color: Object.prototype.hasOwnProperty.call(changes, 'color') ? changes.color : current.color,
286
+ location: changes.location ?? current.location,
287
+ editable: Object.prototype.hasOwnProperty.call(changes, 'editable') ? changes.editable : current.editable,
288
+ durationEditable: Object.prototype.hasOwnProperty.call(changes, 'durationEditable')
289
+ ? changes.durationEditable : current.durationEditable,
290
+ data: changes.data ?? current.data
291
+ };
292
+ const [next] = normalizeCalendarEvents([source]);
293
+ next.data = source.data;
294
+ this._events = this._events.map((event, eventIndex) => eventIndex === index ? next : event);
295
+ this._mutationVersions.delete(String(id));
296
+ this._renderView({ preserveFocus: true });
297
+ if (!options.silent) this.emit('eventschange', { events: this.getEvents() });
298
+ return this;
299
+ }
300
+
301
+ /** Removes one event when present. @param {string|number} id Event id. @param {CalendarSetOptions} [options={}] @returns {this} */
302
+ removeEvent(id, options = {}) {
303
+ const key = String(id);
304
+ const next = this._events.filter((event) => String(event.id) !== key);
305
+ if (next.length === this._events.length) return this;
306
+ this._events = next;
307
+ this._mutationVersions.delete(key);
308
+ this._renderView({ preserveFocus: true });
309
+ if (!options.silent) this.emit('eventschange', { events: this.getEvents() });
310
+ return this;
311
+ }
312
+
313
+ /** Returns a copy of the current anchor date. @returns {Date} */
314
+ getDate() {
315
+ return new Date(this._date.getTime());
316
+ }
317
+
318
+ /** Sets the anchor date and visible period. @param {Date|string|number} date @param {CalendarSetOptions} [options={}] @returns {this} */
319
+ setDate(date, options = {}) {
320
+ this._date = normalizeAnchorDate(date, this.options.dateUnit, this._now());
321
+ this._renderView({ emitDates: !options.silent, preserveFocus: true, scroll: true });
322
+ return this;
323
+ }
324
+
325
+ /** Returns the current view. @returns {CalendarView} */
326
+ getView() {
327
+ return this._view;
328
+ }
329
+
330
+ /** Changes view while retaining the anchor date. @param {CalendarView} view @param {CalendarSetOptions} [options={}] @returns {this} */
331
+ setView(view, options = {}) {
332
+ if (!this._views.includes(view)) throw new RangeError(`Calendar view is not enabled: ${view}`);
333
+ if (view === this._view) return this;
334
+ const oldView = this._view;
335
+ this._view = view;
336
+ this.el.dataset.view = view;
337
+ this._renderView({ emitDates: true, preserveFocus: true, scroll: true });
338
+ if (!options.silent) this.emit('viewchange', { view, oldView });
339
+ return this;
340
+ }
341
+
342
+ /** Moves to the previous visible period. @returns {this} */
343
+ prev() {
344
+ return this._page(-1);
345
+ }
346
+
347
+ /** Moves to the next visible period. @returns {this} */
348
+ next() {
349
+ return this._page(1);
350
+ }
351
+
352
+ /** Moves to today. @returns {this} */
353
+ today() {
354
+ return this.setDate(this._now());
355
+ }
356
+
357
+ /** Toggles loading state without discarding the rendered calendar. @param {boolean} [loading=true] @returns {this} */
358
+ setLoading(loading = true) {
359
+ this._loading = Boolean(loading);
360
+ this.el.dataset.state = this._loading ? 'loading' : 'ready';
361
+ this.el.setAttribute('aria-busy', String(this._loading));
362
+ this.refs.loading.hidden = !this._loading;
363
+ return this;
364
+ }
365
+
366
+ /** Focuses an event in the current view when rendered. @param {string|number} id Event id. @returns {this} */
367
+ focusEvent(id) {
368
+ this.el.querySelector(`[data-event-action][data-event-id="${cssEscape(String(id))}"]`)?.focus();
369
+ return this;
370
+ }
371
+
372
+ /** Restores an enhanced target or removes the owned root and releases timers. @returns {void} */
373
+ destroy() {
374
+ if (this._destroyed) return;
375
+ this._destroyed = true;
376
+ if (this._nowTimer) clearInterval(this._nowTimer);
377
+ this._nowTimer = null;
378
+ const original = this._original;
379
+ super.destroy();
380
+ if (!this._createdRoot && original) {
381
+ for (const attribute of Array.from(this.el.attributes)) this.el.removeAttribute(attribute.name);
382
+ for (const [name, value] of original.attributes) this.el.setAttribute(name, value);
383
+ this.el.replaceChildren(...original.children);
384
+ }
385
+ }
386
+
387
+ /** @param {string} action @param {string} label @param {string|null} [iconName=null] @returns {HTMLButtonElement} */
388
+ _toolbarButton(action, label, iconName = null) {
389
+ return /** @type {HTMLButtonElement} */ (h('button', {
390
+ type: 'button',
391
+ class: 'zx-calendar__toolbar-button',
392
+ dataset: { calendarAction: action },
393
+ ariaLabel: iconName ? label : null,
394
+ disabled: this.options.disabled
395
+ }, iconName ? icon(iconName) : label));
396
+ }
397
+
398
+ /** @returns {import('./calendar-model.js').CalendarNormalizeOptions} */
399
+ _normalizeOptions() {
400
+ return {
401
+ eventId: this.options.eventId,
402
+ eventTitle: this.options.eventTitle,
403
+ eventStart: this.options.eventStart,
404
+ eventEnd: this.options.eventEnd,
405
+ eventAllDay: this.options.eventAllDay,
406
+ eventColor: this.options.eventColor,
407
+ eventLocation: this.options.eventLocation,
408
+ eventEditable: this.options.eventEditable,
409
+ eventDurationEditable: this.options.eventDurationEditable,
410
+ dateUnit: this.options.dateUnit
411
+ };
412
+ }
413
+
414
+ /** @param {{emitDates?: boolean, preserveFocus?: boolean, scroll?: boolean}} [options={}] @returns {void} */
415
+ _renderView(options = {}) {
416
+ if (!this.refs?.body || this._destroyed) return;
417
+ const focus = options.preserveFocus ? this._focusIdentity() : null;
418
+ const range = calendarRange(this._view, this._date, {
419
+ weekStart: this.options.weekStart,
420
+ agendaDays: this.options.agendaDays
421
+ });
422
+ this._range = range;
423
+ this.refs.heading.textContent = this._rangeTitle(range);
424
+ for (const button of this.refs.views.querySelectorAll('[data-view]')) {
425
+ button.setAttribute('aria-pressed', String(button.dataset.view === this._view));
426
+ }
427
+ const content = this._view === 'agenda' ? this._renderAgenda(range)
428
+ : this._view === 'day' || this._view === 'week' ? this._renderTimeGrid(range)
429
+ : this._view === 'month' ? this._renderMonth(range) : this._renderYear(range);
430
+ this.refs.body.replaceChildren(content);
431
+ if (options.scroll) this._scrollTimeGrid();
432
+ if (focus) this._restoreFocus(focus);
433
+ if (options.emitDates) this.emit('dateschange', {
434
+ view: this._view,
435
+ start: new Date(range.start.getTime()),
436
+ end: new Date(range.end.getTime())
437
+ });
438
+ }
439
+
440
+ /** @param {{start: Date, end: Date}} range @returns {HTMLElement} */
441
+ _renderAgenda(range) {
442
+ const grid = h('div', {
443
+ class: 'zx-calendar__agenda',
444
+ role: 'grid',
445
+ ariaLabel: this._rangeTitle(range)
446
+ });
447
+ const days = calendarDayDifference(range.start, range.end);
448
+ for (let index = 0; index < days; index += 1) {
449
+ const day = addCalendarDays(range.start, index);
450
+ const next = addCalendarDays(day, 1);
451
+ const events = this._events.filter((event) => {
452
+ if (!calendarEventIntersects(event, day, next)) return false;
453
+ return event.start >= day || index === 0;
454
+ }).sort(compareEvents);
455
+ const row = h('section', { class: 'zx-calendar__agenda-day', role: 'row' },
456
+ h('button', {
457
+ type: 'button',
458
+ class: 'zx-calendar__agenda-heading',
459
+ role: 'rowheader',
460
+ dataset: { calendarAction: 'date', date: calendarDayKey(day), dateCell: 'true' },
461
+ ariaCurrent: sameCalendarDay(day, this._now()) ? 'date' : null,
462
+ disabled: this.options.disabled
463
+ }, this._format(day, { weekday: 'long', day: 'numeric', month: 'long' })));
464
+ if (!events.length) {
465
+ row.append(h('p', { class: 'zx-calendar__agenda-empty', role: 'gridcell' }, this.msg('noEvents')));
466
+ } else {
467
+ for (const event of events) row.append(this._eventElement(event, { variant: 'agenda', day }));
468
+ }
469
+ grid.append(row);
470
+ }
471
+ return grid;
472
+ }
473
+
474
+ /** @param {{start: Date, end: Date}} range @returns {HTMLElement} */
475
+ _renderTimeGrid(range) {
476
+ const allDays = Array.from({ length: calendarDayDifference(range.start, range.end) }, (_, index) =>
477
+ addCalendarDays(range.start, index));
478
+ const days = this._view === 'week' && this.options.workweek
479
+ ? allDays.filter((date) => date.getDay() > 0 && date.getDay() < 6) : allDays;
480
+ const min = normalizeMinute(this.options.slotMinTime, 0);
481
+ const max = normalizeMinute(this.options.slotMaxTime, 1440, true);
482
+ const slot = normalizeSlot(this.options.slotDuration);
483
+ if (max <= min) throw new RangeError('slotMaxTime must exceed slotMinTime');
484
+ const slotCount = Math.ceil((max - min) / slot);
485
+ const surface = h('div', {
486
+ class: 'zx-calendar__time-surface',
487
+ style: { '--zx-calendar-day-count': String(days.length) }
488
+ });
489
+ const header = h('div', { class: 'zx-calendar__time-header' },
490
+ h('span', { class: 'zx-calendar__time-corner', ariaHidden: 'true' }),
491
+ h('div', { class: 'zx-calendar__day-headings', role: 'row' }, days.map((day) => h('button', {
492
+ type: 'button',
493
+ class: 'zx-calendar__day-heading',
494
+ role: 'columnheader',
495
+ dataset: { calendarAction: 'date', date: calendarDayKey(day), dateCell: 'true' },
496
+ ariaCurrent: sameCalendarDay(day, this._now()) ? 'date' : null,
497
+ disabled: this.options.disabled
498
+ }, h('span', { class: 'zx-calendar__day-weekday' }, this._format(day, { weekday: 'short' })),
499
+ h('span', { class: 'zx-calendar__day-number' }, this._format(day, { day: 'numeric', month: 'short' }))))));
500
+ const allDay = this._renderAllDayRail(days);
501
+ const scroller = h('div', { ref: 'timeScroller', class: 'zx-calendar__time-scroller' });
502
+ const axis = h('div', { class: 'zx-calendar__time-axis', ariaHidden: 'true' });
503
+ const grid = h('div', {
504
+ class: 'zx-calendar__time-grid',
505
+ role: 'grid',
506
+ ariaLabel: this._rangeTitle(range),
507
+ ariaRowcount: String(slotCount),
508
+ ariaColcount: String(days.length),
509
+ style: { '--zx-calendar-slot-count': String(slotCount) }
510
+ });
511
+ const slots = h('div', { ref: 'slots', class: 'zx-calendar__slots' });
512
+ let firstTabStop = true;
513
+ for (let row = 0; row < slotCount; row += 1) {
514
+ const minutes = min + row * slot;
515
+ axis.append(h('span', { class: 'zx-calendar__time-label' }, minutes % 60 === 0 ? formatTimeMinutes(minutes, this.options.locale) : ''));
516
+ for (let column = 0; column < days.length; column += 1) {
517
+ const date = dateWithMinutes(days[column], minutes);
518
+ const key = String(date.getTime());
519
+ const tabbable = this._focusedSlot ? this._focusedSlot === key : firstTabStop;
520
+ if (tabbable) firstTabStop = false;
521
+ slots.append(h('button', {
522
+ type: 'button',
523
+ class: 'zx-calendar__slot',
524
+ role: 'gridcell',
525
+ tabindex: tabbable ? 0 : -1,
526
+ dataset: {
527
+ calendarAction: 'date',
528
+ slot: key,
529
+ dateTime: key,
530
+ dayIndex: String(column),
531
+ rowIndex: String(row),
532
+ major: minutes % 60 === 0 ? 'true' : null
533
+ },
534
+ ariaLabel: this._dateTimeLabel(date),
535
+ disabled: this.options.disabled
536
+ }));
537
+ }
538
+ }
539
+ const eventsLayer = h('div', { class: 'zx-calendar__timed-events', ariaHidden: 'false' });
540
+ days.forEach((day, dayIndex) => {
541
+ const layouts = layoutTimedCalendarEvents(this._events, day, {
542
+ minMinutes: min,
543
+ maxMinutes: max,
544
+ minimumMinutes: slot
545
+ });
546
+ for (const layout of layouts) {
547
+ const top = ((layout.startMinutes - min) / (max - min)) * 100;
548
+ const height = Math.max((slot / (max - min)) * 100,
549
+ ((layout.endMinutes - layout.startMinutes) / (max - min)) * 100);
550
+ const dayWidth = 100 / days.length;
551
+ const width = dayWidth / layout.columns;
552
+ const left = dayIndex * dayWidth + layout.column * width;
553
+ const event = this._eventElement(layout.event, { variant: 'timed', day, resizable: true });
554
+ event.style.insetInlineStart = `${left}%`;
555
+ event.style.inlineSize = `${width}%`;
556
+ event.style.insetBlockStart = `${top}%`;
557
+ event.style.blockSize = `${height}%`;
558
+ eventsLayer.append(event);
559
+ }
560
+ });
561
+ grid.append(slots, eventsLayer, this._nowLine(days, min, max));
562
+ scroller.append(axis, grid);
563
+ surface.append(header, allDay, scroller);
564
+ return surface;
565
+ }
566
+
567
+ /** @param {Date[]} days @returns {HTMLElement} */
568
+ _renderAllDayRail(days) {
569
+ const rail = h('div', { class: 'zx-calendar__all-day' },
570
+ h('span', { class: 'zx-calendar__all-day-label' }, this.msg('allDay')));
571
+ const layer = h('div', {
572
+ class: 'zx-calendar__all-day-grid',
573
+ role: 'row',
574
+ style: { '--zx-calendar-day-count': String(days.length) }
575
+ });
576
+ for (const day of days) layer.append(h('span', {
577
+ class: 'zx-calendar__all-day-cell',
578
+ dataset: { date: calendarDayKey(day) },
579
+ ariaHidden: 'true'
580
+ }));
581
+ const spans = layoutCalendarSpans(this._events, days[0], days.length);
582
+ for (const span of spans) {
583
+ const event = this._eventElement(span.event, { variant: 'span', day: days[span.start] });
584
+ event.style.gridColumn = `${span.start + 1} / ${span.end + 1}`;
585
+ event.style.gridRow = String(span.lane + 1);
586
+ layer.append(event);
587
+ }
588
+ rail.append(layer);
589
+ return rail;
590
+ }
591
+
592
+ /** @param {{start: Date, end: Date}} range @returns {HTMLElement} */
593
+ _renderMonth(range) {
594
+ const wrapper = h('div', { class: 'zx-calendar__month' });
595
+ const weekdays = h('div', { class: 'zx-calendar__month-weekdays', role: 'row' });
596
+ if (this.options.weekNumbers) weekdays.append(h('span', { class: 'zx-calendar__month-week-gutter', ariaHidden: 'true' }));
597
+ for (let offset = 0; offset < 7; offset += 1) {
598
+ const day = addCalendarDays(range.start, offset);
599
+ weekdays.append(h('span', { class: 'zx-calendar__month-weekday', role: 'columnheader' },
600
+ this._format(day, { weekday: 'short' })));
601
+ }
602
+ wrapper.append(weekdays);
603
+ const weekCount = calendarDayDifference(range.start, range.end) / 7;
604
+ for (let week = 0; week < weekCount; week += 1) {
605
+ const start = addCalendarDays(range.start, week * 7);
606
+ const spans = layoutCalendarSpans(this._events.map((event) => ({ ...event, allDay: true })), start, 7);
607
+ const eventLimit = normalizeEventLimit(this.options.eventLimit);
608
+ const visible = spans.filter((span) => span.lane < eventLimit);
609
+ const hidden = spans.filter((span) => span.lane >= eventLimit);
610
+ const row = h('div', {
611
+ class: 'zx-calendar__month-week',
612
+ role: 'row',
613
+ dataset: { weekStart: calendarDayKey(start) }
614
+ });
615
+ if (this.options.weekNumbers) row.append(h('button', {
616
+ type: 'button',
617
+ class: 'zx-calendar__month-week-number',
618
+ dataset: { calendarAction: 'week', date: calendarDayKey(start) },
619
+ ariaLabel: this.msg('weekNumber', isoWeek(start)),
620
+ disabled: this.options.disabled
621
+ }, String(isoWeek(start))));
622
+ const days = h('div', { class: 'zx-calendar__month-days' });
623
+ for (let offset = 0; offset < 7; offset += 1) {
624
+ const day = addCalendarDays(start, offset);
625
+ const count = this._events.filter((event) => calendarEventIntersects(event, day, addCalendarDays(day, 1))).length;
626
+ days.append(h('button', {
627
+ type: 'button',
628
+ class: 'zx-calendar__month-day',
629
+ role: 'gridcell',
630
+ dataset: {
631
+ calendarAction: 'date',
632
+ date: calendarDayKey(day),
633
+ dateCell: 'true',
634
+ otherMonth: day.getMonth() === this._date.getMonth() ? null : 'true',
635
+ eventCount: String(count)
636
+ },
637
+ ariaCurrent: sameCalendarDay(day, this._now()) ? 'date' : null,
638
+ ariaLabel: `${this._format(day, { dateStyle: 'full' })}, ${count} ${count === 1 ? 'event' : 'events'}`,
639
+ disabled: this.options.disabled
640
+ }, this._format(day, { day: 'numeric' })));
641
+ }
642
+ const lanes = h('div', { class: 'zx-calendar__month-events' });
643
+ for (const span of visible) {
644
+ const event = this._eventElement(span.event, { variant: 'month', day: addCalendarDays(start, span.start) });
645
+ event.style.gridColumn = `${span.start + 1} / ${span.end + 1}`;
646
+ event.style.gridRow = String(span.lane + 1);
647
+ lanes.append(event);
648
+ }
649
+ const hiddenByDay = Array.from({ length: 7 }, (_, index) => hidden.filter((span) => span.start <= index && span.end > index).length);
650
+ for (let index = 0; index < hiddenByDay.length; index += 1) {
651
+ const count = hiddenByDay[index];
652
+ if (!count) continue;
653
+ const day = addCalendarDays(start, index);
654
+ lanes.append(h('button', {
655
+ type: 'button',
656
+ class: 'zx-calendar__more',
657
+ dataset: { calendarAction: 'more', date: calendarDayKey(day), count: String(count) },
658
+ style: { gridColumn: String(index + 1), gridRow: String(eventLimit + 1) },
659
+ disabled: this.options.disabled
660
+ }, this.msg('more', count)));
661
+ }
662
+ row.append(days, lanes);
663
+ wrapper.append(row);
664
+ }
665
+ wrapper.setAttribute('role', 'grid');
666
+ wrapper.setAttribute('aria-label', this._rangeTitle(range));
667
+ return wrapper;
668
+ }
669
+
670
+ /** @param {{start: Date, end: Date}} range @returns {HTMLElement} */
671
+ _renderYear(range) {
672
+ const year = range.start.getFullYear();
673
+ const surface = h('div', { class: 'zx-calendar__year', ariaLabel: String(year) });
674
+ for (let month = 0; month < 12; month += 1) {
675
+ const first = new Date(year, month, 1);
676
+ const offset = (first.getDay() - this.options.weekStart + 7) % 7;
677
+ const start = addCalendarDays(first, -offset);
678
+ const grid = h('div', { class: 'zx-calendar__year-grid', role: 'grid', ariaLabel: this._format(first, { month: 'long' }) },
679
+ h('h3', { class: 'zx-calendar__year-heading' }, this._format(first, { month: 'long' })));
680
+ for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) {
681
+ const weekday = addCalendarDays(start, dayIndex);
682
+ grid.append(h('span', { class: 'zx-calendar__year-weekday', role: 'columnheader' },
683
+ this._format(weekday, { weekday: 'narrow' })));
684
+ }
685
+ for (let index = 0; index < 42; index += 1) {
686
+ const day = addCalendarDays(start, index);
687
+ if (day.getMonth() !== month) {
688
+ grid.append(h('span', { class: 'zx-calendar__year-blank', role: 'gridcell', ariaHidden: 'true' }));
689
+ continue;
690
+ }
691
+ const count = this._events.filter((event) => calendarEventIntersects(event, day, addCalendarDays(day, 1))).length;
692
+ grid.append(h('button', {
693
+ type: 'button',
694
+ class: 'zx-calendar__year-day',
695
+ role: 'gridcell',
696
+ dataset: {
697
+ calendarAction: 'date',
698
+ date: calendarDayKey(day),
699
+ dateCell: 'true',
700
+ activity: String(Math.min(3, count))
701
+ },
702
+ ariaCurrent: sameCalendarDay(day, this._now()) ? 'date' : null,
703
+ ariaLabel: `${this._format(day, { dateStyle: 'full' })}, ${count} ${count === 1 ? 'event' : 'events'}`,
704
+ disabled: this.options.disabled
705
+ }, String(day.getDate())));
706
+ }
707
+ surface.append(grid);
708
+ }
709
+ return surface;
710
+ }
711
+
712
+ /**
713
+ * @param {CalendarEvent} event
714
+ * @param {{variant: 'agenda'|'timed'|'span'|'month', day: Date, resizable?: boolean}} context
715
+ * @returns {HTMLElement}
716
+ */
717
+ _eventElement(event, context) {
718
+ const key = String(event.id);
719
+ const wrapper = h('div', {
720
+ class: 'zx-calendar__event',
721
+ dataset: {
722
+ eventId: key,
723
+ variant: context.variant,
724
+ draggable: this._canMove(event) ? 'true' : null
725
+ },
726
+ style: event.color ? { '--zx-calendar-event-color': event.color } : null
727
+ });
728
+ const button = h('button', {
729
+ type: 'button',
730
+ class: 'zx-calendar__event-action',
731
+ dataset: { eventAction: 'activate', eventId: key },
732
+ ariaLabel: this._eventLabel(event),
733
+ disabled: this.options.disabled
734
+ });
735
+ const custom = typeof this.options.renderEvent === 'function'
736
+ ? this.options.renderEvent(cloneCalendarEvent(event)) : null;
737
+ if (custom instanceof Node) button.append(custom);
738
+ else if (typeof custom === 'string') button.append(document.createTextNode(custom));
739
+ else {
740
+ const time = h('span', { class: 'zx-calendar__event-time' }, this._eventTime(event, context.day));
741
+ const title = h('span', { class: 'zx-calendar__event-title' }, event.title);
742
+ button.append(time, title);
743
+ if (event.location) button.append(h('span', { class: 'zx-calendar__event-location' }, event.location));
744
+ }
745
+ wrapper.append(button);
746
+ if (context.resizable && this._canResize(event)) {
747
+ wrapper.append(h('button', {
748
+ type: 'button',
749
+ class: 'zx-calendar__event-resize',
750
+ dataset: { eventAction: 'resize', eventId: key },
751
+ ariaLabel: this.msg('resize', event.title),
752
+ disabled: this.options.disabled
753
+ }, h('span', { ariaHidden: 'true' })));
754
+ }
755
+ return wrapper;
756
+ }
757
+
758
+ /** @param {Date[]} days @param {number} min @param {number} max @returns {HTMLElement} */
759
+ _nowLine(days, min, max) {
760
+ const now = this._now();
761
+ const dayIndex = days.findIndex((day) => sameCalendarDay(day, now));
762
+ const minutes = now.getHours() * 60 + now.getMinutes();
763
+ const visible = this.options.nowIndicator && dayIndex >= 0 && minutes >= min && minutes <= max;
764
+ return h('div', {
765
+ ref: 'nowLine',
766
+ class: 'zx-calendar__now',
767
+ hidden: !visible,
768
+ ariaHidden: 'true',
769
+ style: visible ? {
770
+ insetBlockStart: `${((minutes - min) / (max - min)) * 100}%`,
771
+ '--zx-calendar-now-day': String(dayIndex)
772
+ } : null
773
+ }, h('span', { class: 'zx-calendar__now-time' }, formatTimeMinutes(minutes, this.options.locale)));
774
+ }
775
+
776
+ /** @param {Event} event @returns {void} */
777
+ _onClick(event) {
778
+ const target = event.target?.closest?.('button');
779
+ if (!target || !this.el.contains(target)) return;
780
+ const eventId = target.dataset.eventId;
781
+ if (eventId != null && this._suppressClickId === eventId) {
782
+ this._suppressClickId = null;
783
+ event.preventDefault();
784
+ return;
785
+ }
786
+ if (target.dataset.eventAction === 'activate') {
787
+ const item = this._eventById(eventId);
788
+ if (item) this.emit('eventclick', { event: cloneCalendarEvent(item), jsEvent: event });
789
+ return;
790
+ }
791
+ if (target.dataset.eventAction === 'resize') return;
792
+ const action = target.dataset.calendarAction;
793
+ if (!action) return;
794
+ if (action === 'previous') this.prev();
795
+ else if (action === 'next') this.next();
796
+ else if (action === 'today') this.today();
797
+ else if (action === 'view') this.setView(/** @type {CalendarView} */ (target.dataset.view));
798
+ else if (action === 'new') this.emit('new', { date: this.getDate(), view: this._view, jsEvent: event });
799
+ else if (action === 'week') {
800
+ const date = parseDayKey(target.dataset.date);
801
+ if (date) this.setDate(date).setView('week');
802
+ } else if (action === 'more') {
803
+ const date = parseDayKey(target.dataset.date);
804
+ if (!date) return;
805
+ const emitted = this.emit('moreclick', { date, count: Number(target.dataset.count), jsEvent: event });
806
+ if (!emitted.defaultPrevented && this._views.includes('agenda')) this.setDate(date).setView('agenda');
807
+ } else if (action === 'date') {
808
+ if (this._suppressDateClick) {
809
+ this._suppressDateClick = false;
810
+ event.preventDefault();
811
+ return;
812
+ }
813
+ const date = this._dateFromTarget(target);
814
+ if (!date) return;
815
+ this._rememberFocusedDate(target);
816
+ this.emit('dateclick', {
817
+ date,
818
+ allDay: !target.dataset.dateTime,
819
+ view: this._view,
820
+ jsEvent: event
821
+ });
822
+ }
823
+ }
824
+
825
+ /** @param {Event} event @returns {void} */
826
+ _onDoubleClick(event) {
827
+ const eventTarget = event.target?.closest?.('[data-event-action="activate"]');
828
+ if (eventTarget) {
829
+ const item = this._eventById(eventTarget.dataset.eventId);
830
+ if (item) this.emit('eventdblclick', { event: cloneCalendarEvent(item), jsEvent: event });
831
+ return;
832
+ }
833
+ const dateTarget = event.target?.closest?.('[data-calendar-action="date"]');
834
+ if (!dateTarget) return;
835
+ const date = this._dateFromTarget(dateTarget);
836
+ if (!date) return;
837
+ const allDay = !dateTarget.dataset.dateTime;
838
+ const end = allDay ? addCalendarDays(date, 1) : addMinutes(date, normalizeSlot(this.options.slotDuration));
839
+ this.emit('select', { start: date, end, allDay, view: this._view, jsEvent: event });
840
+ }
841
+
842
+ /** @param {KeyboardEvent} event @returns {void} */
843
+ _onKeydown(event) {
844
+ const eventControl = event.target?.closest?.('[data-event-action]');
845
+ if (eventControl) {
846
+ this._onEventKeydown(event, eventControl);
847
+ return;
848
+ }
849
+ const slot = event.target?.closest?.('[data-slot]');
850
+ if (slot) {
851
+ this._onSlotKeydown(event, slot);
852
+ return;
853
+ }
854
+ const day = event.target?.closest?.('[data-date-cell]');
855
+ if (day) this._onDayKeydown(event, day);
856
+ }
857
+
858
+ /** @param {KeyboardEvent} event @param {HTMLElement} control @returns {void} */
859
+ _onEventKeydown(event, control) {
860
+ const item = this._eventById(control.dataset.eventId);
861
+ if (!item) return;
862
+ const resize = control.dataset.eventAction === 'resize';
863
+ if (event.key === 'Enter' && !this._keyboardEdit && !resize) {
864
+ event.preventDefault();
865
+ this.emit('eventclick', { event: cloneCalendarEvent(item), jsEvent: event });
866
+ return;
867
+ }
868
+ if (event.key === ' ' && !this._keyboardEdit) {
869
+ if (resize ? !this._canResize(item) : !this._canMove(item)) return;
870
+ event.preventDefault();
871
+ this._keyboardEdit = {
872
+ oldEvent: cloneCalendarEvent(item),
873
+ event: cloneCalendarEvent(item),
874
+ action: resize ? 'resize' : 'move',
875
+ control
876
+ };
877
+ control.setAttribute('aria-grabbed', 'true');
878
+ control.closest('.zx-calendar__event')?.setAttribute('data-state', 'grabbed');
879
+ this._announce(this.msg(resize ? 'resizeStarted' : 'moveStarted', item.title));
880
+ return;
881
+ }
882
+ if (!this._keyboardEdit || String(this._keyboardEdit.event.id) !== String(item.id)) return;
883
+ if (event.key === 'Escape') {
884
+ event.preventDefault();
885
+ this._cancelKeyboardEdit();
886
+ return;
887
+ }
888
+ if (event.key === ' ') {
889
+ event.preventDefault();
890
+ const edit = this._keyboardEdit;
891
+ this._keyboardEdit = null;
892
+ edit.control.removeAttribute('aria-grabbed');
893
+ this._commitMutation(edit.oldEvent, edit.event, edit.action, event);
894
+ return;
895
+ }
896
+ let days = 0;
897
+ let minutes = 0;
898
+ const timed = !calendarEventSpansDays(item) && (this._view === 'day' || this._view === 'week');
899
+ if (event.key === 'ArrowLeft') days = -1;
900
+ else if (event.key === 'ArrowRight') days = 1;
901
+ else if (event.key === 'ArrowUp') timed ? minutes = -normalizeSlot(this.options.slotDuration) : days = -7;
902
+ else if (event.key === 'ArrowDown') timed ? minutes = normalizeSlot(this.options.slotDuration) : days = 7;
903
+ else return;
904
+ event.preventDefault();
905
+ const edit = this._keyboardEdit;
906
+ if (edit.action === 'move') edit.event = shiftCalendarEvent(edit.event, { days, minutes });
907
+ else {
908
+ const proposed = shiftCalendarEvent(edit.event, { days: 0, minutes: 0, endDays: days, endMinutes: minutes });
909
+ if (proposed.end > proposed.start) edit.event = proposed;
910
+ }
911
+ edit.control.closest('.zx-calendar__event')?.setAttribute('data-preview', this._eventTime(edit.event, edit.event.start));
912
+ this._announce(this.msg('editPreview', edit.event.title,
913
+ this._dateTimeLabel(edit.event.start), this._dateTimeLabel(edit.event.end)));
914
+ }
915
+
916
+ /** @param {KeyboardEvent} event @param {HTMLElement} slot @returns {void} */
917
+ _onSlotKeydown(event, slot) {
918
+ const row = Number(slot.dataset.rowIndex);
919
+ const column = Number(slot.dataset.dayIndex);
920
+ const grid = slot.closest('[role="grid"]');
921
+ const columns = Number(grid?.getAttribute('aria-colcount')) || 1;
922
+ const rows = Number(grid?.getAttribute('aria-rowcount')) || 1;
923
+ let nextRow = row;
924
+ let nextColumn = column;
925
+ if (event.key === 'ArrowLeft') nextColumn -= 1;
926
+ else if (event.key === 'ArrowRight') nextColumn += 1;
927
+ else if (event.key === 'ArrowUp') nextRow -= 1;
928
+ else if (event.key === 'ArrowDown') nextRow += 1;
929
+ else if (event.key === 'Home') nextColumn = 0;
930
+ else if (event.key === 'End') nextColumn = columns - 1;
931
+ else if (event.key === 'PageUp') {
932
+ event.preventDefault();
933
+ this.prev();
934
+ return;
935
+ } else if (event.key === 'PageDown') {
936
+ event.preventDefault();
937
+ this.next();
938
+ return;
939
+ } else if (event.key === 'Enter' || event.key === ' ') {
940
+ event.preventDefault();
941
+ const date = this._dateFromTarget(slot);
942
+ if (date) this.emit('dateclick', { date, allDay: false, view: this._view, jsEvent: event });
943
+ return;
944
+ } else return;
945
+ event.preventDefault();
946
+ nextRow = Math.max(0, Math.min(rows - 1, nextRow));
947
+ nextColumn = Math.max(0, Math.min(columns - 1, nextColumn));
948
+ const next = grid?.querySelector(`[data-row-index="${nextRow}"][data-day-index="${nextColumn}"]`);
949
+ if (next) {
950
+ slot.tabIndex = -1;
951
+ next.tabIndex = 0;
952
+ this._focusedSlot = next.dataset.slot;
953
+ next.focus();
954
+ }
955
+ }
956
+
957
+ /** @param {KeyboardEvent} event @param {HTMLElement} day @returns {void} */
958
+ _onDayKeydown(event, day) {
959
+ const date = parseDayKey(day.dataset.date);
960
+ if (!date) return;
961
+ let amount = 0;
962
+ if (event.key === 'ArrowLeft') amount = -1;
963
+ else if (event.key === 'ArrowRight') amount = 1;
964
+ else if (event.key === 'ArrowUp') amount = -7;
965
+ else if (event.key === 'ArrowDown') amount = 7;
966
+ else if (event.key === 'Home') amount = -((date.getDay() - this.options.weekStart + 7) % 7);
967
+ else if (event.key === 'End') amount = 6 - ((date.getDay() - this.options.weekStart + 7) % 7);
968
+ else if (event.key === 'PageUp') {
969
+ event.preventDefault();
970
+ this.prev();
971
+ return;
972
+ } else if (event.key === 'PageDown') {
973
+ event.preventDefault();
974
+ this.next();
975
+ return;
976
+ } else return;
977
+ event.preventDefault();
978
+ const targetDate = addCalendarDays(date, amount);
979
+ const target = this.el.querySelector(`[data-date-cell][data-date="${calendarDayKey(targetDate)}"]`);
980
+ if (target) target.focus();
981
+ else this.setDate(targetDate);
982
+ }
983
+
984
+ /** @param {PointerEvent} event @returns {void} */
985
+ _onPointerDown(event) {
986
+ if (this.options.disabled || event.button !== 0) return;
987
+ const control = event.target?.closest?.('[data-event-action]');
988
+ if (control) {
989
+ const item = this._eventById(control.dataset.eventId);
990
+ const action = control.dataset.eventAction === 'resize' ? 'resize' : 'move';
991
+ if (!item || (action === 'move' ? !this._canMove(item) : !this._canResize(item))) return;
992
+ control.setPointerCapture?.(event.pointerId);
993
+ this._pointer = {
994
+ type: 'event',
995
+ pointerId: event.pointerId,
996
+ startX: event.clientX,
997
+ startY: event.clientY,
998
+ oldEvent: cloneCalendarEvent(item),
999
+ event: cloneCalendarEvent(item),
1000
+ action,
1001
+ control,
1002
+ moved: false
1003
+ };
1004
+ return;
1005
+ }
1006
+ const slot = event.target?.closest?.('[data-slot]');
1007
+ if (!slot || !this.options.selectable) return;
1008
+ const date = this._dateFromTarget(slot);
1009
+ if (!date) return;
1010
+ slot.setPointerCapture?.(event.pointerId);
1011
+ this._pointer = {
1012
+ type: 'selection',
1013
+ pointerId: event.pointerId,
1014
+ startX: event.clientX,
1015
+ startY: event.clientY,
1016
+ start: date,
1017
+ end: addMinutes(date, normalizeSlot(this.options.slotDuration)),
1018
+ control: slot,
1019
+ moved: false
1020
+ };
1021
+ }
1022
+
1023
+ /** @param {PointerEvent} event @returns {void} */
1024
+ _onPointerMove(event) {
1025
+ const pointer = this._pointer;
1026
+ if (!pointer || pointer.pointerId !== event.pointerId) return;
1027
+ if (!pointer.moved && Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY) < 5) return;
1028
+ pointer.moved = true;
1029
+ event.preventDefault();
1030
+ if (pointer.type === 'selection') {
1031
+ const date = this._timeAtPoint(event.clientX, event.clientY);
1032
+ if (!date) return;
1033
+ const slot = normalizeSlot(this.options.slotDuration);
1034
+ pointer.end = addMinutes(date, slot);
1035
+ this._previewSelection(pointer.start, pointer.end);
1036
+ return;
1037
+ }
1038
+ const proposed = this._proposalAtPoint(pointer.oldEvent, pointer.action, event.clientX, event.clientY);
1039
+ if (!proposed) return;
1040
+ pointer.event = proposed;
1041
+ const wrapper = pointer.control.closest('.zx-calendar__event');
1042
+ if (wrapper) {
1043
+ wrapper.dataset.state = 'dragging';
1044
+ wrapper.style.transform = `translate(${event.clientX - pointer.startX}px, ${event.clientY - pointer.startY}px)`;
1045
+ wrapper.dataset.preview = this._eventTime(proposed, proposed.start);
1046
+ }
1047
+ this._announce(this.msg('editPreview', proposed.title,
1048
+ this._dateTimeLabel(proposed.start), this._dateTimeLabel(proposed.end)));
1049
+ }
1050
+
1051
+ /** @param {PointerEvent} event @returns {void} */
1052
+ _onPointerUp(event) {
1053
+ const pointer = this._pointer;
1054
+ if (!pointer || pointer.pointerId !== event.pointerId) return;
1055
+ this._pointer = null;
1056
+ this._clearSelectionPreview();
1057
+ if (!pointer.moved) return;
1058
+ event.preventDefault();
1059
+ if (pointer.type === 'selection') {
1060
+ this._suppressDateClick = true;
1061
+ const start = pointer.start < pointer.end ? pointer.start : addMinutes(pointer.end, -normalizeSlot(this.options.slotDuration));
1062
+ const end = pointer.start < pointer.end ? pointer.end : addMinutes(pointer.start, normalizeSlot(this.options.slotDuration));
1063
+ this.emit('select', { start: new Date(start), end: new Date(end), allDay: false, view: this._view, jsEvent: event });
1064
+ return;
1065
+ }
1066
+ this._suppressClickId = String(pointer.oldEvent.id);
1067
+ this._commitMutation(pointer.oldEvent, pointer.event, pointer.action, event);
1068
+ }
1069
+
1070
+ /** @param {PointerEvent} event @returns {void} */
1071
+ _cancelPointer(event) {
1072
+ if (!this._pointer || this._pointer.pointerId !== event.pointerId) return;
1073
+ const control = this._pointer.control;
1074
+ this._pointer = null;
1075
+ this._clearSelectionPreview();
1076
+ control.closest?.('.zx-calendar__event')?.removeAttribute('data-state');
1077
+ this._renderView({ preserveFocus: true });
1078
+ }
1079
+
1080
+ /** @param {CalendarEvent} event @param {'move'|'resize'} action @param {number} x @param {number} y @returns {CalendarEvent|null} */
1081
+ _proposalAtPoint(event, action, x, y) {
1082
+ const timed = this._timeAtPoint(x, y);
1083
+ if (timed && !calendarEventSpansDays(event)) {
1084
+ if (action === 'move') {
1085
+ const duration = event.end.getTime() - event.start.getTime();
1086
+ const next = cloneCalendarEvent(event);
1087
+ next.start = timed;
1088
+ next.end = new Date(timed.getTime() + duration);
1089
+ return next;
1090
+ }
1091
+ const next = cloneCalendarEvent(event);
1092
+ next.end = addMinutes(timed, normalizeSlot(this.options.slotDuration));
1093
+ return next.end > next.start ? next : null;
1094
+ }
1095
+ const day = this._dayAtPoint(x, y);
1096
+ if (!day) return null;
1097
+ const difference = calendarDayDifference(startOfCalendarDay(event.start), day);
1098
+ if (action === 'move') return shiftCalendarEvent(event, { days: difference, minutes: 0 });
1099
+ const next = cloneCalendarEvent(event);
1100
+ const endDay = addCalendarDays(day, 1);
1101
+ next.end = new Date(endDay.getTime());
1102
+ return next.end > next.start ? next : null;
1103
+ }
1104
+
1105
+ /** @param {number} x @param {number} y @returns {Date|null} */
1106
+ _timeAtPoint(x, y) {
1107
+ const slots = this.refs.body.querySelector('.zx-calendar__slots');
1108
+ if (!slots) return null;
1109
+ const rect = slots.getBoundingClientRect();
1110
+ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom || rect.width <= 0 || rect.height <= 0) return null;
1111
+ const columns = Number(slots.closest('[role="grid"]')?.getAttribute('aria-colcount')) || 1;
1112
+ const rows = Number(slots.closest('[role="grid"]')?.getAttribute('aria-rowcount')) || 1;
1113
+ const column = Math.max(0, Math.min(columns - 1, Math.floor((x - rect.left) / rect.width * columns)));
1114
+ const row = Math.max(0, Math.min(rows - 1, Math.floor((y - rect.top) / rect.height * rows)));
1115
+ const target = slots.querySelector(`[data-row-index="${row}"][data-day-index="${column}"]`);
1116
+ return target ? this._dateFromTarget(target) : null;
1117
+ }
1118
+
1119
+ /** @param {number} x @param {number} y @returns {Date|null} */
1120
+ _dayAtPoint(x, y) {
1121
+ const elements = typeof document.elementsFromPoint === 'function' ? document.elementsFromPoint(x, y) : [];
1122
+ for (const element of elements) {
1123
+ const date = element instanceof HTMLElement ? element.dataset.date : null;
1124
+ if (date) return parseDayKey(date);
1125
+ }
1126
+ const allDay = this.refs.body.querySelector('.zx-calendar__all-day-grid');
1127
+ if (allDay) {
1128
+ const rect = allDay.getBoundingClientRect();
1129
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
1130
+ const cells = allDay.querySelectorAll('[data-date]');
1131
+ const index = Math.max(0, Math.min(cells.length - 1, Math.floor((x - rect.left) / rect.width * cells.length)));
1132
+ return parseDayKey(cells[index]?.dataset.date);
1133
+ }
1134
+ }
1135
+ return null;
1136
+ }
1137
+
1138
+ /** @param {Date} start @param {Date} end @returns {void} */
1139
+ _previewSelection(start, end) {
1140
+ const low = Math.min(start.getTime(), end.getTime());
1141
+ const high = Math.max(start.getTime(), end.getTime());
1142
+ for (const slot of this.refs.body.querySelectorAll('[data-slot]')) {
1143
+ const value = Number(slot.dataset.dateTime);
1144
+ slot.setAttribute('aria-selected', String(value >= low && value < high));
1145
+ }
1146
+ }
1147
+
1148
+ /** @returns {void} */
1149
+ _clearSelectionPreview() {
1150
+ for (const slot of this.refs.body.querySelectorAll('[data-slot][aria-selected]')) slot.removeAttribute('aria-selected');
1151
+ }
1152
+
1153
+ /** @returns {void} */
1154
+ _cancelKeyboardEdit() {
1155
+ const edit = this._keyboardEdit;
1156
+ if (!edit) return;
1157
+ edit.control.removeAttribute('aria-grabbed');
1158
+ edit.control.closest('.zx-calendar__event')?.removeAttribute('data-state');
1159
+ this._keyboardEdit = null;
1160
+ this._announce(this.msg('editCancelled'));
1161
+ this._renderView({ preserveFocus: true });
1162
+ }
1163
+
1164
+ /** @param {CalendarEvent} oldEvent @param {CalendarEvent} proposed @param {'move'|'resize'} action @param {Event} jsEvent @returns {void} */
1165
+ _commitMutation(oldEvent, proposed, action, jsEvent) {
1166
+ const changed = oldEvent.start.getTime() !== proposed.start.getTime()
1167
+ || oldEvent.end.getTime() !== proposed.end.getTime();
1168
+ if (!changed) {
1169
+ this._renderView({ preserveFocus: true });
1170
+ return;
1171
+ }
1172
+ const key = String(oldEvent.id);
1173
+ const version = ++this._mutationSerial;
1174
+ let reverted = false;
1175
+ if (this.options.optimistic) {
1176
+ this._replaceNormalizedEvent(proposed);
1177
+ this._mutationVersions.set(key, version);
1178
+ this._renderView({ preserveFocus: true });
1179
+ } else {
1180
+ this._renderView({ preserveFocus: true });
1181
+ }
1182
+ const revert = () => {
1183
+ if (reverted || !this.options.optimistic || this._mutationVersions.get(key) !== version) return false;
1184
+ const current = this._eventById(key);
1185
+ if (!current || current.start.getTime() !== proposed.start.getTime()
1186
+ || current.end.getTime() !== proposed.end.getTime()) return false;
1187
+ reverted = true;
1188
+ this._replaceNormalizedEvent(oldEvent);
1189
+ this._mutationVersions.delete(key);
1190
+ this._renderView({ preserveFocus: true });
1191
+ this._announce(this.msg('editCancelled'));
1192
+ return true;
1193
+ };
1194
+ const detail = {
1195
+ event: cloneCalendarEvent(proposed),
1196
+ oldEvent: cloneCalendarEvent(oldEvent),
1197
+ action,
1198
+ delta: calendarDelta(oldEvent, proposed),
1199
+ jsEvent,
1200
+ revert
1201
+ };
1202
+ const emitted = this.emit('eventchange', detail);
1203
+ if (emitted.defaultPrevented) revert();
1204
+ else this._announce(this.msg('editCommitted', proposed.title));
1205
+ }
1206
+
1207
+ /** @param {CalendarEvent} event @returns {void} */
1208
+ _replaceNormalizedEvent(event) {
1209
+ const key = String(event.id);
1210
+ this._events = this._events.map((current) => String(current.id) === key ? cloneCalendarEvent(event) : current);
1211
+ }
1212
+
1213
+ /** @param {CalendarEvent} event @returns {boolean} */
1214
+ _canMove(event) {
1215
+ if (this.options.disabled) return false;
1216
+ if (event.editable != null) return event.editable;
1217
+ if (this.options.eventStartEditable != null) return Boolean(this.options.eventStartEditable);
1218
+ return Boolean(this.options.editable);
1219
+ }
1220
+
1221
+ /** @param {CalendarEvent} event @returns {boolean} */
1222
+ _canResize(event) {
1223
+ if (this.options.disabled) return false;
1224
+ if (event.durationEditable != null) return event.durationEditable;
1225
+ if (this.options.eventDurationEditable != null) return Boolean(this.options.eventDurationEditable);
1226
+ return Boolean(this.options.editable);
1227
+ }
1228
+
1229
+ /** @param {string|number} id @returns {CalendarEvent|null} */
1230
+ _eventById(id) {
1231
+ const index = this._eventIndex(id);
1232
+ return index < 0 ? null : this._events[index];
1233
+ }
1234
+
1235
+ /** @param {string|number} id @returns {number} */
1236
+ _eventIndex(id) {
1237
+ const key = String(id);
1238
+ return this._events.findIndex((event) => String(event.id) === key);
1239
+ }
1240
+
1241
+ /** @param {number} direction @returns {this} */
1242
+ _page(direction) {
1243
+ const date = new Date(this._date.getTime());
1244
+ if (this._view === 'day') date.setDate(date.getDate() + direction);
1245
+ else if (this._view === 'agenda') date.setDate(date.getDate() + direction * this.options.agendaDays);
1246
+ else if (this._view === 'week') date.setDate(date.getDate() + direction * 7);
1247
+ else if (this._view === 'month') date.setMonth(date.getMonth() + direction);
1248
+ else date.setFullYear(date.getFullYear() + direction);
1249
+ return this.setDate(date);
1250
+ }
1251
+
1252
+ /** @param {{start: Date, end: Date}} range @returns {string} */
1253
+ _rangeTitle(range) {
1254
+ if (this._view === 'day') return this._format(range.start, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
1255
+ if (this._view === 'month') return this._format(this._date, { month: 'long', year: 'numeric' });
1256
+ if (this._view === 'year') return this._format(this._date, { year: 'numeric' });
1257
+ const last = addCalendarDays(range.end, -1);
1258
+ const formatter = new Intl.DateTimeFormat(this.options.locale, {
1259
+ day: 'numeric', month: 'long', year: 'numeric'
1260
+ });
1261
+ if (typeof formatter.formatRange === 'function') return formatter.formatRange(range.start, last);
1262
+ if (range.start.getFullYear() !== last.getFullYear()) {
1263
+ return `${this._format(range.start, { day: 'numeric', month: 'short', year: 'numeric' })} – ${this._format(last, { day: 'numeric', month: 'short', year: 'numeric' })}`;
1264
+ }
1265
+ if (range.start.getMonth() !== last.getMonth()) {
1266
+ return `${this._format(range.start, { day: 'numeric', month: 'short' })} – ${this._format(last, { day: 'numeric', month: 'short', year: 'numeric' })}`;
1267
+ }
1268
+ return `${this._format(range.start, { day: 'numeric' })}–${this._format(last, { day: 'numeric', month: 'long', year: 'numeric' })}`;
1269
+ }
1270
+
1271
+ /** @param {CalendarEvent} event @param {Date} day @returns {string} */
1272
+ _eventTime(event, day) {
1273
+ if (event.allDay) return this.msg('allDay');
1274
+ if (calendarEventSpansDays(event)) {
1275
+ if (sameCalendarDay(event.start, day)) return `${formatDateTime(event.start, this.options.locale)} →`;
1276
+ if (sameCalendarDay(event.end, day)) return `→ ${formatDateTime(event.end, this.options.locale)}`;
1277
+ return this.msg('allDay');
1278
+ }
1279
+ const start = formatTimeDate(event.start, this.options.locale);
1280
+ if (event.end <= event.start) return start;
1281
+ return `${start}–${formatTimeDate(event.end, this.options.locale)}`;
1282
+ }
1283
+
1284
+ /** @param {CalendarEvent} event @returns {string} */
1285
+ _eventLabel(event) {
1286
+ const location = event.location ? `, ${event.location}` : '';
1287
+ return `${event.title}, ${formatDateTime(event.start, this.options.locale)} to ${formatDateTime(event.end, this.options.locale)}${location}`;
1288
+ }
1289
+
1290
+ /** @param {Date} date @returns {string} */
1291
+ _dateTimeLabel(date) {
1292
+ return formatDateTime(date, this.options.locale);
1293
+ }
1294
+
1295
+ /** @param {Date} date @param {Intl.DateTimeFormatOptions} options @returns {string} */
1296
+ _format(date, options) {
1297
+ return new Intl.DateTimeFormat(this.options.locale, options).format(date);
1298
+ }
1299
+
1300
+ /** @returns {Date} */
1301
+ _now() {
1302
+ const source = typeof this.options.now === 'function' ? this.options.now() : this.options.now;
1303
+ const date = source == null ? new Date() : source;
1304
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) throw new TypeError('Calendar now must return a valid Date');
1305
+ return new Date(date.getTime());
1306
+ }
1307
+
1308
+ /** @param {HTMLElement} target @returns {Date|null} */
1309
+ _dateFromTarget(target) {
1310
+ if (target.dataset.dateTime) {
1311
+ const date = new Date(Number(target.dataset.dateTime));
1312
+ return Number.isNaN(date.getTime()) ? null : date;
1313
+ }
1314
+ return parseDayKey(target.dataset.date);
1315
+ }
1316
+
1317
+ /** @param {HTMLElement} target @returns {void} */
1318
+ _rememberFocusedDate(target) {
1319
+ if (target.dataset.slot) this._focusedSlot = target.dataset.slot;
1320
+ }
1321
+
1322
+ /** @returns {{eventId?: string, eventAction?: string, date?: string, slot?: string}|null} */
1323
+ _focusIdentity() {
1324
+ const active = document.activeElement;
1325
+ if (!(active instanceof HTMLElement) || !this.el.contains(active)) return null;
1326
+ return {
1327
+ eventId: active.dataset.eventId,
1328
+ eventAction: active.dataset.eventAction,
1329
+ date: active.dataset.date,
1330
+ slot: active.dataset.slot
1331
+ };
1332
+ }
1333
+
1334
+ /** @param {{eventId?: string, eventAction?: string, date?: string, slot?: string}} identity @returns {void} */
1335
+ _restoreFocus(identity) {
1336
+ let target = null;
1337
+ if (identity.eventId) target = this.el.querySelector(
1338
+ `[data-event-id="${cssEscape(identity.eventId)}"][data-event-action="${identity.eventAction ?? 'activate'}"]`);
1339
+ if (!target && identity.slot) target = this.el.querySelector(`[data-slot="${cssEscape(identity.slot)}"]`);
1340
+ if (!target && identity.date) target = this.el.querySelector(`[data-date-cell][data-date="${cssEscape(identity.date)}"]`);
1341
+ target?.focus?.();
1342
+ }
1343
+
1344
+ /** @returns {void} */
1345
+ _scrollTimeGrid() {
1346
+ const scroller = this.refs.body.querySelector('.zx-calendar__time-scroller');
1347
+ if (!scroller) return;
1348
+ const min = normalizeMinute(this.options.slotMinTime, 0);
1349
+ const max = normalizeMinute(this.options.slotMaxTime, 1440, true);
1350
+ const scroll = normalizeMinute(this.options.scrollTime, 480, true);
1351
+ const ratio = Math.max(0, Math.min(1, (scroll - min) / (max - min)));
1352
+ requestAnimationFrame(() => {
1353
+ if (scroller.isConnected) scroller.scrollTop = Math.max(0, ratio * scroller.scrollHeight - scroller.clientHeight / 4);
1354
+ });
1355
+ }
1356
+
1357
+ /** @returns {void} */
1358
+ _refreshNowIndicator() {
1359
+ if (this._destroyed || !this.options.nowIndicator || !['day', 'week'].includes(this._view)) return;
1360
+ const line = this.refs.body.querySelector('.zx-calendar__now');
1361
+ const headings = [...this.refs.body.querySelectorAll('.zx-calendar__day-heading')];
1362
+ if (!line || !headings.length) return;
1363
+ const now = this._now();
1364
+ const dayIndex = headings.findIndex((heading) => heading.dataset.date === calendarDayKey(now));
1365
+ const min = normalizeMinute(this.options.slotMinTime, 0);
1366
+ const max = normalizeMinute(this.options.slotMaxTime, 1440, true);
1367
+ const minutes = now.getHours() * 60 + now.getMinutes();
1368
+ const visible = dayIndex >= 0 && minutes >= min && minutes <= max;
1369
+ line.hidden = !visible;
1370
+ if (!visible) return;
1371
+ line.style.insetBlockStart = `${((minutes - min) / (max - min)) * 100}%`;
1372
+ line.style.setProperty('--zx-calendar-now-day', String(dayIndex));
1373
+ line.querySelector('.zx-calendar__now-time').textContent = formatTimeMinutes(minutes, this.options.locale);
1374
+ }
1375
+
1376
+ /** @param {string} message @returns {void} */
1377
+ _announce(message) {
1378
+ this.refs.status.textContent = '';
1379
+ requestAnimationFrame(() => {
1380
+ if (!this._destroyed) this.refs.status.textContent = message;
1381
+ });
1382
+ }
1383
+ }
1384
+
1385
+ /** @param {unknown} value @returns {CalendarView[]} */
1386
+ function normalizeViews(value) {
1387
+ if (!Array.isArray(value) || value.length === 0) throw new TypeError('Calendar views must be a non-empty array');
1388
+ const views = [...new Set(value.map(String))];
1389
+ for (const view of views) if (!CALENDAR_VIEWS.includes(/** @type {CalendarView} */ (view))) {
1390
+ throw new RangeError(`Unknown Calendar view: ${view}`);
1391
+ }
1392
+ return /** @type {CalendarView[]} */ (views);
1393
+ }
1394
+
1395
+ /** @param {Date|string|number|null} value @param {'milliseconds'|'seconds'} unit @param {Date} fallback @returns {Date} */
1396
+ function normalizeAnchorDate(value, unit, fallback) {
1397
+ if (value == null) return fallback;
1398
+ if (value instanceof Date) {
1399
+ if (Number.isNaN(value.getTime())) throw new TypeError('Calendar date must be valid');
1400
+ return new Date(value.getTime());
1401
+ }
1402
+ const numeric = typeof value === 'number' ? value : typeof value === 'string' && /^-?\d+(?:\.\d+)?$/.test(value.trim()) ? Number(value) : null;
1403
+ const date = numeric == null ? new Date(value) : new Date(numeric * (unit === 'seconds' ? 1000 : 1));
1404
+ if (Number.isNaN(date.getTime())) throw new TypeError('Calendar date must be valid');
1405
+ return date;
1406
+ }
1407
+
1408
+ /** @param {unknown} value @returns {number} */
1409
+ function normalizeSlot(value) {
1410
+ const number = Number(value);
1411
+ if (!Number.isInteger(number) || number <= 0 || number > 720 || 1440 % number !== 0) {
1412
+ throw new RangeError('Calendar slotDuration must be a positive divisor of 1440');
1413
+ }
1414
+ return number;
1415
+ }
1416
+
1417
+ /** @param {unknown} value @returns {number} */
1418
+ function normalizeEventLimit(value) {
1419
+ const number = Number(value);
1420
+ if (!Number.isInteger(number) || number < 1 || number > 20) {
1421
+ throw new RangeError('Calendar eventLimit must be an integer from 1 through 20');
1422
+ }
1423
+ return number;
1424
+ }
1425
+
1426
+ /** @param {unknown} value @param {number} fallback @param {boolean} [allowEnd=false] @returns {number} */
1427
+ function normalizeMinute(value, fallback, allowEnd = false) {
1428
+ const number = Number(value ?? fallback);
1429
+ const max = allowEnd ? 1440 : 1439;
1430
+ if (!Number.isInteger(number) || number < 0 || number > max) throw new RangeError(`Calendar minute must be from 0 through ${max}`);
1431
+ return number;
1432
+ }
1433
+
1434
+ /** @param {Date} date @param {number} minutes @returns {Date} */
1435
+ function dateWithMinutes(date, minutes) {
1436
+ const result = startOfCalendarDay(date);
1437
+ result.setMinutes(minutes);
1438
+ return result;
1439
+ }
1440
+
1441
+ /** @param {Date} date @param {number} minutes @returns {Date} */
1442
+ function addMinutes(date, minutes) {
1443
+ const result = new Date(date.getTime());
1444
+ result.setMinutes(result.getMinutes() + minutes);
1445
+ return result;
1446
+ }
1447
+
1448
+ /** @param {Date} date @returns {string} */
1449
+ function dayTimestamp(date) {
1450
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
1451
+ }
1452
+
1453
+ /** @param {Date} left @param {Date} right @returns {boolean} */
1454
+ function sameCalendarDay(left, right) {
1455
+ return dayTimestamp(left) === dayTimestamp(right);
1456
+ }
1457
+
1458
+ /** @param {string|undefined} value @returns {Date|null} */
1459
+ function parseDayKey(value) {
1460
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value ?? ''));
1461
+ if (!match) return null;
1462
+ const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
1463
+ return calendarDayKey(date) === value ? date : null;
1464
+ }
1465
+
1466
+ /** @param {CalendarEvent} left @param {CalendarEvent} right @returns {number} */
1467
+ function compareEvents(left, right) {
1468
+ return left.start - right.start || right.end - left.end || left.title.localeCompare(right.title);
1469
+ }
1470
+
1471
+ /** @param {number} minutes @param {string|string[]|undefined} locale @returns {string} */
1472
+ function formatTimeMinutes(minutes, locale) {
1473
+ const date = new Date(2026, 0, 1);
1474
+ date.setHours(Math.floor(minutes / 60) % 24, minutes % 60, 0, 0);
1475
+ return formatTimeDate(date, locale);
1476
+ }
1477
+
1478
+ /** @param {Date} date @param {string|string[]|undefined} locale @returns {string} */
1479
+ function formatTimeDate(date, locale) {
1480
+ return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date);
1481
+ }
1482
+
1483
+ /** @param {Date} date @param {string|string[]|undefined} locale @returns {string} */
1484
+ function formatDateTime(date, locale) {
1485
+ return new Intl.DateTimeFormat(locale, {
1486
+ weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit'
1487
+ }).format(date);
1488
+ }
1489
+
1490
+ /** @param {Date} date @returns {number} */
1491
+ function isoWeek(date) {
1492
+ const copy = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
1493
+ const weekday = copy.getUTCDay() || 7;
1494
+ copy.setUTCDate(copy.getUTCDate() + 4 - weekday);
1495
+ const yearStart = new Date(Date.UTC(copy.getUTCFullYear(), 0, 1));
1496
+ return Math.ceil((((copy - yearStart) / 86_400_000) + 1) / 7);
1497
+ }
1498
+
1499
+ /** @param {CalendarEvent} oldEvent @param {CalendarEvent} event @returns {{milliseconds: number, days: number, minutes: number, endMilliseconds: number}} */
1500
+ function calendarDelta(oldEvent, event) {
1501
+ const milliseconds = event.start.getTime() - oldEvent.start.getTime();
1502
+ return {
1503
+ milliseconds,
1504
+ days: calendarDayDifference(startOfCalendarDay(oldEvent.start), startOfCalendarDay(event.start)),
1505
+ minutes: Math.round(milliseconds / 60_000),
1506
+ endMilliseconds: event.end.getTime() - oldEvent.end.getTime()
1507
+ };
1508
+ }
1509
+
1510
+ /** @param {string} value @returns {string} */
1511
+ function cssEscape(value) {
1512
+ return globalThis.CSS?.escape ? CSS.escape(value) : value.replace(/[^a-zA-Z0-9_-]/g, (character) => `\\${character}`);
1513
+ }
1514
+
1515
+ /** @event Calendar#viewchange @type {CustomEvent<{view: CalendarView, oldView: CalendarView}>} */
1516
+ /** @event Calendar#dateschange @type {CustomEvent<{view: CalendarView, start: Date, end: Date}>} */
1517
+ /** @event Calendar#eventclick @type {CustomEvent<{event: CalendarEvent, jsEvent: Event}>} */
1518
+ /** @event Calendar#eventdblclick @type {CustomEvent<{event: CalendarEvent, jsEvent: Event}>} */
1519
+ /** @event Calendar#dateclick @type {CustomEvent<{date: Date, allDay: boolean, view: CalendarView, jsEvent: Event}>} */
1520
+ /** @event Calendar#select @type {CustomEvent<{start: Date, end: Date, allDay: boolean, view: CalendarView, jsEvent: Event}>} */
1521
+ /** @event Calendar#eventchange @type {CustomEvent<CalendarEventChangeDetail>} */
1522
+ /** @event Calendar#eventschange @type {CustomEvent<{events: CalendarEvent[]}>} */
1523
+ /** @event Calendar#new @type {CustomEvent<{date: Date, view: CalendarView, jsEvent: Event}>} */
1524
+ /** @event Calendar#moreclick @type {CustomEvent<{date: Date, count: number, jsEvent: Event}>} */