@zeyos/zx 4.1.1 → 4.3.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.
- package/CHANGELOG.md +81 -0
- package/DESIGN-SYSTEM.md +33 -3
- package/README.md +22 -5
- package/dist/types/components/app-icon/app-icon.d.ts +8 -1
- package/dist/types/components/aurora/aurora.d.ts +72 -0
- package/dist/types/components/calendar/calendar-model.d.ts +202 -0
- package/dist/types/components/calendar/calendar.d.ts +517 -0
- package/dist/types/components/card-view/card-view.d.ts +303 -0
- package/dist/types/components/card-view/record-card.d.ts +242 -0
- package/dist/types/components/dialog/dialog.d.ts +6 -1
- package/dist/types/components/dropdown/dropdown.d.ts +5 -0
- package/dist/types/components/kanban-view/kanban-view.d.ts +572 -0
- package/dist/types/components/launcher/launcher.d.ts +5 -0
- package/dist/types/components/message/message.d.ts +9 -1
- package/dist/types/components/modal/modal.d.ts +44 -7
- package/dist/types/components/sheet/sheet.d.ts +22 -12
- package/dist/types/components/table/table.d.ts +120 -31
- package/dist/types/components/table-view/table-view.d.ts +192 -0
- package/dist/types/components/view/record-view.d.ts +464 -0
- package/dist/types/core/overlay-host.d.ts +12 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/zeyos/calendar.d.ts +91 -0
- package/dist/types/zeyos/icons.d.ts +18 -2
- package/dist/types/zeyos/index.d.ts +4 -0
- package/dist/types/zeyos/legacy-saved-views.d.ts +66 -0
- package/dist/types/zeyos/saved-views.d.ts +331 -0
- package/dist/types/zeyos/view.d.ts +305 -0
- package/dist/zx-zeyos.esm.js +4742 -1470
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +2536 -471
- package/dist/zx.esm.js +5193 -223
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +5193 -223
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +1729 -182
- package/docs/llms.md +236 -32
- package/docs/llms.txt +16 -6
- package/package.json +1 -1
- package/src/components/account-menu/account-menu.css +1 -1
- package/src/components/app-icon/app-icon.css +77 -51
- package/src/components/app-icon/app-icon.js +28 -4
- package/src/components/app-rail/app-rail.css +12 -3
- package/src/components/app-sidebar/app-sidebar.css +11 -2
- package/src/components/aurora/aurora.css +143 -0
- package/src/components/aurora/aurora.js +146 -0
- package/src/components/avatar/avatar.css +1 -1
- package/src/components/button/button.css +2 -2
- package/src/components/calendar/calendar-model.js +402 -0
- package/src/components/calendar/calendar.css +609 -0
- package/src/components/calendar/calendar.js +1619 -0
- package/src/components/card/card.css +2 -2
- package/src/components/card-view/card-view.css +389 -0
- package/src/components/card-view/card-view.js +527 -0
- package/src/components/card-view/record-card.js +392 -0
- package/src/components/context-menu/context-menu.css +4 -4
- package/src/components/context-menu/context-menu.js +5 -1
- package/src/components/date-picker/date-picker.css +31 -12
- package/src/components/datebox/date-range-box.css +17 -2
- package/src/components/datebox/datebox.css +18 -3
- package/src/components/dialog/dialog.css +4 -3
- package/src/components/dialog/dialog.js +2 -1
- package/src/components/dropdown/dropdown.css +17 -3
- package/src/components/dropdown/dropdown.js +7 -2
- package/src/components/kanban-view/kanban-view.css +216 -0
- package/src/components/kanban-view/kanban-view.js +1228 -0
- package/src/components/launcher/launcher.css +24 -9
- package/src/components/launcher/launcher.js +9 -2
- package/src/components/master-panel/master-panel.css +14 -3
- package/src/components/menu-button/menu-button.css +4 -4
- package/src/components/message/message.css +13 -21
- package/src/components/message/message.js +17 -11
- package/src/components/modal/modal.css +19 -5
- package/src/components/modal/modal.js +99 -25
- package/src/components/navigation-bar/navigation-bar.css +17 -5
- package/src/components/panel/panel.css +10 -2
- package/src/components/select/select.css +26 -8
- package/src/components/sheet/sheet.css +28 -7
- package/src/components/sheet/sheet.js +43 -23
- package/src/components/table/table.css +83 -5
- package/src/components/table/table.js +322 -37
- package/src/components/table-view/table-view.css +18 -0
- package/src/components/table-view/table-view.js +347 -0
- package/src/components/tag-picker/tag-picker.css +18 -4
- package/src/components/tooltip/tooltip.css +17 -3
- package/src/components/tooltip/tooltip.js +4 -1
- package/src/components/view/record-view.css +114 -0
- package/src/components/view/record-view.js +770 -0
- package/src/core/overlay-host.js +23 -0
- package/src/index.js +15 -0
- package/src/zeyos/calendar.js +115 -0
- package/src/zeyos/icons.js +11 -4
- package/src/zeyos/index.js +13 -0
- package/src/zeyos/launcher.js +2 -2
- package/src/zeyos/legacy-saved-views.js +155 -0
- package/src/zeyos/saved-views.js +815 -0
- package/src/zeyos/table.js +3 -1
- package/src/zeyos/view.js +543 -0
- package/styles/icons.css +3 -10
- package/styles/tokens/dark.css +4 -0
- package/styles/tokens/semantic.css +142 -3
- package/styles/zx.css +6 -0
|
@@ -0,0 +1,1619 @@
|
|
|
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', {
|
|
456
|
+
class: 'zx-calendar__agenda-day',
|
|
457
|
+
role: 'row',
|
|
458
|
+
dataset: { date: calendarDayKey(day) }
|
|
459
|
+
},
|
|
460
|
+
h('button', {
|
|
461
|
+
type: 'button',
|
|
462
|
+
class: 'zx-calendar__agenda-heading',
|
|
463
|
+
role: 'rowheader',
|
|
464
|
+
dataset: { calendarAction: 'date', date: calendarDayKey(day), dateCell: 'true' },
|
|
465
|
+
ariaCurrent: sameCalendarDay(day, this._now()) ? 'date' : null,
|
|
466
|
+
disabled: this.options.disabled
|
|
467
|
+
}, this._format(day, { weekday: 'long', day: 'numeric', month: 'long' })));
|
|
468
|
+
const entries = h('div', { class: 'zx-calendar__agenda-events', role: 'gridcell' });
|
|
469
|
+
if (!events.length) {
|
|
470
|
+
entries.append(h('p', { class: 'zx-calendar__agenda-empty' }, this.msg('noEvents')));
|
|
471
|
+
} else {
|
|
472
|
+
for (const event of events) entries.append(this._eventElement(event, { variant: 'agenda', day }));
|
|
473
|
+
}
|
|
474
|
+
row.append(entries);
|
|
475
|
+
grid.append(row);
|
|
476
|
+
}
|
|
477
|
+
return grid;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** @param {{start: Date, end: Date}} range @returns {HTMLElement} */
|
|
481
|
+
_renderTimeGrid(range) {
|
|
482
|
+
const allDays = Array.from({ length: calendarDayDifference(range.start, range.end) }, (_, index) =>
|
|
483
|
+
addCalendarDays(range.start, index));
|
|
484
|
+
const days = this._view === 'week' && this.options.workweek
|
|
485
|
+
? allDays.filter((date) => date.getDay() > 0 && date.getDay() < 6) : allDays;
|
|
486
|
+
const min = normalizeMinute(this.options.slotMinTime, 0);
|
|
487
|
+
const max = normalizeMinute(this.options.slotMaxTime, 1440, true);
|
|
488
|
+
const slot = normalizeSlot(this.options.slotDuration);
|
|
489
|
+
if (max <= min) throw new RangeError('slotMaxTime must exceed slotMinTime');
|
|
490
|
+
const slotCount = Math.ceil((max - min) / slot);
|
|
491
|
+
const surface = h('div', {
|
|
492
|
+
class: 'zx-calendar__time-surface',
|
|
493
|
+
style: { '--zx-calendar-day-count': String(days.length) }
|
|
494
|
+
});
|
|
495
|
+
const header = h('div', { class: 'zx-calendar__time-header' },
|
|
496
|
+
h('span', { class: 'zx-calendar__time-corner', ariaHidden: 'true' }),
|
|
497
|
+
h('div', { class: 'zx-calendar__day-headings', role: 'row' }, days.map((day) => h('button', {
|
|
498
|
+
type: 'button',
|
|
499
|
+
class: 'zx-calendar__day-heading',
|
|
500
|
+
role: 'columnheader',
|
|
501
|
+
dataset: { calendarAction: 'date', date: calendarDayKey(day), dateCell: 'true' },
|
|
502
|
+
ariaCurrent: sameCalendarDay(day, this._now()) ? 'date' : null,
|
|
503
|
+
disabled: this.options.disabled
|
|
504
|
+
}, h('span', { class: 'zx-calendar__day-weekday' }, this._format(day, { weekday: 'short' })),
|
|
505
|
+
h('span', { class: 'zx-calendar__day-number' }, this._format(day, { day: 'numeric', month: 'short' }))))));
|
|
506
|
+
const allDay = this._renderAllDayRail(days);
|
|
507
|
+
const scroller = h('div', { ref: 'timeScroller', class: 'zx-calendar__time-scroller' });
|
|
508
|
+
const axis = h('div', { class: 'zx-calendar__time-axis', ariaHidden: 'true' });
|
|
509
|
+
const grid = h('div', {
|
|
510
|
+
class: 'zx-calendar__time-grid',
|
|
511
|
+
role: 'grid',
|
|
512
|
+
ariaLabel: this._rangeTitle(range),
|
|
513
|
+
ariaRowcount: String(slotCount),
|
|
514
|
+
ariaColcount: String(days.length),
|
|
515
|
+
style: { '--zx-calendar-slot-count': String(slotCount) }
|
|
516
|
+
});
|
|
517
|
+
const slots = h('div', { ref: 'slots', class: 'zx-calendar__slots' });
|
|
518
|
+
let firstTabStop = true;
|
|
519
|
+
for (let row = 0; row < slotCount; row += 1) {
|
|
520
|
+
const minutes = min + row * slot;
|
|
521
|
+
axis.append(h('span', { class: 'zx-calendar__time-label' }, minutes % 60 === 0 ? formatTimeMinutes(minutes, this.options.locale) : ''));
|
|
522
|
+
for (let column = 0; column < days.length; column += 1) {
|
|
523
|
+
const date = dateWithMinutes(days[column], minutes);
|
|
524
|
+
const key = String(date.getTime());
|
|
525
|
+
const tabbable = this._focusedSlot ? this._focusedSlot === key : firstTabStop;
|
|
526
|
+
if (tabbable) firstTabStop = false;
|
|
527
|
+
slots.append(h('button', {
|
|
528
|
+
type: 'button',
|
|
529
|
+
class: 'zx-calendar__slot',
|
|
530
|
+
role: 'gridcell',
|
|
531
|
+
tabindex: tabbable ? 0 : -1,
|
|
532
|
+
dataset: {
|
|
533
|
+
calendarAction: 'date',
|
|
534
|
+
slot: key,
|
|
535
|
+
dateTime: key,
|
|
536
|
+
dayIndex: String(column),
|
|
537
|
+
rowIndex: String(row),
|
|
538
|
+
major: minutes % 60 === 0 ? 'true' : null
|
|
539
|
+
},
|
|
540
|
+
ariaLabel: this._dateTimeLabel(date),
|
|
541
|
+
disabled: this.options.disabled
|
|
542
|
+
}));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const eventsLayer = h('div', { class: 'zx-calendar__timed-events', ariaHidden: 'false' });
|
|
546
|
+
days.forEach((day, dayIndex) => {
|
|
547
|
+
const layouts = layoutTimedCalendarEvents(this._events, day, {
|
|
548
|
+
minMinutes: min,
|
|
549
|
+
maxMinutes: max,
|
|
550
|
+
minimumMinutes: slot
|
|
551
|
+
});
|
|
552
|
+
for (const layout of layouts) {
|
|
553
|
+
const top = ((layout.startMinutes - min) / (max - min)) * 100;
|
|
554
|
+
const height = Math.max((slot / (max - min)) * 100,
|
|
555
|
+
((layout.endMinutes - layout.startMinutes) / (max - min)) * 100);
|
|
556
|
+
const dayWidth = 100 / days.length;
|
|
557
|
+
const width = dayWidth / layout.columns;
|
|
558
|
+
const left = dayIndex * dayWidth + layout.column * width;
|
|
559
|
+
const event = this._eventElement(layout.event, { variant: 'timed', day, resizable: true });
|
|
560
|
+
event.style.insetInlineStart = `${left}%`;
|
|
561
|
+
event.style.inlineSize = `${width}%`;
|
|
562
|
+
event.style.insetBlockStart = `${top}%`;
|
|
563
|
+
event.style.blockSize = `${height}%`;
|
|
564
|
+
eventsLayer.append(event);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
grid.append(slots, eventsLayer, this._nowLine(days, min, max));
|
|
568
|
+
scroller.append(axis, grid);
|
|
569
|
+
surface.append(header, allDay, scroller);
|
|
570
|
+
return surface;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** @param {Date[]} days @returns {HTMLElement} */
|
|
574
|
+
_renderAllDayRail(days) {
|
|
575
|
+
const rail = h('div', { class: 'zx-calendar__all-day' },
|
|
576
|
+
h('span', { class: 'zx-calendar__all-day-label' }, this.msg('allDay')));
|
|
577
|
+
const layer = h('div', {
|
|
578
|
+
class: 'zx-calendar__all-day-grid',
|
|
579
|
+
role: 'row',
|
|
580
|
+
style: { '--zx-calendar-day-count': String(days.length) }
|
|
581
|
+
});
|
|
582
|
+
for (const day of days) layer.append(h('span', {
|
|
583
|
+
class: 'zx-calendar__all-day-cell',
|
|
584
|
+
dataset: { date: calendarDayKey(day) },
|
|
585
|
+
ariaHidden: 'true'
|
|
586
|
+
}));
|
|
587
|
+
const spans = layoutCalendarSpans(this._events, days[0], days.length);
|
|
588
|
+
for (const span of spans) {
|
|
589
|
+
const event = this._eventElement(span.event, { variant: 'span', day: days[span.start] });
|
|
590
|
+
event.style.gridColumn = `${span.start + 1} / ${span.end + 1}`;
|
|
591
|
+
event.style.gridRow = String(span.lane + 1);
|
|
592
|
+
layer.append(event);
|
|
593
|
+
}
|
|
594
|
+
rail.append(layer);
|
|
595
|
+
return rail;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** @param {{start: Date, end: Date}} range @returns {HTMLElement} */
|
|
599
|
+
_renderMonth(range) {
|
|
600
|
+
const wrapper = h('div', { class: 'zx-calendar__month' });
|
|
601
|
+
const weekdays = h('div', { class: 'zx-calendar__month-weekdays', role: 'row' });
|
|
602
|
+
if (this.options.weekNumbers) weekdays.append(h('span', { class: 'zx-calendar__month-week-gutter', ariaHidden: 'true' }));
|
|
603
|
+
for (let offset = 0; offset < 7; offset += 1) {
|
|
604
|
+
const day = addCalendarDays(range.start, offset);
|
|
605
|
+
weekdays.append(h('span', { class: 'zx-calendar__month-weekday', role: 'columnheader' },
|
|
606
|
+
this._format(day, { weekday: 'short' })));
|
|
607
|
+
}
|
|
608
|
+
wrapper.append(weekdays);
|
|
609
|
+
const weekCount = calendarDayDifference(range.start, range.end) / 7;
|
|
610
|
+
for (let week = 0; week < weekCount; week += 1) {
|
|
611
|
+
const start = addCalendarDays(range.start, week * 7);
|
|
612
|
+
const spans = layoutCalendarSpans(this._events.map((event) => ({ ...event, allDay: true })), start, 7);
|
|
613
|
+
const eventLimit = normalizeEventLimit(this.options.eventLimit);
|
|
614
|
+
const visible = spans.filter((span) => span.lane < eventLimit);
|
|
615
|
+
const hidden = spans.filter((span) => span.lane >= eventLimit);
|
|
616
|
+
const row = h('div', {
|
|
617
|
+
class: 'zx-calendar__month-week',
|
|
618
|
+
role: 'row',
|
|
619
|
+
dataset: { weekStart: calendarDayKey(start) }
|
|
620
|
+
});
|
|
621
|
+
if (this.options.weekNumbers) row.append(h('button', {
|
|
622
|
+
type: 'button',
|
|
623
|
+
class: 'zx-calendar__month-week-number',
|
|
624
|
+
dataset: { calendarAction: 'week', date: calendarDayKey(start) },
|
|
625
|
+
ariaLabel: this.msg('weekNumber', isoWeek(start)),
|
|
626
|
+
disabled: this.options.disabled
|
|
627
|
+
}, String(isoWeek(start))));
|
|
628
|
+
const days = h('div', { class: 'zx-calendar__month-days' });
|
|
629
|
+
for (let offset = 0; offset < 7; offset += 1) {
|
|
630
|
+
const day = addCalendarDays(start, offset);
|
|
631
|
+
const count = this._events.filter((event) => calendarEventIntersects(event, day, addCalendarDays(day, 1))).length;
|
|
632
|
+
days.append(h('button', {
|
|
633
|
+
type: 'button',
|
|
634
|
+
class: 'zx-calendar__month-day',
|
|
635
|
+
role: 'gridcell',
|
|
636
|
+
dataset: {
|
|
637
|
+
calendarAction: 'date',
|
|
638
|
+
date: calendarDayKey(day),
|
|
639
|
+
dateCell: 'true',
|
|
640
|
+
otherMonth: day.getMonth() === this._date.getMonth() ? null : 'true',
|
|
641
|
+
eventCount: String(count)
|
|
642
|
+
},
|
|
643
|
+
ariaCurrent: sameCalendarDay(day, this._now()) ? 'date' : null,
|
|
644
|
+
ariaLabel: `${this._format(day, { dateStyle: 'full' })}, ${count} ${count === 1 ? 'event' : 'events'}`,
|
|
645
|
+
disabled: this.options.disabled
|
|
646
|
+
}, this._format(day, { day: 'numeric' })));
|
|
647
|
+
}
|
|
648
|
+
const lanes = h('div', { class: 'zx-calendar__month-events' });
|
|
649
|
+
for (const span of visible) {
|
|
650
|
+
const event = this._eventElement(span.event, { variant: 'month', day: addCalendarDays(start, span.start) });
|
|
651
|
+
event.style.gridColumn = `${span.start + 1} / ${span.end + 1}`;
|
|
652
|
+
event.style.gridRow = String(span.lane + 1);
|
|
653
|
+
lanes.append(event);
|
|
654
|
+
}
|
|
655
|
+
const hiddenByDay = Array.from({ length: 7 }, (_, index) => hidden.filter((span) => span.start <= index && span.end > index).length);
|
|
656
|
+
for (let index = 0; index < hiddenByDay.length; index += 1) {
|
|
657
|
+
const count = hiddenByDay[index];
|
|
658
|
+
if (!count) continue;
|
|
659
|
+
const day = addCalendarDays(start, index);
|
|
660
|
+
lanes.append(h('button', {
|
|
661
|
+
type: 'button',
|
|
662
|
+
class: 'zx-calendar__more',
|
|
663
|
+
dataset: { calendarAction: 'more', date: calendarDayKey(day), count: String(count) },
|
|
664
|
+
style: { gridColumn: String(index + 1), gridRow: String(eventLimit + 1) },
|
|
665
|
+
disabled: this.options.disabled
|
|
666
|
+
}, this.msg('more', count)));
|
|
667
|
+
}
|
|
668
|
+
row.append(days, lanes);
|
|
669
|
+
wrapper.append(row);
|
|
670
|
+
}
|
|
671
|
+
wrapper.setAttribute('role', 'grid');
|
|
672
|
+
wrapper.setAttribute('aria-label', this._rangeTitle(range));
|
|
673
|
+
return wrapper;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** @param {{start: Date, end: Date}} range @returns {HTMLElement} */
|
|
677
|
+
_renderYear(range) {
|
|
678
|
+
const year = range.start.getFullYear();
|
|
679
|
+
const surface = h('div', { class: 'zx-calendar__year', ariaLabel: String(year) });
|
|
680
|
+
for (let month = 0; month < 12; month += 1) {
|
|
681
|
+
const first = new Date(year, month, 1);
|
|
682
|
+
const offset = (first.getDay() - this.options.weekStart + 7) % 7;
|
|
683
|
+
const start = addCalendarDays(first, -offset);
|
|
684
|
+
const grid = h('div', { class: 'zx-calendar__year-grid', role: 'grid', ariaLabel: this._format(first, { month: 'long' }) },
|
|
685
|
+
h('h3', { class: 'zx-calendar__year-heading' }, this._format(first, { month: 'long' })));
|
|
686
|
+
for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) {
|
|
687
|
+
const weekday = addCalendarDays(start, dayIndex);
|
|
688
|
+
grid.append(h('span', { class: 'zx-calendar__year-weekday', role: 'columnheader' },
|
|
689
|
+
this._format(weekday, { weekday: 'narrow' })));
|
|
690
|
+
}
|
|
691
|
+
for (let index = 0; index < 42; index += 1) {
|
|
692
|
+
const day = addCalendarDays(start, index);
|
|
693
|
+
if (day.getMonth() !== month) {
|
|
694
|
+
grid.append(h('span', { class: 'zx-calendar__year-blank', role: 'gridcell', ariaHidden: 'true' }));
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const count = this._events.filter((event) => calendarEventIntersects(event, day, addCalendarDays(day, 1))).length;
|
|
698
|
+
grid.append(h('button', {
|
|
699
|
+
type: 'button',
|
|
700
|
+
class: 'zx-calendar__year-day',
|
|
701
|
+
role: 'gridcell',
|
|
702
|
+
dataset: {
|
|
703
|
+
calendarAction: 'date',
|
|
704
|
+
date: calendarDayKey(day),
|
|
705
|
+
dateCell: 'true',
|
|
706
|
+
activity: String(Math.min(3, count))
|
|
707
|
+
},
|
|
708
|
+
ariaCurrent: sameCalendarDay(day, this._now()) ? 'date' : null,
|
|
709
|
+
ariaLabel: `${this._format(day, { dateStyle: 'full' })}, ${count} ${count === 1 ? 'event' : 'events'}`,
|
|
710
|
+
disabled: this.options.disabled
|
|
711
|
+
}, String(day.getDate())));
|
|
712
|
+
}
|
|
713
|
+
surface.append(grid);
|
|
714
|
+
}
|
|
715
|
+
return surface;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* @param {CalendarEvent} event
|
|
720
|
+
* @param {{variant: 'agenda'|'timed'|'span'|'month', day: Date, resizable?: boolean}} context
|
|
721
|
+
* @returns {HTMLElement}
|
|
722
|
+
*/
|
|
723
|
+
_eventElement(event, context) {
|
|
724
|
+
const key = String(event.id);
|
|
725
|
+
const wrapper = h('div', {
|
|
726
|
+
class: 'zx-calendar__event',
|
|
727
|
+
dataset: {
|
|
728
|
+
eventId: key,
|
|
729
|
+
variant: context.variant,
|
|
730
|
+
allDay: event.allDay ? 'true' : null,
|
|
731
|
+
draggable: this._canMove(event) ? 'true' : null
|
|
732
|
+
},
|
|
733
|
+
style: event.color ? { '--zx-calendar-event-color': event.color } : null
|
|
734
|
+
});
|
|
735
|
+
const button = h('button', {
|
|
736
|
+
type: 'button',
|
|
737
|
+
class: 'zx-calendar__event-action',
|
|
738
|
+
dataset: { eventAction: 'activate', eventId: key },
|
|
739
|
+
ariaLabel: this._eventLabel(event),
|
|
740
|
+
disabled: this.options.disabled
|
|
741
|
+
});
|
|
742
|
+
const custom = typeof this.options.renderEvent === 'function'
|
|
743
|
+
? this.options.renderEvent(cloneCalendarEvent(event)) : null;
|
|
744
|
+
if (custom instanceof Node) button.append(custom);
|
|
745
|
+
else if (typeof custom === 'string') button.append(document.createTextNode(custom));
|
|
746
|
+
else {
|
|
747
|
+
const time = h('span', { class: 'zx-calendar__event-time' }, this._eventTime(event, context.day));
|
|
748
|
+
const title = h('span', { class: 'zx-calendar__event-title' }, event.title);
|
|
749
|
+
button.append(time, title);
|
|
750
|
+
if (event.location) button.append(h('span', { class: 'zx-calendar__event-location' }, event.location));
|
|
751
|
+
}
|
|
752
|
+
wrapper.append(button);
|
|
753
|
+
if (context.resizable && this._canResize(event)) {
|
|
754
|
+
wrapper.append(h('button', {
|
|
755
|
+
type: 'button',
|
|
756
|
+
class: 'zx-calendar__event-resize',
|
|
757
|
+
dataset: { eventAction: 'resize', eventId: key },
|
|
758
|
+
ariaLabel: this.msg('resize', event.title),
|
|
759
|
+
disabled: this.options.disabled
|
|
760
|
+
}, h('span', { ariaHidden: 'true' })));
|
|
761
|
+
}
|
|
762
|
+
return wrapper;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** @param {Date[]} days @param {number} min @param {number} max @returns {HTMLElement} */
|
|
766
|
+
_nowLine(days, min, max) {
|
|
767
|
+
const now = this._now();
|
|
768
|
+
const dayIndex = days.findIndex((day) => sameCalendarDay(day, now));
|
|
769
|
+
const minutes = now.getHours() * 60 + now.getMinutes();
|
|
770
|
+
const visible = this.options.nowIndicator && dayIndex >= 0 && minutes >= min && minutes <= max;
|
|
771
|
+
return h('div', {
|
|
772
|
+
ref: 'nowLine',
|
|
773
|
+
class: 'zx-calendar__now',
|
|
774
|
+
hidden: !visible,
|
|
775
|
+
ariaHidden: 'true',
|
|
776
|
+
style: visible ? {
|
|
777
|
+
insetBlockStart: `${((minutes - min) / (max - min)) * 100}%`,
|
|
778
|
+
'--zx-calendar-now-day': String(dayIndex)
|
|
779
|
+
} : null
|
|
780
|
+
}, h('span', { class: 'zx-calendar__now-time' }, formatTimeMinutes(minutes, this.options.locale)));
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** @param {Event} event @returns {void} */
|
|
784
|
+
_onClick(event) {
|
|
785
|
+
const target = event.target?.closest?.('button');
|
|
786
|
+
if (!target || !this.el.contains(target)) return;
|
|
787
|
+
const eventId = target.dataset.eventId;
|
|
788
|
+
if (eventId != null && this._suppressClickId === eventId) {
|
|
789
|
+
this._suppressClickId = null;
|
|
790
|
+
event.preventDefault();
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (target.dataset.eventAction === 'activate') {
|
|
794
|
+
const item = this._eventById(eventId);
|
|
795
|
+
if (item) this.emit('eventclick', { event: cloneCalendarEvent(item), jsEvent: event });
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (target.dataset.eventAction === 'resize') return;
|
|
799
|
+
const action = target.dataset.calendarAction;
|
|
800
|
+
if (!action) return;
|
|
801
|
+
if (action === 'previous') this.prev();
|
|
802
|
+
else if (action === 'next') this.next();
|
|
803
|
+
else if (action === 'today') this.today();
|
|
804
|
+
else if (action === 'view') this.setView(/** @type {CalendarView} */ (target.dataset.view));
|
|
805
|
+
else if (action === 'new') this.emit('new', { date: this.getDate(), view: this._view, jsEvent: event });
|
|
806
|
+
else if (action === 'week') {
|
|
807
|
+
const date = parseDayKey(target.dataset.date);
|
|
808
|
+
if (date) this.setDate(date).setView('week');
|
|
809
|
+
} else if (action === 'more') {
|
|
810
|
+
const date = parseDayKey(target.dataset.date);
|
|
811
|
+
if (!date) return;
|
|
812
|
+
const emitted = this.emit('moreclick', { date, count: Number(target.dataset.count), jsEvent: event });
|
|
813
|
+
if (!emitted.defaultPrevented && this._views.includes('agenda')) this.setDate(date).setView('agenda');
|
|
814
|
+
} else if (action === 'date') {
|
|
815
|
+
if (this._suppressDateClick) {
|
|
816
|
+
this._suppressDateClick = false;
|
|
817
|
+
event.preventDefault();
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const date = this._dateFromTarget(target);
|
|
821
|
+
if (!date) return;
|
|
822
|
+
this._rememberFocusedDate(target);
|
|
823
|
+
this.emit('dateclick', {
|
|
824
|
+
date,
|
|
825
|
+
allDay: !target.dataset.dateTime,
|
|
826
|
+
view: this._view,
|
|
827
|
+
jsEvent: event
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** @param {Event} event @returns {void} */
|
|
833
|
+
_onDoubleClick(event) {
|
|
834
|
+
const eventTarget = event.target?.closest?.('[data-event-action="activate"]');
|
|
835
|
+
if (eventTarget) {
|
|
836
|
+
const item = this._eventById(eventTarget.dataset.eventId);
|
|
837
|
+
if (item) this.emit('eventdblclick', { event: cloneCalendarEvent(item), jsEvent: event });
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const dateTarget = event.target?.closest?.('[data-calendar-action="date"]');
|
|
841
|
+
if (!dateTarget) return;
|
|
842
|
+
const date = this._dateFromTarget(dateTarget);
|
|
843
|
+
if (!date) return;
|
|
844
|
+
const allDay = !dateTarget.dataset.dateTime;
|
|
845
|
+
const end = allDay ? addCalendarDays(date, 1) : addMinutes(date, normalizeSlot(this.options.slotDuration));
|
|
846
|
+
this.emit('select', { start: date, end, allDay, view: this._view, jsEvent: event });
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** @param {KeyboardEvent} event @returns {void} */
|
|
850
|
+
_onKeydown(event) {
|
|
851
|
+
const eventControl = event.target?.closest?.('[data-event-action]');
|
|
852
|
+
if (eventControl) {
|
|
853
|
+
this._onEventKeydown(event, eventControl);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const slot = event.target?.closest?.('[data-slot]');
|
|
857
|
+
if (slot) {
|
|
858
|
+
this._onSlotKeydown(event, slot);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const day = event.target?.closest?.('[data-date-cell]');
|
|
862
|
+
if (day) this._onDayKeydown(event, day);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** @param {KeyboardEvent} event @param {HTMLElement} control @returns {void} */
|
|
866
|
+
_onEventKeydown(event, control) {
|
|
867
|
+
const item = this._eventById(control.dataset.eventId);
|
|
868
|
+
if (!item) return;
|
|
869
|
+
const resize = control.dataset.eventAction === 'resize';
|
|
870
|
+
if (event.key === 'Enter' && !this._keyboardEdit && !resize) {
|
|
871
|
+
event.preventDefault();
|
|
872
|
+
this.emit('eventclick', { event: cloneCalendarEvent(item), jsEvent: event });
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
if (event.key === ' ' && !this._keyboardEdit) {
|
|
876
|
+
if (resize ? !this._canResize(item) : !this._canMove(item)) return;
|
|
877
|
+
event.preventDefault();
|
|
878
|
+
this._keyboardEdit = {
|
|
879
|
+
oldEvent: cloneCalendarEvent(item),
|
|
880
|
+
event: cloneCalendarEvent(item),
|
|
881
|
+
action: resize ? 'resize' : 'move',
|
|
882
|
+
control
|
|
883
|
+
};
|
|
884
|
+
control.setAttribute('aria-grabbed', 'true');
|
|
885
|
+
control.closest('.zx-calendar__event')?.setAttribute('data-state', 'grabbed');
|
|
886
|
+
this._announce(this.msg(resize ? 'resizeStarted' : 'moveStarted', item.title));
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if (!this._keyboardEdit || String(this._keyboardEdit.event.id) !== String(item.id)) return;
|
|
890
|
+
if (event.key === 'Escape') {
|
|
891
|
+
event.preventDefault();
|
|
892
|
+
this._cancelKeyboardEdit();
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (event.key === ' ') {
|
|
896
|
+
event.preventDefault();
|
|
897
|
+
const edit = this._keyboardEdit;
|
|
898
|
+
this._keyboardEdit = null;
|
|
899
|
+
edit.control.removeAttribute('aria-grabbed');
|
|
900
|
+
this._commitMutation(edit.oldEvent, edit.event, edit.action, event);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
let days = 0;
|
|
904
|
+
let minutes = 0;
|
|
905
|
+
const timed = !calendarEventSpansDays(item) && (this._view === 'day' || this._view === 'week');
|
|
906
|
+
if (event.key === 'ArrowLeft') days = -1;
|
|
907
|
+
else if (event.key === 'ArrowRight') days = 1;
|
|
908
|
+
else if (event.key === 'ArrowUp') timed ? minutes = -normalizeSlot(this.options.slotDuration) : days = -7;
|
|
909
|
+
else if (event.key === 'ArrowDown') timed ? minutes = normalizeSlot(this.options.slotDuration) : days = 7;
|
|
910
|
+
else return;
|
|
911
|
+
event.preventDefault();
|
|
912
|
+
const edit = this._keyboardEdit;
|
|
913
|
+
if (edit.action === 'move') edit.event = shiftCalendarEvent(edit.event, { days, minutes });
|
|
914
|
+
else {
|
|
915
|
+
const proposed = shiftCalendarEvent(edit.event, { days: 0, minutes: 0, endDays: days, endMinutes: minutes });
|
|
916
|
+
if (proposed.end > proposed.start) edit.event = proposed;
|
|
917
|
+
}
|
|
918
|
+
edit.control.closest('.zx-calendar__event')?.setAttribute('data-preview', this._eventTime(edit.event, edit.event.start));
|
|
919
|
+
this._announce(this.msg('editPreview', edit.event.title,
|
|
920
|
+
this._dateTimeLabel(edit.event.start), this._dateTimeLabel(edit.event.end)));
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** @param {KeyboardEvent} event @param {HTMLElement} slot @returns {void} */
|
|
924
|
+
_onSlotKeydown(event, slot) {
|
|
925
|
+
const row = Number(slot.dataset.rowIndex);
|
|
926
|
+
const column = Number(slot.dataset.dayIndex);
|
|
927
|
+
const grid = slot.closest('[role="grid"]');
|
|
928
|
+
const columns = Number(grid?.getAttribute('aria-colcount')) || 1;
|
|
929
|
+
const rows = Number(grid?.getAttribute('aria-rowcount')) || 1;
|
|
930
|
+
let nextRow = row;
|
|
931
|
+
let nextColumn = column;
|
|
932
|
+
if (event.key === 'ArrowLeft') nextColumn -= 1;
|
|
933
|
+
else if (event.key === 'ArrowRight') nextColumn += 1;
|
|
934
|
+
else if (event.key === 'ArrowUp') nextRow -= 1;
|
|
935
|
+
else if (event.key === 'ArrowDown') nextRow += 1;
|
|
936
|
+
else if (event.key === 'Home') nextColumn = 0;
|
|
937
|
+
else if (event.key === 'End') nextColumn = columns - 1;
|
|
938
|
+
else if (event.key === 'PageUp') {
|
|
939
|
+
event.preventDefault();
|
|
940
|
+
this.prev();
|
|
941
|
+
return;
|
|
942
|
+
} else if (event.key === 'PageDown') {
|
|
943
|
+
event.preventDefault();
|
|
944
|
+
this.next();
|
|
945
|
+
return;
|
|
946
|
+
} else if (event.key === 'Enter' || event.key === ' ') {
|
|
947
|
+
event.preventDefault();
|
|
948
|
+
const date = this._dateFromTarget(slot);
|
|
949
|
+
if (date) this.emit('dateclick', { date, allDay: false, view: this._view, jsEvent: event });
|
|
950
|
+
return;
|
|
951
|
+
} else return;
|
|
952
|
+
event.preventDefault();
|
|
953
|
+
nextRow = Math.max(0, Math.min(rows - 1, nextRow));
|
|
954
|
+
nextColumn = Math.max(0, Math.min(columns - 1, nextColumn));
|
|
955
|
+
const next = grid?.querySelector(`[data-row-index="${nextRow}"][data-day-index="${nextColumn}"]`);
|
|
956
|
+
if (next) {
|
|
957
|
+
slot.tabIndex = -1;
|
|
958
|
+
next.tabIndex = 0;
|
|
959
|
+
this._focusedSlot = next.dataset.slot;
|
|
960
|
+
next.focus();
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** @param {KeyboardEvent} event @param {HTMLElement} day @returns {void} */
|
|
965
|
+
_onDayKeydown(event, day) {
|
|
966
|
+
const date = parseDayKey(day.dataset.date);
|
|
967
|
+
if (!date) return;
|
|
968
|
+
let amount = 0;
|
|
969
|
+
if (event.key === 'ArrowLeft') amount = -1;
|
|
970
|
+
else if (event.key === 'ArrowRight') amount = 1;
|
|
971
|
+
else if (event.key === 'ArrowUp') amount = -7;
|
|
972
|
+
else if (event.key === 'ArrowDown') amount = 7;
|
|
973
|
+
else if (event.key === 'Home') amount = -((date.getDay() - this.options.weekStart + 7) % 7);
|
|
974
|
+
else if (event.key === 'End') amount = 6 - ((date.getDay() - this.options.weekStart + 7) % 7);
|
|
975
|
+
else if (event.key === 'PageUp') {
|
|
976
|
+
event.preventDefault();
|
|
977
|
+
this.prev();
|
|
978
|
+
return;
|
|
979
|
+
} else if (event.key === 'PageDown') {
|
|
980
|
+
event.preventDefault();
|
|
981
|
+
this.next();
|
|
982
|
+
return;
|
|
983
|
+
} else return;
|
|
984
|
+
event.preventDefault();
|
|
985
|
+
const targetDate = addCalendarDays(date, amount);
|
|
986
|
+
const target = this.el.querySelector(`[data-date-cell][data-date="${calendarDayKey(targetDate)}"]`);
|
|
987
|
+
if (target) target.focus();
|
|
988
|
+
else this.setDate(targetDate);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** @param {PointerEvent} event @returns {void} */
|
|
992
|
+
_onPointerDown(event) {
|
|
993
|
+
if (this.options.disabled || event.button !== 0) return;
|
|
994
|
+
const control = event.target?.closest?.('[data-event-action]');
|
|
995
|
+
if (control) {
|
|
996
|
+
const item = this._eventById(control.dataset.eventId);
|
|
997
|
+
const action = control.dataset.eventAction === 'resize' ? 'resize' : 'move';
|
|
998
|
+
if (!item || (action === 'move' ? !this._canMove(item) : !this._canResize(item))) return;
|
|
999
|
+
event.preventDefault();
|
|
1000
|
+
this._capturePointer(event.pointerId);
|
|
1001
|
+
const offset = action === 'move' ? this._dragOffsetForEvent(item, event.clientX, event.clientY) : null;
|
|
1002
|
+
this._pointer = {
|
|
1003
|
+
type: 'event',
|
|
1004
|
+
pointerId: event.pointerId,
|
|
1005
|
+
startX: event.clientX,
|
|
1006
|
+
startY: event.clientY,
|
|
1007
|
+
oldEvent: cloneCalendarEvent(item),
|
|
1008
|
+
event: cloneCalendarEvent(item),
|
|
1009
|
+
action,
|
|
1010
|
+
control,
|
|
1011
|
+
timeOffsetMinutes: offset?.minutes ?? 0,
|
|
1012
|
+
dayOffset: offset?.days ?? 0,
|
|
1013
|
+
moved: false
|
|
1014
|
+
};
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const slot = event.target?.closest?.('[data-slot]');
|
|
1018
|
+
if (!slot || !this.options.selectable) return;
|
|
1019
|
+
const date = this._dateFromTarget(slot);
|
|
1020
|
+
if (!date) return;
|
|
1021
|
+
event.preventDefault();
|
|
1022
|
+
this._capturePointer(event.pointerId);
|
|
1023
|
+
this._pointer = {
|
|
1024
|
+
type: 'selection',
|
|
1025
|
+
pointerId: event.pointerId,
|
|
1026
|
+
startX: event.clientX,
|
|
1027
|
+
startY: event.clientY,
|
|
1028
|
+
start: date,
|
|
1029
|
+
end: addMinutes(date, normalizeSlot(this.options.slotDuration)),
|
|
1030
|
+
control: slot,
|
|
1031
|
+
moved: false
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** @param {PointerEvent} event @returns {void} */
|
|
1036
|
+
_onPointerMove(event) {
|
|
1037
|
+
const pointer = this._pointer;
|
|
1038
|
+
if (!pointer || pointer.pointerId !== event.pointerId) return;
|
|
1039
|
+
if (!pointer.moved && Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY) < 5) return;
|
|
1040
|
+
pointer.moved = true;
|
|
1041
|
+
event.preventDefault();
|
|
1042
|
+
if (pointer.type === 'selection') {
|
|
1043
|
+
const date = this._timeAtPoint(event.clientX, event.clientY);
|
|
1044
|
+
if (!date) return;
|
|
1045
|
+
const slot = normalizeSlot(this.options.slotDuration);
|
|
1046
|
+
pointer.end = addMinutes(date, slot);
|
|
1047
|
+
this._previewSelection(pointer.start, pointer.end);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const proposed = this._proposalAtPoint(pointer.oldEvent, pointer.action, event.clientX, event.clientY);
|
|
1051
|
+
if (!proposed) return;
|
|
1052
|
+
pointer.event = proposed;
|
|
1053
|
+
const wrapper = pointer.control.closest('.zx-calendar__event');
|
|
1054
|
+
if (wrapper) {
|
|
1055
|
+
wrapper.dataset.state = 'dragging';
|
|
1056
|
+
if (pointer.action === 'resize') {
|
|
1057
|
+
wrapper.style.transform = '';
|
|
1058
|
+
const height = this._timedPreviewHeight(proposed);
|
|
1059
|
+
if (height) wrapper.style.blockSize = height;
|
|
1060
|
+
} else {
|
|
1061
|
+
wrapper.style.transform = `translate(${event.clientX - pointer.startX}px, ${event.clientY - pointer.startY}px)`;
|
|
1062
|
+
}
|
|
1063
|
+
wrapper.dataset.preview = this._eventTime(proposed, proposed.start);
|
|
1064
|
+
}
|
|
1065
|
+
this._announce(this.msg('editPreview', proposed.title,
|
|
1066
|
+
this._dateTimeLabel(proposed.start), this._dateTimeLabel(proposed.end)));
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** @param {PointerEvent} event @returns {void} */
|
|
1070
|
+
_onPointerUp(event) {
|
|
1071
|
+
const pointer = this._pointer;
|
|
1072
|
+
if (!pointer || pointer.pointerId !== event.pointerId) return;
|
|
1073
|
+
this._pointer = null;
|
|
1074
|
+
this._releasePointerCapture(pointer.pointerId);
|
|
1075
|
+
this._clearSelectionPreview();
|
|
1076
|
+
if (!pointer.moved) return;
|
|
1077
|
+
event.preventDefault();
|
|
1078
|
+
if (pointer.type === 'selection') {
|
|
1079
|
+
this._suppressDateClick = true;
|
|
1080
|
+
const start = pointer.start < pointer.end ? pointer.start : addMinutes(pointer.end, -normalizeSlot(this.options.slotDuration));
|
|
1081
|
+
const end = pointer.start < pointer.end ? pointer.end : addMinutes(pointer.start, normalizeSlot(this.options.slotDuration));
|
|
1082
|
+
this.emit('select', { start: new Date(start), end: new Date(end), allDay: false, view: this._view, jsEvent: event });
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
this._suppressClickId = String(pointer.oldEvent.id);
|
|
1086
|
+
this._commitMutation(pointer.oldEvent, pointer.event, pointer.action, event);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/** @param {PointerEvent} event @returns {void} */
|
|
1090
|
+
_cancelPointer(event) {
|
|
1091
|
+
if (!this._pointer || this._pointer.pointerId !== event.pointerId) return;
|
|
1092
|
+
const control = this._pointer.control;
|
|
1093
|
+
this._releasePointerCapture(this._pointer.pointerId);
|
|
1094
|
+
this._pointer = null;
|
|
1095
|
+
this._clearSelectionPreview();
|
|
1096
|
+
control.closest?.('.zx-calendar__event')?.removeAttribute('data-state');
|
|
1097
|
+
this._renderView({ preserveFocus: true });
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** @param {CalendarEvent} event @param {'move'|'resize'} action @param {number} x @param {number} y @returns {CalendarEvent|null} */
|
|
1101
|
+
_proposalAtPoint(event, action, x, y) {
|
|
1102
|
+
const resizeTimed = action === 'resize' && !calendarEventSpansDays(event);
|
|
1103
|
+
const timed = this._timeAtPoint(x, y, resizeTimed ? event.start : null);
|
|
1104
|
+
if (timed && !calendarEventSpansDays(event)) {
|
|
1105
|
+
if (action === 'move') {
|
|
1106
|
+
const duration = event.end.getTime() - event.start.getTime();
|
|
1107
|
+
const next = cloneCalendarEvent(event);
|
|
1108
|
+
next.start = addMinutes(timed, -Number(this._pointer?.timeOffsetMinutes ?? 0));
|
|
1109
|
+
next.end = new Date(next.start.getTime() + duration);
|
|
1110
|
+
return next;
|
|
1111
|
+
}
|
|
1112
|
+
const next = cloneCalendarEvent(event);
|
|
1113
|
+
next.end = addMinutes(timed, normalizeSlot(this.options.slotDuration));
|
|
1114
|
+
return next.end > next.start ? next : null;
|
|
1115
|
+
}
|
|
1116
|
+
const day = this._dayAtPoint(x, y);
|
|
1117
|
+
if (!day) return null;
|
|
1118
|
+
const targetDay = addCalendarDays(day, -Number(this._pointer?.dayOffset ?? 0));
|
|
1119
|
+
const difference = calendarDayDifference(startOfCalendarDay(event.start), targetDay);
|
|
1120
|
+
if (action === 'move') return shiftCalendarEvent(event, { days: difference, minutes: 0 });
|
|
1121
|
+
const next = cloneCalendarEvent(event);
|
|
1122
|
+
const endDay = addCalendarDays(day, 1);
|
|
1123
|
+
next.end = new Date(endDay.getTime());
|
|
1124
|
+
return next.end > next.start ? next : null;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** @param {number} x @param {number} y @param {Date|null} [fixedDay=null] @returns {Date|null} */
|
|
1128
|
+
_timeAtPoint(x, y, fixedDay = null) {
|
|
1129
|
+
const slots = this.refs.body.querySelector('.zx-calendar__slots');
|
|
1130
|
+
if (!slots) return null;
|
|
1131
|
+
const rect = slots.getBoundingClientRect();
|
|
1132
|
+
const scroller = slots.closest('.zx-calendar__time-scroller');
|
|
1133
|
+
const viewport = scroller?.getBoundingClientRect();
|
|
1134
|
+
const left = viewport ? Math.max(rect.left, viewport.left) : rect.left;
|
|
1135
|
+
const right = viewport ? Math.min(rect.right, viewport.right) : rect.right;
|
|
1136
|
+
const top = viewport ? Math.max(rect.top, viewport.top) : rect.top;
|
|
1137
|
+
const bottom = viewport ? Math.min(rect.bottom, viewport.bottom) : rect.bottom;
|
|
1138
|
+
if (x < left || x > right || y < top || y > bottom || rect.width <= 0 || rect.height <= 0) return null;
|
|
1139
|
+
const columns = Number(slots.closest('[role="grid"]')?.getAttribute('aria-colcount')) || 1;
|
|
1140
|
+
const rows = Number(slots.closest('[role="grid"]')?.getAttribute('aria-rowcount')) || 1;
|
|
1141
|
+
const fixedColumn = fixedDay ? this._timeGridDayIndex(fixedDay) : -1;
|
|
1142
|
+
const column = fixedColumn >= 0 ? fixedColumn
|
|
1143
|
+
: Math.max(0, Math.min(columns - 1, Math.floor((x - rect.left) / rect.width * columns)));
|
|
1144
|
+
const row = Math.max(0, Math.min(rows - 1, Math.floor((y - rect.top) / rect.height * rows)));
|
|
1145
|
+
const target = slots.querySelector(`[data-row-index="${row}"][data-day-index="${column}"]`);
|
|
1146
|
+
return target ? this._dateFromTarget(target) : null;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** @param {number} x @param {number} y @returns {Date|null} */
|
|
1150
|
+
_dayAtPoint(x, y) {
|
|
1151
|
+
const allDay = this.refs.body.querySelector('.zx-calendar__all-day-grid');
|
|
1152
|
+
if (allDay) {
|
|
1153
|
+
const rect = allDay.getBoundingClientRect();
|
|
1154
|
+
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
|
|
1155
|
+
const cells = allDay.querySelectorAll('[data-date]');
|
|
1156
|
+
const index = Math.max(0, Math.min(cells.length - 1, Math.floor((x - rect.left) / rect.width * cells.length)));
|
|
1157
|
+
return parseDayKey(cells[index]?.dataset.date);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
for (const monthEvents of this.refs.body.querySelectorAll('.zx-calendar__month-events')) {
|
|
1161
|
+
const rect = monthEvents.getBoundingClientRect();
|
|
1162
|
+
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom || rect.width <= 0) continue;
|
|
1163
|
+
const start = parseDayKey(monthEvents.closest('.zx-calendar__month-week')?.dataset.weekStart);
|
|
1164
|
+
if (!start) continue;
|
|
1165
|
+
const index = Math.max(0, Math.min(6, Math.floor((x - rect.left) / rect.width * 7)));
|
|
1166
|
+
return addCalendarDays(start, index);
|
|
1167
|
+
}
|
|
1168
|
+
for (const agendaDay of this.refs.body.querySelectorAll('.zx-calendar__agenda-day')) {
|
|
1169
|
+
const rect = agendaDay.getBoundingClientRect();
|
|
1170
|
+
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) continue;
|
|
1171
|
+
return parseDayKey(agendaDay.dataset.date);
|
|
1172
|
+
}
|
|
1173
|
+
const elements = typeof document.elementsFromPoint === 'function' ? document.elementsFromPoint(x, y) : [];
|
|
1174
|
+
for (const element of elements) {
|
|
1175
|
+
const dateTarget = element instanceof HTMLElement ? element.closest('[data-date]') : null;
|
|
1176
|
+
const date = dateTarget instanceof HTMLElement ? dateTarget.dataset.date : null;
|
|
1177
|
+
if (date) return parseDayKey(date);
|
|
1178
|
+
}
|
|
1179
|
+
return null;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** @param {CalendarEvent} event @param {number} x @param {number} y @returns {{minutes: number, days: number}} */
|
|
1183
|
+
_dragOffsetForEvent(event, x, y) {
|
|
1184
|
+
if (!calendarEventSpansDays(event)) {
|
|
1185
|
+
const timed = this._timeAtPoint(x, y);
|
|
1186
|
+
if (timed) {
|
|
1187
|
+
const slot = normalizeSlot(this.options.slotDuration);
|
|
1188
|
+
const duration = Math.max(0, Math.round((event.end.getTime() - event.start.getTime()) / 60_000));
|
|
1189
|
+
const maxOffset = Math.max(0, duration - slot);
|
|
1190
|
+
const minutes = Math.round((timed.getTime() - event.start.getTime()) / 60_000);
|
|
1191
|
+
return { minutes: Math.max(0, Math.min(maxOffset, minutes)), days: 0 };
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
const day = this._dayAtPoint(x, y);
|
|
1195
|
+
const days = day ? calendarDayDifference(startOfCalendarDay(event.start), day) : 0;
|
|
1196
|
+
return { minutes: 0, days: Math.max(0, days) };
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** @param {Date} day @returns {number} */
|
|
1200
|
+
_timeGridDayIndex(day) {
|
|
1201
|
+
const key = calendarDayKey(day);
|
|
1202
|
+
const headings = [...this.refs.body.querySelectorAll('.zx-calendar__day-heading')];
|
|
1203
|
+
return headings.findIndex((heading) => heading.dataset.date === key);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** @param {CalendarEvent} event @returns {string|null} */
|
|
1207
|
+
_timedPreviewHeight(event) {
|
|
1208
|
+
const grid = this.refs.body.querySelector('.zx-calendar__time-grid');
|
|
1209
|
+
if (!grid || calendarEventSpansDays(event)) return null;
|
|
1210
|
+
const min = normalizeMinute(this.options.slotMinTime, 0);
|
|
1211
|
+
const max = normalizeMinute(this.options.slotMaxTime, 1440, true);
|
|
1212
|
+
const slot = normalizeSlot(this.options.slotDuration);
|
|
1213
|
+
const start = event.start.getHours() * 60 + event.start.getMinutes();
|
|
1214
|
+
const end = event.end.getHours() * 60 + event.end.getMinutes();
|
|
1215
|
+
const minutes = Math.max(slot, Math.min(max, end) - Math.max(min, start));
|
|
1216
|
+
return `${(minutes / (max - min)) * 100}%`;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** @param {number} pointerId @returns {void} */
|
|
1220
|
+
_capturePointer(pointerId) {
|
|
1221
|
+
try {
|
|
1222
|
+
this.el.setPointerCapture?.(pointerId);
|
|
1223
|
+
} catch {
|
|
1224
|
+
// Synthetic PointerEvents used by smoke tests do not create an active pointer capture target.
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** @param {number} pointerId @returns {void} */
|
|
1229
|
+
_releasePointerCapture(pointerId) {
|
|
1230
|
+
if (this.el.hasPointerCapture?.(pointerId)) this.el.releasePointerCapture?.(pointerId);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** @param {Date} start @param {Date} end @returns {void} */
|
|
1234
|
+
_previewSelection(start, end) {
|
|
1235
|
+
const low = Math.min(start.getTime(), end.getTime());
|
|
1236
|
+
const high = Math.max(start.getTime(), end.getTime());
|
|
1237
|
+
for (const slot of this.refs.body.querySelectorAll('[data-slot]')) {
|
|
1238
|
+
const value = Number(slot.dataset.dateTime);
|
|
1239
|
+
slot.setAttribute('aria-selected', String(value >= low && value < high));
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/** @returns {void} */
|
|
1244
|
+
_clearSelectionPreview() {
|
|
1245
|
+
for (const slot of this.refs.body.querySelectorAll('[data-slot][aria-selected]')) slot.removeAttribute('aria-selected');
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/** @returns {void} */
|
|
1249
|
+
_cancelKeyboardEdit() {
|
|
1250
|
+
const edit = this._keyboardEdit;
|
|
1251
|
+
if (!edit) return;
|
|
1252
|
+
edit.control.removeAttribute('aria-grabbed');
|
|
1253
|
+
edit.control.closest('.zx-calendar__event')?.removeAttribute('data-state');
|
|
1254
|
+
this._keyboardEdit = null;
|
|
1255
|
+
this._announce(this.msg('editCancelled'));
|
|
1256
|
+
this._renderView({ preserveFocus: true });
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** @param {CalendarEvent} oldEvent @param {CalendarEvent} proposed @param {'move'|'resize'} action @param {Event} jsEvent @returns {void} */
|
|
1260
|
+
_commitMutation(oldEvent, proposed, action, jsEvent) {
|
|
1261
|
+
const changed = oldEvent.start.getTime() !== proposed.start.getTime()
|
|
1262
|
+
|| oldEvent.end.getTime() !== proposed.end.getTime();
|
|
1263
|
+
if (!changed) {
|
|
1264
|
+
this._renderView({ preserveFocus: true });
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
const key = String(oldEvent.id);
|
|
1268
|
+
const version = ++this._mutationSerial;
|
|
1269
|
+
let reverted = false;
|
|
1270
|
+
if (this.options.optimistic) {
|
|
1271
|
+
this._replaceNormalizedEvent(proposed);
|
|
1272
|
+
this._mutationVersions.set(key, version);
|
|
1273
|
+
this._renderView({ preserveFocus: true });
|
|
1274
|
+
} else {
|
|
1275
|
+
this._renderView({ preserveFocus: true });
|
|
1276
|
+
}
|
|
1277
|
+
const revert = () => {
|
|
1278
|
+
if (reverted || !this.options.optimistic || this._mutationVersions.get(key) !== version) return false;
|
|
1279
|
+
const current = this._eventById(key);
|
|
1280
|
+
if (!current || current.start.getTime() !== proposed.start.getTime()
|
|
1281
|
+
|| current.end.getTime() !== proposed.end.getTime()) return false;
|
|
1282
|
+
reverted = true;
|
|
1283
|
+
this._replaceNormalizedEvent(oldEvent);
|
|
1284
|
+
this._mutationVersions.delete(key);
|
|
1285
|
+
this._renderView({ preserveFocus: true });
|
|
1286
|
+
this._announce(this.msg('editCancelled'));
|
|
1287
|
+
return true;
|
|
1288
|
+
};
|
|
1289
|
+
const detail = {
|
|
1290
|
+
event: cloneCalendarEvent(proposed),
|
|
1291
|
+
oldEvent: cloneCalendarEvent(oldEvent),
|
|
1292
|
+
action,
|
|
1293
|
+
delta: calendarDelta(oldEvent, proposed),
|
|
1294
|
+
jsEvent,
|
|
1295
|
+
revert
|
|
1296
|
+
};
|
|
1297
|
+
const emitted = this.emit('eventchange', detail);
|
|
1298
|
+
if (emitted.defaultPrevented) revert();
|
|
1299
|
+
else this._announce(this.msg('editCommitted', proposed.title));
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** @param {CalendarEvent} event @returns {void} */
|
|
1303
|
+
_replaceNormalizedEvent(event) {
|
|
1304
|
+
const key = String(event.id);
|
|
1305
|
+
this._events = this._events.map((current) => String(current.id) === key ? cloneCalendarEvent(event) : current);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/** @param {CalendarEvent} event @returns {boolean} */
|
|
1309
|
+
_canMove(event) {
|
|
1310
|
+
if (this.options.disabled) return false;
|
|
1311
|
+
if (event.editable != null) return event.editable;
|
|
1312
|
+
if (this.options.eventStartEditable != null) return Boolean(this.options.eventStartEditable);
|
|
1313
|
+
return Boolean(this.options.editable);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/** @param {CalendarEvent} event @returns {boolean} */
|
|
1317
|
+
_canResize(event) {
|
|
1318
|
+
if (this.options.disabled) return false;
|
|
1319
|
+
if (event.durationEditable != null) return event.durationEditable;
|
|
1320
|
+
if (this.options.eventDurationEditable != null) return Boolean(this.options.eventDurationEditable);
|
|
1321
|
+
return Boolean(this.options.editable);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/** @param {string|number} id @returns {CalendarEvent|null} */
|
|
1325
|
+
_eventById(id) {
|
|
1326
|
+
const index = this._eventIndex(id);
|
|
1327
|
+
return index < 0 ? null : this._events[index];
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** @param {string|number} id @returns {number} */
|
|
1331
|
+
_eventIndex(id) {
|
|
1332
|
+
const key = String(id);
|
|
1333
|
+
return this._events.findIndex((event) => String(event.id) === key);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** @param {number} direction @returns {this} */
|
|
1337
|
+
_page(direction) {
|
|
1338
|
+
const date = new Date(this._date.getTime());
|
|
1339
|
+
if (this._view === 'day') date.setDate(date.getDate() + direction);
|
|
1340
|
+
else if (this._view === 'agenda') date.setDate(date.getDate() + direction * this.options.agendaDays);
|
|
1341
|
+
else if (this._view === 'week') date.setDate(date.getDate() + direction * 7);
|
|
1342
|
+
else if (this._view === 'month') date.setMonth(date.getMonth() + direction);
|
|
1343
|
+
else date.setFullYear(date.getFullYear() + direction);
|
|
1344
|
+
return this.setDate(date);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** @param {{start: Date, end: Date}} range @returns {string} */
|
|
1348
|
+
_rangeTitle(range) {
|
|
1349
|
+
if (this._view === 'day') return this._format(range.start, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
|
1350
|
+
if (this._view === 'month') return this._format(this._date, { month: 'long', year: 'numeric' });
|
|
1351
|
+
if (this._view === 'year') return this._format(this._date, { year: 'numeric' });
|
|
1352
|
+
const last = addCalendarDays(range.end, -1);
|
|
1353
|
+
const formatter = new Intl.DateTimeFormat(this.options.locale, {
|
|
1354
|
+
day: 'numeric', month: 'long', year: 'numeric'
|
|
1355
|
+
});
|
|
1356
|
+
if (typeof formatter.formatRange === 'function') return formatter.formatRange(range.start, last);
|
|
1357
|
+
if (range.start.getFullYear() !== last.getFullYear()) {
|
|
1358
|
+
return `${this._format(range.start, { day: 'numeric', month: 'short', year: 'numeric' })} – ${this._format(last, { day: 'numeric', month: 'short', year: 'numeric' })}`;
|
|
1359
|
+
}
|
|
1360
|
+
if (range.start.getMonth() !== last.getMonth()) {
|
|
1361
|
+
return `${this._format(range.start, { day: 'numeric', month: 'short' })} – ${this._format(last, { day: 'numeric', month: 'short', year: 'numeric' })}`;
|
|
1362
|
+
}
|
|
1363
|
+
return `${this._format(range.start, { day: 'numeric' })}–${this._format(last, { day: 'numeric', month: 'long', year: 'numeric' })}`;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/** @param {CalendarEvent} event @param {Date} day @returns {string} */
|
|
1367
|
+
_eventTime(event, day) {
|
|
1368
|
+
if (event.allDay) return this.msg('allDay');
|
|
1369
|
+
if (calendarEventSpansDays(event)) {
|
|
1370
|
+
if (sameCalendarDay(event.start, day)) return `${formatDateTime(event.start, this.options.locale)} →`;
|
|
1371
|
+
if (sameCalendarDay(event.end, day)) return `→ ${formatDateTime(event.end, this.options.locale)}`;
|
|
1372
|
+
return this.msg('allDay');
|
|
1373
|
+
}
|
|
1374
|
+
const start = formatTimeDate(event.start, this.options.locale);
|
|
1375
|
+
if (event.end <= event.start) return start;
|
|
1376
|
+
return `${start}–${formatTimeDate(event.end, this.options.locale)}`;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/** @param {CalendarEvent} event @returns {string} */
|
|
1380
|
+
_eventLabel(event) {
|
|
1381
|
+
const location = event.location ? `, ${event.location}` : '';
|
|
1382
|
+
return `${event.title}, ${formatDateTime(event.start, this.options.locale)} to ${formatDateTime(event.end, this.options.locale)}${location}`;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** @param {Date} date @returns {string} */
|
|
1386
|
+
_dateTimeLabel(date) {
|
|
1387
|
+
return formatDateTime(date, this.options.locale);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** @param {Date} date @param {Intl.DateTimeFormatOptions} options @returns {string} */
|
|
1391
|
+
_format(date, options) {
|
|
1392
|
+
return new Intl.DateTimeFormat(this.options.locale, options).format(date);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/** @returns {Date} */
|
|
1396
|
+
_now() {
|
|
1397
|
+
const source = typeof this.options.now === 'function' ? this.options.now() : this.options.now;
|
|
1398
|
+
const date = source == null ? new Date() : source;
|
|
1399
|
+
if (!(date instanceof Date) || Number.isNaN(date.getTime())) throw new TypeError('Calendar now must return a valid Date');
|
|
1400
|
+
return new Date(date.getTime());
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/** @param {HTMLElement} target @returns {Date|null} */
|
|
1404
|
+
_dateFromTarget(target) {
|
|
1405
|
+
if (target.dataset.dateTime) {
|
|
1406
|
+
const date = new Date(Number(target.dataset.dateTime));
|
|
1407
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
1408
|
+
}
|
|
1409
|
+
return parseDayKey(target.dataset.date);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** @param {HTMLElement} target @returns {void} */
|
|
1413
|
+
_rememberFocusedDate(target) {
|
|
1414
|
+
if (target.dataset.slot) this._focusedSlot = target.dataset.slot;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/** @returns {{eventId?: string, eventAction?: string, date?: string, slot?: string}|null} */
|
|
1418
|
+
_focusIdentity() {
|
|
1419
|
+
const active = document.activeElement;
|
|
1420
|
+
if (!(active instanceof HTMLElement) || !this.el.contains(active)) return null;
|
|
1421
|
+
return {
|
|
1422
|
+
eventId: active.dataset.eventId,
|
|
1423
|
+
eventAction: active.dataset.eventAction,
|
|
1424
|
+
date: active.dataset.date,
|
|
1425
|
+
slot: active.dataset.slot
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/** @param {{eventId?: string, eventAction?: string, date?: string, slot?: string}} identity @returns {void} */
|
|
1430
|
+
_restoreFocus(identity) {
|
|
1431
|
+
let target = null;
|
|
1432
|
+
if (identity.eventId) target = this.el.querySelector(
|
|
1433
|
+
`[data-event-id="${cssEscape(identity.eventId)}"][data-event-action="${identity.eventAction ?? 'activate'}"]`);
|
|
1434
|
+
if (!target && identity.slot) target = this.el.querySelector(`[data-slot="${cssEscape(identity.slot)}"]`);
|
|
1435
|
+
if (!target && identity.date) target = this.el.querySelector(`[data-date-cell][data-date="${cssEscape(identity.date)}"]`);
|
|
1436
|
+
target?.focus?.();
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/** @returns {void} */
|
|
1440
|
+
_scrollTimeGrid() {
|
|
1441
|
+
const scroller = this.refs.body.querySelector('.zx-calendar__time-scroller');
|
|
1442
|
+
if (!scroller) return;
|
|
1443
|
+
const min = normalizeMinute(this.options.slotMinTime, 0);
|
|
1444
|
+
const max = normalizeMinute(this.options.slotMaxTime, 1440, true);
|
|
1445
|
+
const scroll = normalizeMinute(this.options.scrollTime, 480, true);
|
|
1446
|
+
const ratio = Math.max(0, Math.min(1, (scroll - min) / (max - min)));
|
|
1447
|
+
requestAnimationFrame(() => {
|
|
1448
|
+
if (scroller.isConnected) scroller.scrollTop = Math.max(0, ratio * scroller.scrollHeight - scroller.clientHeight / 4);
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/** @returns {void} */
|
|
1453
|
+
_refreshNowIndicator() {
|
|
1454
|
+
if (this._destroyed || !this.options.nowIndicator || !['day', 'week'].includes(this._view)) return;
|
|
1455
|
+
const line = this.refs.body.querySelector('.zx-calendar__now');
|
|
1456
|
+
const headings = [...this.refs.body.querySelectorAll('.zx-calendar__day-heading')];
|
|
1457
|
+
if (!line || !headings.length) return;
|
|
1458
|
+
const now = this._now();
|
|
1459
|
+
const dayIndex = headings.findIndex((heading) => heading.dataset.date === calendarDayKey(now));
|
|
1460
|
+
const min = normalizeMinute(this.options.slotMinTime, 0);
|
|
1461
|
+
const max = normalizeMinute(this.options.slotMaxTime, 1440, true);
|
|
1462
|
+
const minutes = now.getHours() * 60 + now.getMinutes();
|
|
1463
|
+
const visible = dayIndex >= 0 && minutes >= min && minutes <= max;
|
|
1464
|
+
line.hidden = !visible;
|
|
1465
|
+
if (!visible) return;
|
|
1466
|
+
line.style.insetBlockStart = `${((minutes - min) / (max - min)) * 100}%`;
|
|
1467
|
+
line.style.setProperty('--zx-calendar-now-day', String(dayIndex));
|
|
1468
|
+
line.querySelector('.zx-calendar__now-time').textContent = formatTimeMinutes(minutes, this.options.locale);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/** @param {string} message @returns {void} */
|
|
1472
|
+
_announce(message) {
|
|
1473
|
+
this.refs.status.textContent = '';
|
|
1474
|
+
requestAnimationFrame(() => {
|
|
1475
|
+
if (!this._destroyed) this.refs.status.textContent = message;
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/** @param {unknown} value @returns {CalendarView[]} */
|
|
1481
|
+
function normalizeViews(value) {
|
|
1482
|
+
if (!Array.isArray(value) || value.length === 0) throw new TypeError('Calendar views must be a non-empty array');
|
|
1483
|
+
const views = [...new Set(value.map(String))];
|
|
1484
|
+
for (const view of views) if (!CALENDAR_VIEWS.includes(/** @type {CalendarView} */ (view))) {
|
|
1485
|
+
throw new RangeError(`Unknown Calendar view: ${view}`);
|
|
1486
|
+
}
|
|
1487
|
+
return /** @type {CalendarView[]} */ (views);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/** @param {Date|string|number|null} value @param {'milliseconds'|'seconds'} unit @param {Date} fallback @returns {Date} */
|
|
1491
|
+
function normalizeAnchorDate(value, unit, fallback) {
|
|
1492
|
+
if (value == null) return fallback;
|
|
1493
|
+
if (value instanceof Date) {
|
|
1494
|
+
if (Number.isNaN(value.getTime())) throw new TypeError('Calendar date must be valid');
|
|
1495
|
+
return new Date(value.getTime());
|
|
1496
|
+
}
|
|
1497
|
+
const numeric = typeof value === 'number' ? value : typeof value === 'string' && /^-?\d+(?:\.\d+)?$/.test(value.trim()) ? Number(value) : null;
|
|
1498
|
+
const date = numeric == null ? new Date(value) : new Date(numeric * (unit === 'seconds' ? 1000 : 1));
|
|
1499
|
+
if (Number.isNaN(date.getTime())) throw new TypeError('Calendar date must be valid');
|
|
1500
|
+
return date;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/** @param {unknown} value @returns {number} */
|
|
1504
|
+
function normalizeSlot(value) {
|
|
1505
|
+
const number = Number(value);
|
|
1506
|
+
if (!Number.isInteger(number) || number <= 0 || number > 720 || 1440 % number !== 0) {
|
|
1507
|
+
throw new RangeError('Calendar slotDuration must be a positive divisor of 1440');
|
|
1508
|
+
}
|
|
1509
|
+
return number;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/** @param {unknown} value @returns {number} */
|
|
1513
|
+
function normalizeEventLimit(value) {
|
|
1514
|
+
const number = Number(value);
|
|
1515
|
+
if (!Number.isInteger(number) || number < 1 || number > 20) {
|
|
1516
|
+
throw new RangeError('Calendar eventLimit must be an integer from 1 through 20');
|
|
1517
|
+
}
|
|
1518
|
+
return number;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/** @param {unknown} value @param {number} fallback @param {boolean} [allowEnd=false] @returns {number} */
|
|
1522
|
+
function normalizeMinute(value, fallback, allowEnd = false) {
|
|
1523
|
+
const number = Number(value ?? fallback);
|
|
1524
|
+
const max = allowEnd ? 1440 : 1439;
|
|
1525
|
+
if (!Number.isInteger(number) || number < 0 || number > max) throw new RangeError(`Calendar minute must be from 0 through ${max}`);
|
|
1526
|
+
return number;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** @param {Date} date @param {number} minutes @returns {Date} */
|
|
1530
|
+
function dateWithMinutes(date, minutes) {
|
|
1531
|
+
const result = startOfCalendarDay(date);
|
|
1532
|
+
result.setMinutes(minutes);
|
|
1533
|
+
return result;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/** @param {Date} date @param {number} minutes @returns {Date} */
|
|
1537
|
+
function addMinutes(date, minutes) {
|
|
1538
|
+
const result = new Date(date.getTime());
|
|
1539
|
+
result.setMinutes(result.getMinutes() + minutes);
|
|
1540
|
+
return result;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** @param {Date} date @returns {string} */
|
|
1544
|
+
function dayTimestamp(date) {
|
|
1545
|
+
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/** @param {Date} left @param {Date} right @returns {boolean} */
|
|
1549
|
+
function sameCalendarDay(left, right) {
|
|
1550
|
+
return dayTimestamp(left) === dayTimestamp(right);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** @param {string|undefined} value @returns {Date|null} */
|
|
1554
|
+
function parseDayKey(value) {
|
|
1555
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value ?? ''));
|
|
1556
|
+
if (!match) return null;
|
|
1557
|
+
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
|
1558
|
+
return calendarDayKey(date) === value ? date : null;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/** @param {CalendarEvent} left @param {CalendarEvent} right @returns {number} */
|
|
1562
|
+
function compareEvents(left, right) {
|
|
1563
|
+
return left.start - right.start || right.end - left.end || left.title.localeCompare(right.title);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/** @param {number} minutes @param {string|string[]|undefined} locale @returns {string} */
|
|
1567
|
+
function formatTimeMinutes(minutes, locale) {
|
|
1568
|
+
const date = new Date(2026, 0, 1);
|
|
1569
|
+
date.setHours(Math.floor(minutes / 60) % 24, minutes % 60, 0, 0);
|
|
1570
|
+
return formatTimeDate(date, locale);
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/** @param {Date} date @param {string|string[]|undefined} locale @returns {string} */
|
|
1574
|
+
function formatTimeDate(date, locale) {
|
|
1575
|
+
return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/** @param {Date} date @param {string|string[]|undefined} locale @returns {string} */
|
|
1579
|
+
function formatDateTime(date, locale) {
|
|
1580
|
+
return new Intl.DateTimeFormat(locale, {
|
|
1581
|
+
weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit'
|
|
1582
|
+
}).format(date);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/** @param {Date} date @returns {number} */
|
|
1586
|
+
function isoWeek(date) {
|
|
1587
|
+
const copy = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
1588
|
+
const weekday = copy.getUTCDay() || 7;
|
|
1589
|
+
copy.setUTCDate(copy.getUTCDate() + 4 - weekday);
|
|
1590
|
+
const yearStart = new Date(Date.UTC(copy.getUTCFullYear(), 0, 1));
|
|
1591
|
+
return Math.ceil((((copy - yearStart) / 86_400_000) + 1) / 7);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/** @param {CalendarEvent} oldEvent @param {CalendarEvent} event @returns {{milliseconds: number, days: number, minutes: number, endMilliseconds: number}} */
|
|
1595
|
+
function calendarDelta(oldEvent, event) {
|
|
1596
|
+
const milliseconds = event.start.getTime() - oldEvent.start.getTime();
|
|
1597
|
+
return {
|
|
1598
|
+
milliseconds,
|
|
1599
|
+
days: calendarDayDifference(startOfCalendarDay(oldEvent.start), startOfCalendarDay(event.start)),
|
|
1600
|
+
minutes: Math.round(milliseconds / 60_000),
|
|
1601
|
+
endMilliseconds: event.end.getTime() - oldEvent.end.getTime()
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/** @param {string} value @returns {string} */
|
|
1606
|
+
function cssEscape(value) {
|
|
1607
|
+
return globalThis.CSS?.escape ? CSS.escape(value) : value.replace(/[^a-zA-Z0-9_-]/g, (character) => `\\${character}`);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/** @event Calendar#viewchange @type {CustomEvent<{view: CalendarView, oldView: CalendarView}>} */
|
|
1611
|
+
/** @event Calendar#dateschange @type {CustomEvent<{view: CalendarView, start: Date, end: Date}>} */
|
|
1612
|
+
/** @event Calendar#eventclick @type {CustomEvent<{event: CalendarEvent, jsEvent: Event}>} */
|
|
1613
|
+
/** @event Calendar#eventdblclick @type {CustomEvent<{event: CalendarEvent, jsEvent: Event}>} */
|
|
1614
|
+
/** @event Calendar#dateclick @type {CustomEvent<{date: Date, allDay: boolean, view: CalendarView, jsEvent: Event}>} */
|
|
1615
|
+
/** @event Calendar#select @type {CustomEvent<{start: Date, end: Date, allDay: boolean, view: CalendarView, jsEvent: Event}>} */
|
|
1616
|
+
/** @event Calendar#eventchange @type {CustomEvent<CalendarEventChangeDetail>} */
|
|
1617
|
+
/** @event Calendar#eventschange @type {CustomEvent<{events: CalendarEvent[]}>} */
|
|
1618
|
+
/** @event Calendar#new @type {CustomEvent<{date: Date, view: CalendarView, jsEvent: Event}>} */
|
|
1619
|
+
/** @event Calendar#moreclick @type {CustomEvent<{date: Date, count: number, jsEvent: Event}>} */
|