@fieldia/calendar 0.0.0-stage → 0.23.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -2
  3. package/package.json +37 -3
  4. package/src/index.d.mts +3 -0
  5. package/src/index.d.ts +3 -0
  6. package/src/index.js +6 -0
  7. package/src/index.js.map +1 -0
  8. package/src/index.mjs +2 -0
  9. package/src/index.mjs.map +1 -0
  10. package/src/lib/calendar-core.d.mts +83 -0
  11. package/src/lib/calendar-core.d.ts +83 -0
  12. package/src/lib/calendar-core.js +18 -0
  13. package/src/lib/calendar-core.js.map +1 -0
  14. package/src/lib/calendar-core.mjs +13 -0
  15. package/src/lib/calendar-core.mjs.map +1 -0
  16. package/src/lib/calendar-view.d.mts +11 -0
  17. package/src/lib/calendar-view.d.ts +11 -0
  18. package/src/lib/calendar-view.js +829 -0
  19. package/src/lib/calendar-view.js.map +1 -0
  20. package/src/lib/calendar-view.mjs +826 -0
  21. package/src/lib/calendar-view.mjs.map +1 -0
  22. package/src/lib/dates.d.mts +43 -0
  23. package/src/lib/dates.d.ts +43 -0
  24. package/src/lib/dates.js +100 -0
  25. package/src/lib/dates.js.map +1 -0
  26. package/src/lib/dates.mjs +80 -0
  27. package/src/lib/dates.mjs.map +1 -0
  28. package/src/lib/events.d.mts +58 -0
  29. package/src/lib/events.d.ts +58 -0
  30. package/src/lib/events.js +136 -0
  31. package/src/lib/events.js.map +1 -0
  32. package/src/lib/events.mjs +125 -0
  33. package/src/lib/events.mjs.map +1 -0
  34. package/src/lib/gestures.d.mts +11 -0
  35. package/src/lib/gestures.d.ts +11 -0
  36. package/src/lib/gestures.js +215 -0
  37. package/src/lib/gestures.js.map +1 -0
  38. package/src/lib/gestures.mjs +210 -0
  39. package/src/lib/gestures.mjs.map +1 -0
  40. package/src/lib/keys.d.mts +5 -0
  41. package/src/lib/keys.d.ts +5 -0
  42. package/src/lib/keys.js +151 -0
  43. package/src/lib/keys.js.map +1 -0
  44. package/src/lib/keys.mjs +146 -0
  45. package/src/lib/keys.mjs.map +1 -0
  46. package/src/lib/labels.d.mts +65 -0
  47. package/src/lib/labels.d.ts +65 -0
  48. package/src/lib/labels.js +193 -0
  49. package/src/lib/labels.js.map +1 -0
  50. package/src/lib/labels.mjs +188 -0
  51. package/src/lib/labels.mjs.map +1 -0
  52. package/src/lib/list-view.d.mts +11 -0
  53. package/src/lib/list-view.d.ts +11 -0
  54. package/src/lib/list-view.js +54 -0
  55. package/src/lib/list-view.js.map +1 -0
  56. package/src/lib/list-view.mjs +51 -0
  57. package/src/lib/list-view.mjs.map +1 -0
  58. package/src/lib/move-to.d.mts +15 -0
  59. package/src/lib/move-to.d.ts +15 -0
  60. package/src/lib/move-to.js +122 -0
  61. package/src/lib/move-to.js.map +1 -0
  62. package/src/lib/move-to.mjs +118 -0
  63. package/src/lib/move-to.mjs.map +1 -0
  64. package/src/lib/settings.d.mts +11 -0
  65. package/src/lib/settings.d.ts +11 -0
  66. package/src/lib/settings.js +3 -0
  67. package/src/lib/settings.js.map +1 -0
  68. package/src/lib/settings.mjs +2 -0
  69. package/src/lib/settings.mjs.map +1 -0
  70. package/src/lib/styles.d.mts +9 -0
  71. package/src/lib/styles.d.ts +9 -0
  72. package/src/lib/styles.js +165 -0
  73. package/src/lib/styles.js.map +1 -0
  74. package/src/lib/styles.mjs +161 -0
  75. package/src/lib/styles.mjs.map +1 -0
@@ -0,0 +1,826 @@
1
+ import { saveProblemOf } from '@fieldia/core';
2
+ import { moneyReads } from '@fieldia/widgets';
3
+ import { linesIn, maker } from './calendar-core.mjs';
4
+ import { addDays, addMinutes, atMinutes, dayKey, firstDayOf, format, minutesOf, rangeOf, sameDay, startOfDay, stepFrom, writeWhen } from './dates.mjs';
5
+ import { colourOf, comma, eventsOf, keyOf, kindOf, sideBySide } from './events.mjs';
6
+ import { pressEvent, pressSlot, stretch } from './gestures.mjs';
7
+ import { onDayKey, onEventKey, onSlotKey } from './keys.mjs';
8
+ import { calendarLabels, fill } from './labels.mjs';
9
+ import { moveTo, placeUnder } from './move-to.mjs';
10
+ import { installCalendarStyles } from './styles.mjs';
11
+ const ALL_VIEWS = ['day', 'week', 'month', 'agenda'];
12
+ /** The least a slot or a record is tall, so a finger or a pointer can hit it (WCAG's 24 pixels). */
13
+ const TARGET = 24;
14
+ /** How long a record pressed into an empty slot lasts, in minutes. */
15
+ const NEW_LENGTH = 60;
16
+ /** How many records a month's day shows before "+2 more". */
17
+ const IN_A_DAY = 3;
18
+ /** The narrowest a record beside others may be, in pixels, and still show its name's start: past it, "+N more". */
19
+ const LANE = 64;
20
+ /** How many sit side by side where no width is measured (a calendar not yet laid out): two, and "+N more" in the third. */
21
+ const LANES = 3;
22
+ /** Back and on, as the line reads left to right: flipped right to left by the stylesheet. */
23
+ const CHEVRON = (back) => `<path d="${back ? 'M10 3 5 8l5 5' : 'M6 3l5 5-5 5'}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`;
24
+ let made = 0;
25
+ /**
26
+ * A list's records on a calendar, for the list's switcher: the list's own
27
+ * search, its records by day, week or month, or as an agenda; a column for
28
+ * each person or room in the day view; dragged to move and stretched by
29
+ * their end (gestures.ts), or moved from the keyboard (keys.ts) with "Move
30
+ * to…" (move-to.ts), written through the data source's save with Undo; an
31
+ * empty slot pressed for a new record.
32
+ */
33
+ export function calendarPart(context, settings = {}) {
34
+ const { page, node, document: doc, locale } = context;
35
+ const spec = node.calendar;
36
+ const source = context.dataSource;
37
+ const model = page.data.kind === 'record' ? page.data.model : '';
38
+ const labels = calendarLabels(locale, settings.labels?.[locale]);
39
+ const now = () => settings.now?.() ?? new Date();
40
+ const kind = kindOf(page, spec);
41
+ const views = (spec.views?.length ? spec.views : ALL_VIEWS).filter((v, i, all) => all.indexOf(v) === i);
42
+ const firstDay = firstDayOf(locale, spec.firstDay);
43
+ const slot = spec.slot ?? 30;
44
+ /** Pixels an hour takes in the day and the week (--fd-cal-hour): 48, or more where a slot would be under 24 pixels. */
45
+ const HOUR = Math.max(48, Math.ceil((TARGET * 60) / slot));
46
+ const hours = spec.hours ?? { start: 8, end: 20 };
47
+ const limit = settings.limit ?? 500;
48
+ const id = `fd-cal-${++made}`;
49
+ const rtl = () => element.closest('[dir]')?.getAttribute('dir') === 'rtl';
50
+ const phone = !!doc.defaultView?.matchMedia?.('(max-width: 640px)').matches;
51
+ const opened = startOfDay(now());
52
+ /** What it shows now: its view, its span, the keys' stop, its columns and hours as drawn, its people. */
53
+ const state = {
54
+ view: phone && views.includes('agenda') ? 'agenda' : spec.view && views.includes(spec.view) ? spec.view : views[0],
55
+ anchor: opened,
56
+ cursor: { day: opened, minutes: hours.start * 60, column: 0 },
57
+ columns: [],
58
+ shownHours: hours,
59
+ resources: [],
60
+ };
61
+ let records = [];
62
+ let events = [];
63
+ let total = 0;
64
+ let knownResources = null;
65
+ let asking = 0;
66
+ let destroyed = false;
67
+ let failed = false;
68
+ /** What Undo writes back, the version the record holds after the move, and the moment it started at before. */
69
+ let undo = null;
70
+ /** The fields a move writes. When the page locks one, nothing moves; when a record's source locks or hides one, that record stays. */
71
+ const moveFields = [spec.start, spec.end, spec.resource].filter((name) => !!name);
72
+ const canSave = !!source?.save && !moveFields.some((name) => page.fields[name]?.readonly);
73
+ const canCreate = !!context.create;
74
+ const canOpen = !!context.open;
75
+ const fields = [...new Set([spec.start, spec.end, spec.title, spec.colour, spec.resource, ...moneyReads(page.fields[spec.title])].filter((name) => !!name && !!page.fields[name]))];
76
+ installCalendarStyles(doc);
77
+ const make = maker(doc);
78
+ const chevron = (back) => {
79
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
80
+ svg.setAttribute('viewBox', '0 0 16 16');
81
+ svg.setAttribute('aria-hidden', 'true');
82
+ svg.innerHTML = CHEVRON(back);
83
+ return svg;
84
+ };
85
+ // ---- the bar: Today, back and on, the span in words, the views, New ----------------------------------------
86
+ const today = make('button', { type: 'button', class: 'fd-button' }, labels.today);
87
+ const back = make('button', { type: 'button', class: 'fd-button fd-cal-step' }, chevron(true));
88
+ const on = make('button', { type: 'button', class: 'fd-button fd-cal-step' }, chevron(false));
89
+ const title = make('h2', { class: 'fd-cal-title', id: `${id}-title`, 'aria-live': 'polite' });
90
+ const viewButtons = views.map((name) => {
91
+ const button = make('button', { type: 'button', class: 'fd-button', 'data-range': name, 'aria-pressed': 'false' }, labels.views[name]);
92
+ button.addEventListener('click', () => showView(name));
93
+ return button;
94
+ });
95
+ const ranges = make('div', { class: 'fd-cal-ranges', role: 'group', 'aria-label': labels.showAs }, ...viewButtons);
96
+ ranges.hidden = views.length < 2;
97
+ const newButton = make('button', { type: 'button', class: 'fd-button fd-button-primary fd-cal-new' }, labels.newRecord);
98
+ newButton.hidden = !canCreate;
99
+ today.addEventListener('click', () => go(startOfDay(now())));
100
+ back.addEventListener('click', () => go(stepFrom(state.view, state.anchor, -1)));
101
+ on.addEventListener('click', () => go(stepFrom(state.view, state.anchor, 1)));
102
+ newButton.addEventListener('click', () => void createAt(...newPlace()));
103
+ const bar = make('div', { class: 'fd-cal-bar' }, make('div', { class: 'fd-cal-nav', role: 'group', 'aria-label': labels.moveThrough }, today, back, on), title, ranges, newButton);
104
+ // What a move says, with Undo; what went wrong, at once; how many it shows, when it cannot show all.
105
+ const said = make('div', { class: 'fd-cal-said', role: 'status' });
106
+ const alert = make('div', { class: 'fd-cal-alert', role: 'alert' });
107
+ const note = make('p', { class: 'fd-cal-note', hidden: '' });
108
+ const body = make('div', { class: 'fd-cal-body', role: 'region', 'aria-labelledby': `${id}-title` });
109
+ // What a record's keys do: Enter and M, or Enter alone for one that stays where it is.
110
+ const keysEvent = make('span', { id: `${id}-keys-event`, class: 'fd-sr-only' }, canOpen ? labels.keysOpenMove : labels.keysMove);
111
+ const keysStill = make('span', { id: `${id}-keys-still`, class: 'fd-sr-only' }, labels.keysOpen);
112
+ const keysSlot = make('span', { id: `${id}-keys-slot`, class: 'fd-sr-only' }, labels.keysSlots);
113
+ const keysDay = make('span', { id: `${id}-keys-day`, class: 'fd-sr-only' }, labels.keysDays);
114
+ const element = make('div', { class: 'fd-cal', 'data-creates': canCreate ? '' : undefined, 'aria-label': labels.calendar }, bar, said, alert, note, body, keysEvent, keysStill, keysSlot, keysDay);
115
+ element.setAttribute('role', 'group');
116
+ element.style.setProperty('--fd-cal-hour', `${HOUR}px`);
117
+ // ---- asking for the records of the span shown ---------------------------------------------------------------
118
+ const range = () => rangeOf(state.view, state.anchor, firstDay);
119
+ /** The records in the span: starting before it ends, and ending after it starts (or starting in it, with no end). */
120
+ function spanFilter({ from, to }) {
121
+ const last = kind === 'date' ? dayKey(addDays(to, -1)) : writeWhen(to, kind);
122
+ const first = writeWhen(from, kind);
123
+ const starts = { field: spec.start, op: kind === 'date' ? '<=' : '<', value: last };
124
+ if (!spec.end)
125
+ return [starts, { field: spec.start, op: '>=', value: first }];
126
+ return [starts, { any: [{ field: spec.end, op: kind === 'date' ? '>=' : '>', value: first }, { field: spec.start, op: '>=', value: first }] }];
127
+ }
128
+ async function reload() {
129
+ const mine = ++asking;
130
+ body.setAttribute('aria-busy', 'true');
131
+ try {
132
+ const answer = source?.list ? await source.list({ model, fields, filter: [...context.filter(), ...spanFilter(range())], sort: [{ field: spec.start }], offset: 0, limit }) : { records: [], total: 0 };
133
+ const people = await resourcesFor(answer.records);
134
+ if (mine !== asking || destroyed)
135
+ return;
136
+ records = answer.records;
137
+ total = answer.total;
138
+ state.resources = people;
139
+ failed = false;
140
+ }
141
+ catch {
142
+ if (mine !== asking || destroyed)
143
+ return;
144
+ failed = true;
145
+ }
146
+ body.removeAttribute('aria-busy');
147
+ events = eventsOf(records, spec, page, locale);
148
+ draw();
149
+ }
150
+ /**
151
+ * The people or rooms the day shows a column for: a choice's options, a link's records (asked for once), the
152
+ * values the records hold besides; "No one" for records with none. Kept to those with records when the search
153
+ * names the field itself.
154
+ */
155
+ async function resourcesFor(found) {
156
+ const field = spec.resource ? page.fields[spec.resource] : undefined;
157
+ if (!spec.resource || !field)
158
+ return [null];
159
+ const name = spec.resource;
160
+ if (knownResources === null) {
161
+ knownResources = [];
162
+ if (field.type === 'selection')
163
+ knownResources = field.options.map((o) => ({ value: o.value, key: String(o.value), label: o.label }));
164
+ else if (field.type === 'many2one' && source?.search) {
165
+ try {
166
+ knownResources = (await source.search({ model: field.relation, query: '', limit: 40 })).map((r) => ({ value: { id: r.id, label: r.label }, key: String(r.id), label: r.label }));
167
+ }
168
+ catch {
169
+ knownResources = [];
170
+ }
171
+ }
172
+ }
173
+ const seen = new Map();
174
+ let none = false;
175
+ for (const record of found) {
176
+ const value = record.values[name];
177
+ const key = keyOf(value);
178
+ if (key === null)
179
+ none = true;
180
+ else if (!seen.has(key))
181
+ seen.set(key, { value: value, key, label: eventsOf([record], { ...spec, title: name }, page, locale)[0]?.title ?? key });
182
+ }
183
+ const searched = JSON.stringify(context.filter()).includes(`"field":"${name}"`);
184
+ const listed = (searched ? knownResources.filter((r) => seen.has(r.key)) : knownResources).map((r) => seen.get(r.key) ?? r);
185
+ const more = [...seen.values()].filter((r) => !listed.some((l) => l.key === r.key));
186
+ const all = [...listed, ...more];
187
+ if (none)
188
+ all.push({ value: null, key: '', label: labels.noResource });
189
+ return all.length ? all : [null];
190
+ }
191
+ // ---- drawing ----------------------------------------------------------------------------------------------
192
+ const longDay = (d) => format(locale, { weekday: 'long', day: 'numeric', month: 'long' }).format(d);
193
+ /** The parts of what a block, a slot or a day says joined as the language joins them: in Arabic, "، ". */
194
+ const joined = comma(locale);
195
+ const time = (d) => format(locale, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(d);
196
+ const isToday = (d) => sameDay(d, now());
197
+ function drawTitle({ from, to }) {
198
+ const { view } = state;
199
+ const last = addDays(to, -1);
200
+ if (view === 'day')
201
+ title.textContent = format(locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }).format(from);
202
+ else if (view === 'month')
203
+ title.textContent = format(locale, { month: 'long', year: 'numeric' }).format(state.anchor);
204
+ else {
205
+ const days = format(locale, { day: 'numeric', month: 'short', year: 'numeric' });
206
+ title.textContent = typeof days.formatRange === 'function' ? days.formatRange(from, last) : `${days.format(from)} – ${days.format(last)}`;
207
+ }
208
+ back.setAttribute('aria-label', labels.previous[view]);
209
+ back.title = labels.previous[view];
210
+ on.setAttribute('aria-label', labels.next[view]);
211
+ on.title = labels.next[view];
212
+ for (const button of viewButtons)
213
+ button.setAttribute('aria-pressed', String(button.dataset['range'] === view));
214
+ }
215
+ function draw() {
216
+ const active = doc.activeElement;
217
+ // Where the keys stand, to stand there again: a record or a slot; in a crowded slot's list, its record or its "+N more".
218
+ const inList = !!active && !!crowdBox?.contains(active);
219
+ const focusKeys = active && (body.contains(active) || inList) ? [active.dataset['focus'], inList ? crowdFrom : undefined] : [];
220
+ closeCrowd();
221
+ const shown = range();
222
+ element.dataset['range'] = state.view;
223
+ drawTitle(shown);
224
+ note.hidden = total <= records.length;
225
+ note.textContent = fill(labels.tooMany, { shown: records.length, total });
226
+ if (failed) {
227
+ const retry = make('button', { type: 'button', class: 'fd-button fd-button-link' }, labels.retry);
228
+ retry.addEventListener('click', () => void reload());
229
+ body.replaceChildren(make('p', { class: 'fd-cal-failed', role: 'alert' }, labels.loadFailed, ' ', retry));
230
+ return;
231
+ }
232
+ if (state.view === 'month')
233
+ body.replaceChildren(monthView(shown));
234
+ else if (state.view === 'agenda')
235
+ body.replaceChildren(agendaView(shown));
236
+ else
237
+ body.replaceChildren(timeView(shown));
238
+ for (const key of focusKeys) {
239
+ const again = key ? body.querySelector(`[data-focus="${key}"]`) : null;
240
+ if (again) {
241
+ again.focus();
242
+ break;
243
+ }
244
+ }
245
+ placeNow();
246
+ }
247
+ /**
248
+ * A record's block: its time, its words, its person; what it says to a screen reader; what a press does. In the day,
249
+ * the week and the month the block itself is the press; in the agenda and a crowded slot's list (`row`), a row with
250
+ * its own Move to… button beside it, which a phone has no keys for.
251
+ */
252
+ function eventBlock(event, day, compact, inOwnColumn = false, row = state.view === 'agenda') {
253
+ const can = event.record.can;
254
+ const movable = canSave && can?.edit !== false && !moveFields.some((name) => can?.readonlyFields?.includes(name) || can?.hiddenFields?.includes(name));
255
+ const why = canSave && !movable ? (can?.reason ?? labels.cannotMove) : '';
256
+ const span = event.allDay ? '' : fill(labels.fromTo, { from: time(event.start), to: time(event.end) });
257
+ const who = event.resourceLabel ? fill(labels.withWho, { name: event.resourceLabel }) : '';
258
+ const block = make('div', {
259
+ class: 'fd-cal-event',
260
+ 'data-id': String(event.id),
261
+ 'data-opens': canOpen ? '' : undefined,
262
+ 'data-moves': movable ? '' : undefined,
263
+ // Its words whole when pointed at, as a narrow block cuts them; or why it stays where it is.
264
+ title: why || [span, event.title, event.resourceLabel].filter(Boolean).join(' · '),
265
+ });
266
+ block.style.setProperty('--fd-cal-c', colourOf(event.colour) ?? 'var(--fd-accent)');
267
+ /** What the keys and a press reach: the block, or the agenda row's own part. */
268
+ const target = row ? make('div', { class: 'fd-cal-event-open' }) : block;
269
+ target.dataset['focus'] = `e:${event.id}:${dayKey(day)}`;
270
+ if (canOpen || movable) {
271
+ target.tabIndex = 0;
272
+ target.setAttribute('role', 'button');
273
+ target.setAttribute('aria-describedby', movable ? keysEvent.id : keysStill.id);
274
+ }
275
+ target.setAttribute('aria-label', [event.title, event.allDay ? longDay(event.start) : `${longDay(event.start)}${joined}${span}`, who, why].filter(Boolean).join(joined));
276
+ // Its words; in a block, in a box of their own, which the hours keep to whole lines (--fd-cal-lines).
277
+ const words = row ? target : make('span', { class: 'fd-cal-event-text' });
278
+ if (!event.allDay)
279
+ words.append(make('span', { class: 'fd-cal-event-time' }, row ? `${time(event.start)} – ${time(event.end)}` : time(event.start)), ' ');
280
+ // Its own direction: a Latin name on a right-to-left page reads, and is cut short, as Latin.
281
+ words.append(make('span', { class: 'fd-cal-event-title', dir: 'auto' }, event.title));
282
+ if (event.resourceLabel && !compact && !inOwnColumn)
283
+ words.append(make('span', { class: 'fd-cal-event-who' }, make('bdi', {}, event.resourceLabel)));
284
+ block.append(row ? target : words);
285
+ if (movable) {
286
+ // In a block, for a pointer alone: the keys reach Move to… by M, as the block tells a screen reader, and a
287
+ // button inside a button is no button. Beside an agenda's row, a button of its own, for a phone too.
288
+ const menu = row
289
+ ? make('button', { type: 'button', class: 'fd-cal-event-menu', tabindex: '-1', 'aria-label': fill(labels.moveTitle, { name: event.title }), title: labels.moveTo }, '⋯')
290
+ : make('button', { type: 'button', class: 'fd-cal-event-menu', tabindex: '-1', 'aria-hidden': 'true', title: labels.moveTo }, '⋯');
291
+ menu.addEventListener('pointerdown', (e) => e.stopPropagation());
292
+ menu.addEventListener('click', (e) => {
293
+ e.stopPropagation();
294
+ mover.open(event, target);
295
+ });
296
+ block.append(menu);
297
+ }
298
+ target.addEventListener('keydown', (e) => onEventKey(cal, e, event, target));
299
+ if (!row) {
300
+ block.addEventListener('pointerdown', (e) => pressEvent(cal, e, event, block, movable));
301
+ // A screen reader's press is a click with no pointer behind it: it opens the record as Enter does.
302
+ block.addEventListener('click', (e) => {
303
+ if (e.detail === 0 && e.target === block)
304
+ onEventKey(cal, new KeyboardEvent('keydown', { key: 'Enter' }), event, block, true);
305
+ });
306
+ }
307
+ else
308
+ target.addEventListener('click', () => open(event));
309
+ return block;
310
+ }
311
+ // ---- the day and the week ------------------------------------------------------------------------------------
312
+ function timeView(shown) {
313
+ const columns = state.view === 'day' && spec.resource ? state.resources.map((resource) => ({ day: shown.from, resource })) : shown.days.map((day) => ({ day, resource: null }));
314
+ state.columns = columns;
315
+ const { cursor } = state;
316
+ if (cursor.column >= columns.length || !sameDay(columns[cursor.column].day, cursor.day)) {
317
+ const at = Math.max(0, columns.findIndex((column) => sameDay(column.day, cursor.day)));
318
+ state.cursor = { ...cursor, column: at, day: columns[at].day };
319
+ }
320
+ const timed = events.filter((event) => !event.allDay);
321
+ let from = hours.start;
322
+ let to = hours.end;
323
+ for (const event of timed) {
324
+ for (const column of columns) {
325
+ const seg = segment(event, column.day);
326
+ if (!seg)
327
+ continue;
328
+ from = Math.min(from, Math.floor(minutesOf(seg.start) / 60));
329
+ to = Math.max(to, sameDay(seg.end, column.day) ? Math.ceil(minutesOf(seg.end) / 60) : 24);
330
+ }
331
+ }
332
+ state.shownHours = { start: from, end: to };
333
+ const lanes = (drawnLanes = lanesFor(columns.length));
334
+ const grid = make('div', { class: 'fd-cal-grid' });
335
+ grid.style.setProperty('--fd-cal-cols', String(columns.length));
336
+ grid.style.setProperty('--fd-cal-span', String(to - from));
337
+ grid.style.setProperty('--fd-cal-slot', String(slot));
338
+ if (columns.length > 7 || (state.view === 'day' && spec.resource))
339
+ grid.style.setProperty('--fd-cal-min', '120px');
340
+ // The heads: each day (a press shows it alone), or each person under the day.
341
+ grid.append(make('div', { class: 'fd-cal-corner', 'aria-hidden': 'true' }));
342
+ for (const column of columns) {
343
+ const head = make('div', { class: 'fd-cal-head', 'aria-current': isToday(column.day) && !column.resource ? 'date' : undefined });
344
+ if (column.resource)
345
+ head.append(make('span', { class: 'fd-cal-head-who' }, make('bdi', {}, column.resource.label)));
346
+ else {
347
+ const button = make('button', { type: 'button', class: 'fd-cal-head-day', 'aria-label': longDay(column.day) }, make('span', {}, format(locale, { weekday: 'short' }).format(column.day)), make('span', { class: 'fd-cal-head-num' }, format(locale, { day: 'numeric' }).format(column.day)));
348
+ if (state.view === 'week' && views.includes('day'))
349
+ button.addEventListener('click', () => showView('day', column.day));
350
+ else
351
+ button.tabIndex = -1;
352
+ head.append(button);
353
+ }
354
+ grid.append(head);
355
+ }
356
+ // A whole day's records over the hours; for a calendar of days alone, the whole of it.
357
+ const daysOnly = kind === 'date';
358
+ const allDay = events.filter((event) => event.allDay);
359
+ if (daysOnly || allDay.length) {
360
+ grid.append(make('div', { class: 'fd-cal-corner' }, labels.allDay));
361
+ columns.forEach((column, i) => {
362
+ const cell = make('div', { class: 'fd-cal-allday', 'data-day': dayKey(column.day), 'data-column': String(i), 'data-alone': daysOnly ? '' : undefined });
363
+ for (const event of allDay)
364
+ if (covers(event, column.day) && inColumn(event, column))
365
+ cell.append(eventBlock(event, column.day, true));
366
+ cell.addEventListener('pointerdown', (e) => {
367
+ if (e.target !== cell || !canCreate || e.button !== 0)
368
+ return;
369
+ void createAt(column.day, null, column, true);
370
+ });
371
+ grid.append(cell);
372
+ });
373
+ }
374
+ if (daysOnly)
375
+ return make('div', { class: 'fd-cal-scroll' }, grid);
376
+ // The hours down the side, then a column of slots for each day or person, its records over them.
377
+ const side = make('div', { class: 'fd-cal-hours', 'aria-hidden': 'true' });
378
+ for (let h = from; h < to; h++) {
379
+ const label = make('span', { class: 'fd-cal-hour' }, time(atMinutes(new Date(2026, 0, 1), h * 60)));
380
+ label.style.top = `${(h - from) * HOUR}px`;
381
+ side.append(label);
382
+ }
383
+ grid.append(side);
384
+ columns.forEach((column, i) => {
385
+ const col = make('div', {
386
+ class: 'fd-cal-col',
387
+ role: 'group',
388
+ 'aria-label': [longDay(column.day), column.resource?.label].filter(Boolean).join(joined),
389
+ 'data-column': String(i),
390
+ 'data-day': dayKey(column.day),
391
+ 'data-today': isToday(column.day) ? '' : undefined,
392
+ });
393
+ if (canCreate) {
394
+ for (let m = from * 60; m < to * 60; m += slot) {
395
+ const at = atMinutes(column.day, m);
396
+ const busy = timed.filter((event) => event.start <= at && event.end > at && inColumn(event, column)).length;
397
+ const words = [longDay(column.day), time(at), column.resource?.label, busy ? (busy === 1 ? labels.holdsOne : fill(labels.holds, { n: busy })) : labels.free].filter(Boolean).join(joined);
398
+ const cell = make('div', { class: 'fd-cal-slot', role: 'button', tabindex: '-1', 'data-minutes': String(m), 'data-focus': `s:${i}:${m}`, 'aria-label': words, 'aria-describedby': keysSlot.id });
399
+ cell.addEventListener('keydown', (e) => onSlotKey(cal, e, i, m));
400
+ // A screen reader's press, with no pointer behind it: a new record there, as Enter makes one.
401
+ cell.addEventListener('click', (e) => {
402
+ if (e.detail === 0)
403
+ void createAt(atMinutes(column.day, m), null, column);
404
+ });
405
+ cell.addEventListener('focus', () => (state.cursor = { day: column.day, minutes: m, column: i }));
406
+ col.append(cell);
407
+ }
408
+ col.addEventListener('pointerdown', (e) => pressSlot(cal, e, col, i));
409
+ }
410
+ const mine = timed.flatMap((event) => {
411
+ const seg = segment(event, column.day);
412
+ return seg && inColumn(event, column) ? [{ event, ...seg }] : [];
413
+ });
414
+ const placed = sideBySide(mine.map(({ event, start, end }) => ({ id: event.id, start, end })));
415
+ /** Where a part of a day sits in the column: its top and its height, never under a finger's target. */
416
+ const span = (start, end) => [(minutesOf(start) - from * 60) * (HOUR / 60), Math.max(TARGET, ((sameDay(end, column.day) ? minutesOf(end) : 24 * 60) - minutesOf(start)) * (HOUR / 60) - 2)];
417
+ const across = (el, lane, of) => {
418
+ el.style.insetInlineStart = `calc(${(lane / of) * 100}% + 1px)`;
419
+ el.style.width = `calc(${100 / of}% - 3px)`;
420
+ };
421
+ // A crowd wider than its lanes read shows its first records, and "+N more" in the last lane for the rest.
422
+ const held = [];
423
+ /** A record's block in its lane of so many. */
424
+ const put = ({ event, start, end }, lane, of) => {
425
+ // In a person's own column, the block need not name them again.
426
+ const block = eventBlock(event, column.day, false, !!column.resource);
427
+ const [top, height] = span(start, end);
428
+ block.style.top = `${top}px`;
429
+ block.style.height = `${height}px`;
430
+ block.style.setProperty('--fd-cal-lines', String(linesIn(height)));
431
+ across(block, lane, of);
432
+ if (height < 36)
433
+ block.setAttribute('data-short', '');
434
+ // Its end stretches, when it has one to write and it ends on this day.
435
+ if (block.hasAttribute('data-moves') && spec.end && sameDay(end, column.day) && sameDay(start, event.start)) {
436
+ const grip = make('span', { class: 'fd-cal-resize', 'aria-hidden': 'true' });
437
+ grip.addEventListener('pointerdown', (e) => stretch(cal, e, event, block, col));
438
+ block.append(grip);
439
+ }
440
+ col.append(block);
441
+ };
442
+ for (const m of mine) {
443
+ const place = placed.get(m.event.id) ?? { column: 0, columns: 1 };
444
+ if (place.columns > lanes && place.column >= lanes - 1)
445
+ held.push(m);
446
+ else
447
+ put(m, place.column, Math.min(place.columns, lanes));
448
+ }
449
+ // Those held back, a "+N more" for each run of them that meet, over their own time, which lists all the column holds
450
+ // then; one alone is drawn itself in the last lane.
451
+ const holdBack = (out) => {
452
+ if (out.length < 2)
453
+ return out.length && put(out[0], lanes - 1, lanes);
454
+ const [first, last] = [Math.min(...out.map((m) => m.start.getTime())), Math.max(...out.map((m) => m.end.getTime()))].map((t) => new Date(t));
455
+ const [top, height] = span(first, last);
456
+ const n = out.length;
457
+ const words = fill(labels.moreOn, { n, when: [longDay(column.day), time(first), column.resource?.label].filter(Boolean).join(joined) });
458
+ const more = make('button', { type: 'button', class: 'fd-cal-more', 'data-holds': out.map((m) => String(m.event.id)).join(' '), 'data-focus': `m:${i}:${minutesOf(first)}`, 'aria-haspopup': 'dialog', 'aria-label': words, title: words }, fill(labels.more, { n }));
459
+ more.style.top = `${top}px`;
460
+ more.style.height = `${height}px`;
461
+ across(more, lanes - 1, lanes);
462
+ more.addEventListener('click', () => openCrowd(mine.filter((m) => m.start < last && m.end > first), column, more));
463
+ col.append(more);
464
+ };
465
+ let run = [];
466
+ let until = 0;
467
+ for (const m of held.sort((a, b) => a.start.getTime() - b.start.getTime())) {
468
+ if (m.start.getTime() >= until) {
469
+ holdBack(run);
470
+ run = [];
471
+ }
472
+ run.push(m);
473
+ until = Math.max(until, m.end.getTime());
474
+ }
475
+ holdBack(run);
476
+ grid.append(col);
477
+ });
478
+ // The slots' one stop: where the cursor is.
479
+ const stop = grid.querySelector(`[data-focus="s:${state.cursor.column}:${clampMinutes(state.cursor.minutes)}"]`) ?? grid.querySelector('.fd-cal-slot');
480
+ if (stop)
481
+ stop.tabIndex = 0;
482
+ return make('div', { class: 'fd-cal-scroll' }, grid);
483
+ }
484
+ /**
485
+ * How many records may sit side by side in a column of the day or the week, past which "+N more" holds the rest: as
486
+ * many as its width shows the start of a name in, two at least.
487
+ */
488
+ function lanesFor(count) {
489
+ const width = body.clientWidth;
490
+ if (!width)
491
+ return LANES;
492
+ return Math.max(2, Math.floor(Math.max((width - 52) / count, count > 7 || (state.view === 'day' && spec.resource) ? 120 : 0) / LANE));
493
+ }
494
+ let drawnLanes = 0;
495
+ // A calendar made wider or narrower, or shown after being hidden, draws its crowds again when their lanes change.
496
+ // Never under a drag, whose block it would take away.
497
+ const resized = typeof ResizeObserver === 'function' ? new ResizeObserver(() => (state.view === 'day' || state.view === 'week') && state.columns.length && lanesFor(state.columns.length) !== drawnLanes && !failed && !body.querySelector('[data-moving]') && draw()) : null;
498
+ resized?.observe(body);
499
+ // ---- a crowded slot's list ------------------------------------------------------------------------------------
500
+ let crowdBox = null;
501
+ /** The "+N more" the list opened from, by its key, as a redraw makes it again: where Escape puts the keys back. */
502
+ let crowdFrom = '';
503
+ const crowdOutside = (e) => crowdBox && !crowdBox.contains(e.target) && closeCrowd();
504
+ function closeCrowd(back = false) {
505
+ crowdBox?.remove();
506
+ crowdBox = null;
507
+ doc.removeEventListener('pointerdown', crowdOutside, true);
508
+ if (back)
509
+ body.querySelector(`[data-focus="${crowdFrom}"]`)?.focus();
510
+ }
511
+ /** The records of a crowd too wide to read side by side, as a list under its "+N more": each opened, or moved, as an agenda's row. */
512
+ function openCrowd(crowd, column, more) {
513
+ closeCrowd();
514
+ crowdFrom = more.dataset['focus'] ?? '';
515
+ const [first, last] = [Math.min(...crowd.map((m) => m.start.getTime())), Math.max(...crowd.map((m) => m.end.getTime()))].map((t) => new Date(t));
516
+ const heading = make('h3', { class: 'fd-cal-move-title', id: `${id}-crowd` }, [longDay(column.day), fill(labels.fromTo, { from: time(first), to: time(last) }), column.resource?.label].filter(Boolean).join(joined));
517
+ const shut = make('button', { type: 'button', class: 'fd-button fd-button-link', 'aria-label': labels.close, title: labels.close }, '×');
518
+ shut.addEventListener('click', () => closeCrowd(true));
519
+ const list = make('ul', { class: 'fd-cal-agenda-list' }, ...crowd.map(({ event }) => make('li', {}, eventBlock(event, column.day, false, !!column.resource, true))));
520
+ crowdBox = make('div', { class: 'fd-cal-crowd', role: 'dialog', 'aria-labelledby': heading.id }, make('div', { class: 'fd-cal-crowd-head' }, heading, shut), list);
521
+ crowdBox.addEventListener('keydown', (k) => {
522
+ if (k.key !== 'Escape')
523
+ return;
524
+ k.preventDefault();
525
+ k.stopPropagation();
526
+ closeCrowd(true);
527
+ });
528
+ placeUnder(cal, crowdBox, more);
529
+ list.querySelector('[data-focus]')?.focus();
530
+ doc.addEventListener('pointerdown', crowdOutside, true);
531
+ }
532
+ const clampMinutes = (m) => Math.min(Math.max(state.shownHours.start * 60, m - (m % slot)), state.shownHours.end * 60 - slot);
533
+ /** The part of an event on one day. */
534
+ function segment(event, day) {
535
+ const dayStart = startOfDay(day);
536
+ const dayEnd = addDays(dayStart, 1);
537
+ if (event.end <= dayStart || event.start >= dayEnd)
538
+ return null;
539
+ return { start: event.start > dayStart ? event.start : dayStart, end: event.end < dayEnd ? event.end : dayEnd };
540
+ }
541
+ const covers = (event, day) => !!segment(event, day);
542
+ const inColumn = (event, column) => !column.resource || column.resource.key === (event.resource ?? '');
543
+ // ---- the month -----------------------------------------------------------------------------------------------
544
+ function monthView(shown) {
545
+ const grid = make('div', { class: 'fd-cal-month' });
546
+ for (const day of shown.days.slice(0, 7))
547
+ grid.append(make('div', { class: 'fd-cal-weekday', 'aria-hidden': 'true' }, format(locale, { weekday: 'short' }).format(day)));
548
+ const cursorKey = shown.days.some((d) => sameDay(d, state.cursor.day)) ? dayKey(state.cursor.day) : dayKey(shown.days.find(isToday) ?? state.anchor);
549
+ for (const day of shown.days) {
550
+ const mine = events.filter((event) => covers(event, day));
551
+ const cell = make('div', { class: 'fd-cal-day', 'data-day': dayKey(day), 'data-out': day.getMonth() !== state.anchor.getMonth() ? '' : undefined, 'aria-current': isToday(day) ? 'date' : undefined });
552
+ const number = make('button', { type: 'button', class: 'fd-cal-daynum', tabindex: dayKey(day) === cursorKey ? '0' : '-1', 'data-focus': `d:${dayKey(day)}`, 'aria-label': [longDay(day), mine.length ? (mine.length === 1 ? labels.holdsOne : fill(labels.holds, { n: mine.length })) : ''].filter(Boolean).join(joined), 'aria-describedby': keysDay.id }, format(locale, { day: 'numeric' }).format(day));
553
+ number.addEventListener('click', () => (views.includes('day') ? showView('day', day) : undefined));
554
+ number.addEventListener('keydown', (e) => onDayKey(cal, e, day));
555
+ number.addEventListener('focus', () => (state.cursor = { ...state.cursor, day }));
556
+ cell.append(number);
557
+ for (const event of mine.slice(0, IN_A_DAY))
558
+ cell.append(eventBlock(event, day, true));
559
+ if (mine.length > IN_A_DAY) {
560
+ const n = mine.length - IN_A_DAY;
561
+ const more = make('button', { type: 'button', class: 'fd-cal-more', 'data-holds': mine.slice(IN_A_DAY).map((event) => String(event.id)).join(' '), 'aria-label': fill(labels.moreOn, { n, when: longDay(day) }) }, fill(labels.more, { n }));
562
+ more.addEventListener('click', () => (views.includes('day') ? showView('day', day) : showView(views.includes('agenda') ? 'agenda' : state.view, day)));
563
+ cell.append(more);
564
+ }
565
+ cell.addEventListener('pointerdown', (e) => {
566
+ if (e.target !== cell || !canCreate || e.button !== 0)
567
+ return;
568
+ void createAt(day, null, null, kind === 'date');
569
+ });
570
+ grid.append(cell);
571
+ }
572
+ return grid;
573
+ }
574
+ // ---- the agenda ---------------------------------------------------------------------------------------------
575
+ function agendaView(shown) {
576
+ const list = make('div', { class: 'fd-cal-agenda' });
577
+ for (const day of shown.days) {
578
+ const mine = events.filter((event) => covers(event, day));
579
+ if (!mine.length)
580
+ continue;
581
+ const items = make('ul', { class: 'fd-cal-agenda-list' });
582
+ for (const event of mine)
583
+ items.append(make('li', {}, eventBlock(event, day, false)));
584
+ list.append(make('section', { class: 'fd-cal-agenda-day', 'aria-current': isToday(day) ? 'date' : undefined, 'aria-label': longDay(day) }, make('h3', { class: 'fd-cal-agenda-head' }, longDay(day)), items));
585
+ }
586
+ if (!list.childElementCount)
587
+ list.append(make('p', { class: 'fd-cal-empty' }, labels.nothing));
588
+ return list;
589
+ }
590
+ // ---- now --------------------------------------------------------------------------------------------------
591
+ /** The line across today's columns at this minute, moved on each minute. */
592
+ function placeNow() {
593
+ for (const old of body.querySelectorAll('.fd-cal-now'))
594
+ old.remove();
595
+ if (state.view !== 'day' && state.view !== 'week')
596
+ return;
597
+ const at = now();
598
+ const minutes = minutesOf(at);
599
+ if (minutes < state.shownHours.start * 60 || minutes >= state.shownHours.end * 60)
600
+ return;
601
+ for (const col of body.querySelectorAll('.fd-cal-col[data-today]')) {
602
+ const line = make('div', { class: 'fd-cal-now', 'aria-hidden': 'true' });
603
+ line.style.top = `${(minutes - state.shownHours.start * 60) * (HOUR / 60)}px`;
604
+ col.append(line);
605
+ }
606
+ }
607
+ const ticking = setInterval(placeNow, 60000);
608
+ // ---- going somewhere ------------------------------------------------------------------------------------------
609
+ function go(day) {
610
+ state.anchor = startOfDay(day);
611
+ state.cursor = { ...state.cursor, day: state.anchor, column: 0 };
612
+ void reload();
613
+ }
614
+ function showView(next, day) {
615
+ state.view = next;
616
+ if (day)
617
+ state.anchor = startOfDay(day);
618
+ state.cursor = { ...state.cursor, day: day ? startOfDay(day) : state.cursor.day, column: 0 };
619
+ void reload();
620
+ }
621
+ // ---- making a record --------------------------------------------------------------------------------------
622
+ /** Where New puts a record: the slot the keys stand on, else the next whole hour today in the span, else its first day. */
623
+ function newPlace() {
624
+ const shown = range();
625
+ if ((state.view === 'day' || state.view === 'week') && kind === 'datetime' && body.querySelector('.fd-cal-slot')) {
626
+ const column = state.columns[state.cursor.column] ?? state.columns[0];
627
+ return [atMinutes(column.day, clampMinutes(state.cursor.minutes)), null, column, false];
628
+ }
629
+ const at = now();
630
+ const inSpan = at >= shown.from && at < shown.to;
631
+ const day = inSpan ? startOfDay(at) : shown.from;
632
+ if (kind === 'date')
633
+ return [day, null, null, true];
634
+ const minutes = inSpan ? Math.min(Math.max(hours.start * 60, (Math.floor(minutesOf(at) / 60) + 1) * 60), (hours.end - 1) * 60) : hours.start * 60;
635
+ return [atMinutes(day, minutes), null, null, false];
636
+ }
637
+ /** A new record from `start` (to `end`, or an hour on; the whole day for a day's), with its column's person. */
638
+ async function createAt(start, end, column, wholeDay = false) {
639
+ if (!context.create)
640
+ return;
641
+ const values = {};
642
+ if (kind === 'date') {
643
+ values[spec.start] = writeWhen(start, 'date');
644
+ if (spec.end)
645
+ values[spec.end] = writeWhen(end ? addDays(end, -1) : start, 'date');
646
+ }
647
+ else {
648
+ const from = wholeDay ? startOfDay(start) : start;
649
+ const until = end ?? (wholeDay ? addDays(from, 1) : addMinutes(from, NEW_LENGTH));
650
+ values[spec.start] = writeWhen(from, 'datetime');
651
+ if (spec.end)
652
+ values[spec.end] = writeWhen(until, 'datetime');
653
+ }
654
+ if (spec.resource && column?.resource && column.resource.key !== '')
655
+ values[spec.resource] = column.resource.value;
656
+ await context.create(values);
657
+ }
658
+ // ---- opening, and writing a move --------------------------------------------------------------------------
659
+ function open(event) {
660
+ if (!context.open)
661
+ return;
662
+ const answer = context.open(event.id);
663
+ if (answer instanceof Promise)
664
+ void answer.then(() => (destroyed ? undefined : reload()));
665
+ }
666
+ /**
667
+ * Where a record is now, for the keys to stand on: its block on that day, else any of its blocks, else the "+N more"
668
+ * that holds it; else, when the record is in none of the days shown, the bar's step toward its day.
669
+ */
670
+ function place(recordId, day) {
671
+ const key = String(recordId);
672
+ const found = body.querySelector(`[data-focus="e:${key}:${dayKey(day)}"][tabindex="0"]`) ??
673
+ body.querySelector(`[data-focus^="e:${key}:"][tabindex="0"]`) ??
674
+ [...body.querySelectorAll('[data-holds]')].find((more) => more.dataset['holds']?.split(' ').includes(key));
675
+ if (found)
676
+ return found;
677
+ const shown = range();
678
+ return day < shown.from ? back : day >= shown.to ? on : today;
679
+ }
680
+ /**
681
+ * A move's save: the fields it writes and no others, in `values` as in `changes`, and `partial` saying so, so a
682
+ * source that saves whole records keeps every field the calendar never had, and every change made elsewhere since.
683
+ */
684
+ const moveSave = (recordId, values, version) => ({
685
+ model,
686
+ id: recordId,
687
+ fields: Object.fromEntries(Object.keys(values).map((name) => [name, page.fields[name]])),
688
+ changes: { values, lines: {}, links: {} },
689
+ values: { ...values },
690
+ partial: true,
691
+ ...(version ? { version } : {}),
692
+ });
693
+ /**
694
+ * Write a move through the data source's save — at once on the page, put back if the save is refused — and offer
695
+ * Undo, which writes back what those fields held. Whether it was saved.
696
+ */
697
+ async function commit(event, values, words) {
698
+ if (!source?.save || !Object.keys(values).length) {
699
+ draw();
700
+ return false;
701
+ }
702
+ const record = event.record;
703
+ const before = Object.fromEntries(Object.keys(values).map((key) => [key, record.values[key] ?? null]));
704
+ const after = { ...record, values: { ...record.values, ...values } };
705
+ records = records.map((r) => (r === record ? after : r));
706
+ events = eventsOf(records, spec, page, locale);
707
+ draw();
708
+ for (const block of body.querySelectorAll('.fd-cal-event'))
709
+ if (block.dataset['id'] === String(event.id))
710
+ block.setAttribute('aria-busy', 'true');
711
+ say('');
712
+ alert.replaceChildren();
713
+ let saved = false;
714
+ try {
715
+ const result = await source.save(moveSave(event.id, values, record.version));
716
+ undo = { record: { ...after, ...(result.version ? { version: result.version } : {}) }, values: before, ...(result.version ? { version: result.version } : {}), at: event.start };
717
+ say(words, { id: event.id, at: eventsOf([after], spec, page, locale)[0]?.start ?? event.start }, true);
718
+ saved = true;
719
+ }
720
+ catch (error) {
721
+ records = records.map((r) => (r === after ? record : r));
722
+ events = eventsOf(records, spec, page, locale);
723
+ const problem = saveProblemOf(error);
724
+ const why = problem.message || Object.values(problem.fields ?? {}).join(' ');
725
+ alert.textContent = fill(labels.notMoved, { why });
726
+ draw();
727
+ }
728
+ if (!destroyed)
729
+ await reload();
730
+ return saved;
731
+ }
732
+ /** Undo: the moved fields as they were, and no others; the keys, when they were on Undo, on the record where it is put back. */
733
+ async function undoLast() {
734
+ const last = undo;
735
+ if (!last || !source?.save)
736
+ return;
737
+ const keys = said.contains(doc.activeElement);
738
+ undo = null;
739
+ say('');
740
+ try {
741
+ await source.save(moveSave(last.record.id, last.values, last.version));
742
+ say(labels.undone, { id: last.record.id, at: last.at });
743
+ }
744
+ catch (error) {
745
+ alert.textContent = fill(labels.notMoved, { why: saveProblemOf(error).message });
746
+ }
747
+ if (destroyed)
748
+ return;
749
+ await reload();
750
+ if (keys && (!doc.activeElement || doc.activeElement === doc.body))
751
+ place(last.record.id, last.at).focus();
752
+ }
753
+ /** Words in the status line about a record, with Undo after a move and a way to close them, which leaves the keys on that record. */
754
+ function say(words, about, withUndo = false) {
755
+ said.replaceChildren();
756
+ if (!words)
757
+ return;
758
+ said.append(make('span', {}, words));
759
+ if (withUndo) {
760
+ const undoButton = make('button', { type: 'button', class: 'fd-button fd-button-link' }, labels.undo);
761
+ undoButton.addEventListener('click', () => void undoLast());
762
+ said.append(undoButton);
763
+ }
764
+ const close = make('button', { type: 'button', class: 'fd-button fd-button-link', 'aria-label': labels.close, title: labels.close }, '×');
765
+ close.addEventListener('click', () => {
766
+ const keys = said.contains(doc.activeElement);
767
+ said.replaceChildren();
768
+ if (keys && about)
769
+ place(about.id, about.at).focus();
770
+ });
771
+ said.append(close);
772
+ }
773
+ // ---- what its gestures, its keys and Move to… reach ------------------------------------------------------------
774
+ const cal = {
775
+ id,
776
+ doc,
777
+ element,
778
+ body,
779
+ page,
780
+ spec,
781
+ locale,
782
+ labels,
783
+ slot,
784
+ hour: HOUR,
785
+ firstDay,
786
+ canOpen,
787
+ state,
788
+ make,
789
+ rtl,
790
+ time,
791
+ segment,
792
+ clampMinutes,
793
+ draw,
794
+ reload,
795
+ open,
796
+ createAt,
797
+ commit,
798
+ place,
799
+ openMove: (event, target) => mover.open(event, target),
800
+ };
801
+ const mover = moveTo(cal);
802
+ drawTitle(range());
803
+ return {
804
+ element,
805
+ reload,
806
+ highlight(recordId) {
807
+ for (const block of body.querySelectorAll('.fd-cal-event'))
808
+ block.classList.toggle('fd-cal-hot', recordId !== null && block.dataset['id'] === String(recordId));
809
+ },
810
+ chosenChanged() {
811
+ // A calendar has no boxes to choose a record: what is chosen is the table's.
812
+ },
813
+ shown() {
814
+ placeNow();
815
+ },
816
+ destroy() {
817
+ destroyed = true;
818
+ clearInterval(ticking);
819
+ resized?.disconnect();
820
+ mover.close();
821
+ closeCrowd();
822
+ element.remove();
823
+ },
824
+ };
825
+ }
826
+ //# sourceMappingURL=calendar-view.mjs.map