mnfst 0.5.119 → 0.5.122

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.
@@ -0,0 +1,1208 @@
1
+ /* Manifest Date Picker
2
+ /* By Andrew Matlock under MIT license
3
+ /* https://manifestx.dev
4
+ /*
5
+ /* A fully modular date (and time) picker behind one `x-date` directive.
6
+ /*
7
+ /* The element decides its role — no role modifiers needed:
8
+ /* <input x-date> field with auto-generated dropdown calendar
9
+ /* <input x-date type="date"> same, degrades to the native picker without JS
10
+ /* <input x-date type="time"> standalone time picker
11
+ /* <input x-date type="datetime-local"> date + time, degrades natively without JS
12
+ /* <button x-date="cal-id"> button trigger for an authored calendar
13
+ /* <div x-date> inline calendar
14
+ /* <menu popover x-date> / <dialog x-date> dropdown / modal calendar
15
+ /*
16
+ /* Selection modes are modifiers: x-date.range, x-date.multiple, x-date.time
17
+ /* (x-date.time on a date field appends a time-of-day control).
18
+ /*
19
+ /* The value is polymorphic, mirroring the color picker's swatch:
20
+ /* string → the id of an authored calendar element (fields only)
21
+ /* object → reactive config: { months, min, max, disabled, presets }
22
+ */
23
+
24
+ (function () {
25
+ 'use strict';
26
+
27
+ /* ------------------------------------------------------------------ *
28
+ * Shared global: ManifestDates — in-house, dependency-free date math.
29
+ * Operates on date-only values anchored at noon local time, which makes
30
+ * every add/subtract immune to DST shifts (a ±1h move never crosses a
31
+ * calendar day from noon). All formatting/localization is delegated to
32
+ * Intl, so we never parse locale-formatted strings — the one place a date
33
+ * library would earn its weight. Thin by design: swappable for Temporal.
34
+ * ------------------------------------------------------------------ */
35
+ if (!window.ManifestDates) {
36
+ const pad = (n) => String(n).padStart(2, '0');
37
+ const D = {
38
+ /* Construct a noon-anchored Date from y/m/d (m is 1-based). */
39
+ make(y, m, d) { return new Date(y, m - 1, d, 12, 0, 0, 0); },
40
+ /* Today, noon-anchored. */
41
+ today() { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), n.getDate(), 12); },
42
+ /* Serialize to ISO yyyy-mm-dd from LOCAL parts (matches what the user sees). */
43
+ toISO(date) { if (!(date instanceof Date) || isNaN(date)) return ''; return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()); },
44
+ /* Parse an ISO yyyy-mm-dd (or anything Date accepts) to a noon-anchored Date; null on failure. */
45
+ fromISO(str) {
46
+ if (str instanceof Date) return isNaN(str) ? null : new Date(str.getFullYear(), str.getMonth(), str.getDate(), 12);
47
+ if (typeof str !== 'string') return null;
48
+ const m = str.trim().match(/^(\d{4})-(\d{2})-(\d{2})/);
49
+ if (m) return D.make(+m[1], +m[2], +m[3]);
50
+ const d = new Date(str);
51
+ return isNaN(d) ? null : new Date(d.getFullYear(), d.getMonth(), d.getDate(), 12);
52
+ },
53
+ addDays(date, n) { const d = new Date(date); d.setDate(d.getDate() + n); return d; },
54
+ addMonths(date, n) {
55
+ const d = new Date(date);
56
+ const targetMonth = d.getMonth() + n;
57
+ const y = d.getFullYear() + Math.floor(targetMonth / 12);
58
+ const m = ((targetMonth % 12) + 12) % 12;
59
+ const day = Math.min(d.getDate(), D.daysInMonth(y, m + 1));
60
+ return D.make(y, m + 1, day);
61
+ },
62
+ startOfMonth(date) { return D.make(date.getFullYear(), date.getMonth() + 1, 1); },
63
+ daysInMonth(y, m) { return new Date(y, m, 0).getDate(); }, // m is 1-based
64
+ isSameDay(a, b) { return !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); },
65
+ compare(a, b) { const x = D.toISO(a), y = D.toISO(b); return x < y ? -1 : x > y ? 1 : 0; },
66
+ clamp(date, min, max) { if (min && D.compare(date, min) < 0) return min; if (max && D.compare(date, max) > 0) return max; return date; },
67
+ /* First day of week for a locale, normalized to 0=Sun..6=Sat. */
68
+ firstDayOfWeek(locale) {
69
+ try {
70
+ const loc = new Intl.Locale(locale);
71
+ const info = (typeof loc.getWeekInfo === 'function' ? loc.getWeekInfo() : loc.weekInfo);
72
+ if (info && info.firstDay) return info.firstDay === 7 ? 0 : info.firstDay; // Intl: 1=Mon..7=Sun
73
+ } catch (_) { }
74
+ // Fallback: US-style locales start Sunday, most others Monday.
75
+ return /^en(-US|-CA|-PH)?$/i.test(locale || '') ? 0 : 1;
76
+ }
77
+ };
78
+ window.ManifestDates = D;
79
+ }
80
+
81
+ /* ------------------------------------------------------------------ *
82
+ * Shared global: ManifestUI — universal `_ui` text resolver.
83
+ * Any Manifest element with baked-in default chrome (kept intentionally
84
+ * rare) can localize/override that text without rebuilding the element.
85
+ * `_ui` is a reserved, self-identifying key: any loaded data source may
86
+ * carry a top-level `_ui` object, namespaced per element, e.g.
87
+ * _ui: { date: { today: …, clear: … }, colorpicker: { … } }
88
+ * No manifest flag — overrides piggyback on the normal local-data/
89
+ * localization model and can be colocated with author content. Values may
90
+ * be plain strings or $x/$locale references (locale-reactive). resolve()
91
+ * deep-merges every loaded source's `_ui[component]` onto the plugin's
92
+ * English fallbacks. Defensive: any failure returns the fallbacks untouched.
93
+ * Kept byte-identical across the colorpicker / charts copies.
94
+ * ------------------------------------------------------------------ */
95
+ if (!window.ManifestUI) {
96
+ window.ManifestUI = {
97
+ /* Names of data sources that have loaded (current locale). Enumerates loaded
98
+ * sources only — never force-loads others just to scan them for `_ui`. */
99
+ _loadedSourceNames() {
100
+ try {
101
+ const store = window.ManifestDataStore && window.ManifestDataStore.rawDataStore;
102
+ if (store && typeof store.keys === 'function') return [...store.keys()];
103
+ } catch (_) { }
104
+ return [];
105
+ },
106
+ /* Deep-merge every loaded source's `_ui[component]` onto `fallbacks`.
107
+ * Reads inside the caller's Alpine effect (if any) so $x/$locale make it reactive. */
108
+ resolve(component, fallbacks) {
109
+ const merged = JSON.parse(JSON.stringify(fallbacks || {}));
110
+ try {
111
+ if (!window.Alpine || typeof Alpine.evaluate !== 'function') return merged;
112
+ try { Alpine.evaluate(document.body, '$locale && $locale.current'); } catch (_) { } // dep → re-resolve on locale switch
113
+ for (const name of this._loadedSourceNames()) {
114
+ let ui;
115
+ try { ui = Alpine.evaluate(document.body, `$x['${name}'] && $x['${name}']._ui && $x['${name}']._ui['${component}']`); } catch (_) { ui = null; }
116
+ if (ui && typeof ui === 'object' && !Array.isArray(ui)) this._deepOverlay(merged, ui);
117
+ }
118
+ } catch (_) { }
119
+ return merged;
120
+ },
121
+ _deepOverlay(target, src) {
122
+ for (const k of Object.keys(src)) {
123
+ if (k.startsWith('$') || k === 'contentType' || k === 'valueOf' || k === 'toString') continue;
124
+ const v = src[k];
125
+ if (typeof v === 'function') continue;
126
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
127
+ if (!target[k] || typeof target[k] !== 'object') target[k] = {};
128
+ this._deepOverlay(target[k], v);
129
+ } else if (v !== undefined && v !== null && v !== '') {
130
+ target[k] = v;
131
+ }
132
+ }
133
+ }
134
+ };
135
+ }
136
+
137
+ /* ================================================================== *
138
+ * Date picker plugin
139
+ * ================================================================== */
140
+ function initializeDatepickerPlugin() {
141
+ const Alpine = window.Alpine;
142
+ const D = window.ManifestDates;
143
+
144
+ // Reactive registry: id -> api, so $date('id') resolves even when the
145
+ // calendar mounts after the reader binds.
146
+ const _registry = Alpine.reactive ? Alpine.reactive({}) : {};
147
+ let _uid = 0;
148
+
149
+ // Default English UI chrome; overridable via a data source's `_ui.date`.
150
+ const UI_FALLBACK = {
151
+ today: 'Today',
152
+ now: 'Now',
153
+ clear: 'Clear',
154
+ previousMonth: 'Previous month',
155
+ nextMonth: 'Next month',
156
+ time: 'Time'
157
+ };
158
+
159
+ // A no-op api so bindings never throw before a calendar exists.
160
+ const _nullApi = {
161
+ value: '', iso: '', formatted: '', date: null,
162
+ setDate() { }, clear() { }, open() { }, close() { },
163
+ [Symbol.toPrimitive]() { return ''; }, toString() { return ''; }, valueOf() { return ''; }
164
+ };
165
+
166
+ const currentLocale = () => {
167
+ try { return Alpine.store('locale')?.current || document.documentElement.lang || 'en'; } catch (_) { return document.documentElement.lang || 'en'; }
168
+ };
169
+
170
+ function findAncestorState(el) {
171
+ let n = el;
172
+ while (n) { if (n._dateState) return n._dateState; n = n.parentElement; }
173
+ return null;
174
+ }
175
+
176
+ const pad2 = (n) => String(n).padStart(2, '0');
177
+
178
+ // Normalize a firstDay config value to 0=Sun..6=Sat, or null (locale default).
179
+ // Accepts 0–6, or a weekday name ('sunday', 'mon', …).
180
+ const _dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
181
+ function parseFirstDay(v) {
182
+ if (v == null || v === '') return null;
183
+ if (typeof v === 'number' && v >= 0 && v <= 6) return v;
184
+ const s = String(v).toLowerCase().trim();
185
+ const i = _dayNames.findIndex(n => n === s || n.slice(0, 3) === s);
186
+ if (i >= 0) return i;
187
+ const n = parseInt(s, 10);
188
+ return (!isNaN(n) && n >= 0 && n <= 6) ? n : null;
189
+ }
190
+
191
+ // Intl gives typographically-correct names ("juin", "lun."), but chrome
192
+ // labels should read like an author wrote them: capitalized, no
193
+ // abbreviation periods. aria-labels keep the untouched Intl output.
194
+ function tidy(s, locale) {
195
+ if (!s) return s;
196
+ let out = s.replace(/\.+$/, '');
197
+ try { out = out.charAt(0).toLocaleUpperCase(locale) + out.slice(1); }
198
+ catch (_) { out = out.charAt(0).toUpperCase() + out.slice(1); }
199
+ return out;
200
+ }
201
+
202
+ // 12-hour clock locales (en-US etc.) get an AM/PM segment.
203
+ function uses12h(locale) {
204
+ try { return !!new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12; }
205
+ catch (_) { return false; }
206
+ }
207
+
208
+ /* ---- Per-calendar state ------------------------------------- */
209
+ function createState(rootEl) {
210
+ const today = D.today();
211
+ const now = new Date();
212
+ const state = {
213
+ rootEl,
214
+ id: rootEl.id || ('mnfst-dp-' + (++_uid)),
215
+ selectionMode: 'single', // 'single' | 'range' | 'multiple'
216
+ selected: null, // Date | null (single)
217
+ rangeStart: null, rangeEnd: null, rangeHover: null, // range
218
+ selectedDates: [], // Date[] (multiple)
219
+ view: { y: today.getFullYear(), m: today.getMonth() + 1 },
220
+ viewMode: 'days', // 'days' | 'months' | 'years'
221
+ monthCount: 1, // months shown side by side (two-up = 2)
222
+ firstDay: null, // 0=Sun..6=Sat override; null = locale default
223
+ presets: [], // [{ label, value | start+end | dates[] }]
224
+ withTime: false, // append a time-of-day control (single mode)
225
+ timeOnly: false, // standalone time picker (input type="time")
226
+ time: pad2(now.getHours()) + ':' + pad2(now.getMinutes()),
227
+ timeSet: false, // time becomes part of the value once touched/seeded
228
+ showNow: true, // Now / Today action (config: now:false)
229
+ showClear: true, // Clear action (config: clear:false)
230
+ min: null, max: null,
231
+ disabled: [], // ISO strings, {from,to} ranges, or a predicate fn
232
+ mode: 'inline', // 'inline' (authored container) | 'menu' (plugin-owned dropdown)
233
+ field: null, // trigger element, if any
234
+ fieldPlaceholder: '', // captured initial label/placeholder text
235
+ hiddenInput: null,
236
+ modelGet: null, modelSet: null,
237
+ snapshot: Alpine.reactive ? Alpine.reactive({ version: 0 }) : { version: 0 },
238
+ _mounted: false,
239
+
240
+ bump() { this.snapshot.version++; },
241
+ locale() { return currentLocale(); },
242
+
243
+ isDisabled(date) {
244
+ if (this.min && D.compare(date, this.min) < 0) return true;
245
+ if (this.max && D.compare(date, this.max) > 0) return true;
246
+ const dis = this.disabled;
247
+ if (typeof dis === 'function') { try { return !!dis(date); } catch (_) { return false; } }
248
+ if (Array.isArray(dis)) {
249
+ const iso = D.toISO(date);
250
+ return dis.some(d => {
251
+ if (typeof d === 'string') return d === iso;
252
+ if (d && d.from && d.to) return iso >= d.from && iso <= d.to;
253
+ return false;
254
+ });
255
+ }
256
+ return false;
257
+ },
258
+
259
+ formatValue(date) {
260
+ if (!date) return '';
261
+ try { return new Intl.DateTimeFormat(this.locale(), { dateStyle: 'medium' }).format(date); }
262
+ catch (_) { return D.toISO(date); }
263
+ },
264
+
265
+ formatTime() {
266
+ try {
267
+ const [h, mi] = this.time.split(':').map(Number);
268
+ const dt = new Date(2000, 0, 1, h || 0, mi || 0);
269
+ return new Intl.DateTimeFormat(this.locale(), { timeStyle: 'short' }).format(dt);
270
+ } catch (_) { return this.time; }
271
+ },
272
+
273
+ // Human-readable display for the field / trigger label.
274
+ displayValue() {
275
+ if (this.timeOnly) return this.timeSet ? this.formatTime() : '';
276
+ if (this.selectionMode === 'range') {
277
+ if (this.rangeStart && this.rangeEnd) return this.formatValue(this.rangeStart) + ' – ' + this.formatValue(this.rangeEnd);
278
+ if (this.rangeStart) return this.formatValue(this.rangeStart) + ' – …';
279
+ return '';
280
+ }
281
+ if (this.selectionMode === 'multiple') {
282
+ if (!this.selectedDates.length) return '';
283
+ if (this.selectedDates.length === 1) return this.formatValue(this.selectedDates[0]);
284
+ return this.selectedDates.length + ' dates';
285
+ }
286
+ if (this.withTime && this.selected) {
287
+ try {
288
+ const [h, mi] = this.time.split(':').map(Number);
289
+ const dt = new Date(this.selected.getFullYear(), this.selected.getMonth(), this.selected.getDate(), h || 0, mi || 0);
290
+ return new Intl.DateTimeFormat(this.locale(), { dateStyle: 'medium', timeStyle: 'short' }).format(dt);
291
+ } catch (_) { return D.toISO(this.selected) + ' ' + this.time; }
292
+ }
293
+ return this.formatValue(this.selected);
294
+ },
295
+
296
+ // The committed-or-previewing range, normalized start ≤ end.
297
+ activeRange() {
298
+ if (this.selectionMode !== 'range') return null;
299
+ let s = this.rangeStart, e = this.rangeEnd;
300
+ if (s && !e && this.rangeHover) e = this.rangeHover;
301
+ if (s && e && D.compare(e, s) < 0) { const t = s; s = e; e = t; }
302
+ return { start: s, end: e };
303
+ },
304
+
305
+ // (Re)apply selection/today state to a day button. Selection and
306
+ // today are ARIA attributes (aria-selected / aria-current="date");
307
+ // range shape has no ARIA equivalent, so plain state classes.
308
+ applyDayState(btn, date) {
309
+ btn.classList.remove('range-start', 'range-end', 'in-range');
310
+ btn.removeAttribute('aria-selected');
311
+ if (D.isSameDay(date, D.today())) btn.setAttribute('aria-current', 'date'); else btn.removeAttribute('aria-current');
312
+ if (this.selectionMode === 'range') {
313
+ const r = this.activeRange();
314
+ if (r && r.start && D.isSameDay(date, r.start)) { btn.classList.add('range-start'); btn.setAttribute('aria-selected', 'true'); }
315
+ if (r && r.end && D.isSameDay(date, r.end)) { btn.classList.add('range-end'); btn.setAttribute('aria-selected', 'true'); }
316
+ if (r && r.start && r.end && D.compare(date, r.start) > 0 && D.compare(date, r.end) < 0) btn.classList.add('in-range');
317
+ } else if (this.selectionMode === 'multiple') {
318
+ if (this.selectedDates.some(d => D.isSameDay(d, date))) btn.setAttribute('aria-selected', 'true');
319
+ } else if (this.selected && D.isSameDay(date, this.selected)) {
320
+ btn.setAttribute('aria-selected', 'true');
321
+ }
322
+ },
323
+
324
+ // Repaint range highlight without rebuilding the grid (used on hover).
325
+ paintRange() {
326
+ this.rootEl.querySelectorAll('[role=grid] button').forEach(btn => {
327
+ const d = D.fromISO(btn.value);
328
+ if (d) this.applyDayState(btn, d);
329
+ });
330
+ },
331
+
332
+ select(date) {
333
+ if (!date || this.isDisabled(date)) return;
334
+ // Keep the viewed months as-is when the picked date is already
335
+ // visible (matters in multi-month: clicking a day in the 2nd
336
+ // month must not scroll it into the 1st). Only jump when the
337
+ // date falls outside the visible [view.m … view.m+count-1] window.
338
+ const count = Math.max(1, this.monthCount || 1);
339
+ const idx = (date.getFullYear() * 12 + date.getMonth()) - (this.view.y * 12 + (this.view.m - 1));
340
+ if (idx < 0 || idx >= count) this.view = { y: date.getFullYear(), m: date.getMonth() + 1 };
341
+ if (this.selectionMode === 'range') {
342
+ if (!this.rangeStart || (this.rangeStart && this.rangeEnd)) {
343
+ this.rangeStart = date; this.rangeEnd = null; this.rangeHover = null;
344
+ } else {
345
+ let s = this.rangeStart, e = date;
346
+ if (D.compare(e, s) < 0) { const t = s; s = e; e = t; }
347
+ this.rangeStart = s; this.rangeEnd = e; this.rangeHover = null;
348
+ }
349
+ this.syncOut(); this.bump(); this.render();
350
+ if (this.rangeEnd && this.mode === 'menu') this.close();
351
+ return;
352
+ }
353
+ if (this.selectionMode === 'multiple') {
354
+ const iso = D.toISO(date);
355
+ const idx = this.selectedDates.findIndex(d => D.toISO(d) === iso);
356
+ if (idx >= 0) this.selectedDates.splice(idx, 1);
357
+ else { this.selectedDates.push(date); this.selectedDates.sort((a, b) => D.compare(a, b)); }
358
+ this.syncOut(); this.bump(); this.render();
359
+ return; // stay open for multi-select
360
+ }
361
+ this.selected = date;
362
+ if (this.withTime) this.timeSet = true;
363
+ this.syncOut(); this.bump(); this.render();
364
+ // With a time control, keep the dropdown open so the user can set
365
+ // the time after picking the day.
366
+ if (this.mode === 'menu' && !this.withTime) this.close();
367
+ },
368
+
369
+ setTime(h24, mi) {
370
+ this.time = pad2(h24) + ':' + pad2(mi);
371
+ this.timeSet = true;
372
+ this.syncOut(); this.bump();
373
+ },
374
+
375
+ clear() {
376
+ this.selected = null; this.rangeStart = null; this.rangeEnd = null; this.rangeHover = null; this.selectedDates = [];
377
+ this.timeSet = false;
378
+ this.syncOut(); this.bump(); this.render();
379
+ },
380
+
381
+ _primaryValue() {
382
+ if (this.timeOnly) return this.timeSet ? this.time : '';
383
+ if (this.selectionMode === 'range') { const s = this.rangeStart ? D.toISO(this.rangeStart) : '', e = this.rangeEnd ? D.toISO(this.rangeEnd) : ''; return s && e ? s + '/' + e : s; }
384
+ if (this.selectionMode === 'multiple') return this.selectedDates.map(d => D.toISO(d)).join(',');
385
+ if (!this.selected) return '';
386
+ const iso = D.toISO(this.selected);
387
+ return this.withTime ? iso + 'T' + this.time : iso;
388
+ },
389
+
390
+ setView(y, m) {
391
+ const dt = D.make(y, m, 1); // normalize month overflow
392
+ this.view = { y: dt.getFullYear(), m: dt.getMonth() + 1 };
393
+ this.render();
394
+ },
395
+
396
+ // Write the value out to: x-model, hidden input, and the field display.
397
+ // Model shape: single → ISO string ('yyyy-mm-dd' or '…THH:mm');
398
+ // range → { start, end }; multiple → ISO[]; time-only → 'HH:mm'.
399
+ syncOut() {
400
+ let modelVal, hiddenVal;
401
+ if (this.selectionMode === 'range') {
402
+ const s = this.rangeStart ? D.toISO(this.rangeStart) : '', e = this.rangeEnd ? D.toISO(this.rangeEnd) : '';
403
+ modelVal = { start: s, end: e };
404
+ hiddenVal = s && e ? s + '/' + e : s;
405
+ } else if (this.selectionMode === 'multiple') {
406
+ const arr = this.selectedDates.map(d => D.toISO(d));
407
+ modelVal = arr; hiddenVal = arr.join(',');
408
+ } else {
409
+ const iso = this._primaryValue();
410
+ modelVal = iso; hiddenVal = iso;
411
+ }
412
+ if (this.modelSet) { try { this.modelSet(modelVal); } catch (_) { } }
413
+ if (this.hiddenInput) {
414
+ this.hiddenInput.value = hiddenVal;
415
+ this.hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
416
+ this.hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
417
+ }
418
+ const disp = this.displayValue();
419
+ if (this.field && this.field.tagName === 'INPUT' && this.field.type !== 'hidden') {
420
+ this.field.value = disp;
421
+ // Alpine's own x-model on the input writes the RAW model value
422
+ // into it during the same reactive flush — re-assert the
423
+ // human-readable display after that write settles.
424
+ if (this.field.hasAttribute('x-model')) {
425
+ const f = this.field;
426
+ setTimeout(() => { f.value = this.displayValue(); }, 0);
427
+ }
428
+ } else if (this.field && !this.field.firstElementChild) {
429
+ // Text-only triggers swap their text for the selection.
430
+ // Triggers with element children (icons, custom layout) own
431
+ // their content — authors bind $date(id) for display.
432
+ this.field.textContent = disp || this.fieldPlaceholder;
433
+ }
434
+ },
435
+
436
+ // Pull an initial value from x-model, then hidden input, then value attr.
437
+ seed() {
438
+ let raw;
439
+ if (this.modelGet) { try { raw = this.modelGet(); } catch (_) { } }
440
+ const attrVal = () => this.rootEl.getAttribute('value') || (this.field && this.field.getAttribute && this.field.getAttribute('value')) || '';
441
+ if (this.timeOnly) {
442
+ const str = (typeof raw === 'string' && raw) || (this.hiddenInput && this.hiddenInput.value) || attrVal();
443
+ const m = typeof str === 'string' && str.match(/^(\d{2}):(\d{2})/);
444
+ if (m) { this.time = m[1] + ':' + m[2]; this.timeSet = true; }
445
+ } else if (this.selectionMode === 'range') {
446
+ let s, e;
447
+ if (raw && typeof raw === 'object' && !(raw instanceof Date)) { s = raw.start; e = raw.end; }
448
+ else { const str = (typeof raw === 'string' && raw) || (this.hiddenInput && this.hiddenInput.value) || attrVal(); if (str.includes('/')) { const p = str.split('/'); s = p[0]; e = p[1]; } else s = str; }
449
+ this.rangeStart = s ? D.fromISO(s) : null; this.rangeEnd = e ? D.fromISO(e) : null;
450
+ if (this.rangeStart) this.view = { y: this.rangeStart.getFullYear(), m: this.rangeStart.getMonth() + 1 };
451
+ } else if (this.selectionMode === 'multiple') {
452
+ let arr = [];
453
+ if (Array.isArray(raw)) arr = raw;
454
+ else { const str = (typeof raw === 'string' && raw) || (this.hiddenInput && this.hiddenInput.value) || attrVal(); if (str) arr = str.split(','); }
455
+ this.selectedDates = arr.map(x => D.fromISO(x)).filter(Boolean).sort((a, b) => D.compare(a, b));
456
+ if (this.selectedDates.length) this.view = { y: this.selectedDates[0].getFullYear(), m: this.selectedDates[0].getMonth() + 1 };
457
+ } else {
458
+ let iso = '';
459
+ if (typeof raw === 'string' && raw) iso = raw; else if (raw instanceof Date) iso = D.toISO(raw);
460
+ if (!iso && this.hiddenInput && this.hiddenInput.value) iso = this.hiddenInput.value;
461
+ if (!iso) iso = attrVal();
462
+ // Split a datetime seed (yyyy-mm-ddTHH:mm) into date + time.
463
+ const tm = typeof iso === 'string' && iso.match(/T(\d{2}:\d{2})/);
464
+ if (tm) { this.time = tm[1]; this.withTime = true; this.timeSet = true; }
465
+ const d = iso ? D.fromISO(iso) : null;
466
+ if (d) { this.selected = d; this.view = { y: d.getFullYear(), m: d.getMonth() + 1 }; }
467
+ }
468
+ },
469
+
470
+ // Only the plugin-generated dropdown (for an <input> field, which
471
+ // can't carry popovertarget without becoming a button) is opened
472
+ // here. We still fire native showPopover() — so the menu rides the
473
+ // reset.css popover transitions, dropdown.css anchor positioning
474
+ // (anchor-name/position-anchor are wired in wireField), light-
475
+ // dismiss, and nested-popover cascade. Authored <div>/<menu>/
476
+ // <dialog> calendars are inline; native HTML owns their visibility.
477
+ open() {
478
+ if (this.mode !== 'menu') return;
479
+ this.viewMode = 'days';
480
+ const t = this.rootEl;
481
+ if (t.matches(':popover-open')) { this.close(); return; } // toggle
482
+ this.render();
483
+ try { t.showPopover(); } catch (_) { }
484
+ },
485
+ close() {
486
+ if (this._closeTimeMenu) this._closeTimeMenu();
487
+ if (this.mode !== 'menu') return;
488
+ const t = this.rootEl;
489
+ try { if (t.matches(':popover-open')) t.hidePopover(); } catch (_) { }
490
+ },
491
+
492
+ render() { renderCalendar(this); },
493
+
494
+ get api() {
495
+ const self = this;
496
+ return {
497
+ get value() { self.snapshot.version; return self._primaryValue(); },
498
+ get iso() { self.snapshot.version; return self._primaryValue(); },
499
+ get formatted() { self.snapshot.version; return self.displayValue(); },
500
+ get date() { self.snapshot.version; return self.selected; },
501
+ get range() { self.snapshot.version; return { start: self.rangeStart ? D.toISO(self.rangeStart) : '', end: self.rangeEnd ? D.toISO(self.rangeEnd) : '' }; },
502
+ get start() { self.snapshot.version; return self.rangeStart ? D.toISO(self.rangeStart) : ''; },
503
+ get end() { self.snapshot.version; return self.rangeEnd ? D.toISO(self.rangeEnd) : ''; },
504
+ get dates() { self.snapshot.version; return self.selectedDates.map(d => D.toISO(d)); },
505
+ get time() { self.snapshot.version; return self.timeSet ? self.time : ''; },
506
+ get mode() { return self.selectionMode; },
507
+ setDate(v) { const d = D.fromISO(v); if (d) self.select(d); },
508
+ setTime(v) { const m = String(v || '').match(/^(\d{1,2}):(\d{2})/); if (m) self.setTime(+m[1], +m[2]); },
509
+ clear() { self.clear(); },
510
+ open() { self.open(); },
511
+ close() { self.close(); },
512
+ [Symbol.toPrimitive]() { self.snapshot.version; return self._primaryValue(); },
513
+ toString() { self.snapshot.version; return self._primaryValue(); },
514
+ valueOf() { self.snapshot.version; return self._primaryValue(); }
515
+ };
516
+ }
517
+ };
518
+ // Styling marker: CSS can't match attribute-NAME prefixes, so modified
519
+ // attributes (x-date.range, …) are unreachable by [x-date] selectors.
520
+ rootEl.classList.add('date-picker');
521
+ return state;
522
+ }
523
+
524
+ /* ---- Calendar rendering ------------------------------------- */
525
+ // Chevrons are CSS mask icons (see manifest.datepicker.css) — no markup,
526
+ // no icons-plugin dependency.
527
+ function navButton(aria, onClick) {
528
+ const b = document.createElement('button');
529
+ b.type = 'button';
530
+ b.setAttribute('aria-label', aria);
531
+ b.addEventListener('click', onClick);
532
+ return b;
533
+ }
534
+ // <header> — previous / heading / next.
535
+ function buildHeader(shell, labelText, onPrev, onNext, onLabel, ui) {
536
+ const header = document.createElement('header');
537
+ const label = document.createElement('button');
538
+ label.type = 'button'; label.setAttribute('aria-live', 'polite');
539
+ label.textContent = labelText;
540
+ if (onLabel) label.addEventListener('click', onLabel); else label.disabled = true;
541
+ header.append(
542
+ navButton(ui.previousMonth, onPrev),
543
+ label,
544
+ navButton(ui.nextMonth, onNext)
545
+ );
546
+ shell.appendChild(header);
547
+ }
548
+
549
+ function renderCalendar(state) {
550
+ const root = state.rootEl;
551
+ const locale = state.locale();
552
+ const ui = window.ManifestUI.resolve('date', UI_FALLBACK);
553
+
554
+ // Reuse or build the shell once.
555
+ let shell = root.querySelector(':scope > [role=group]');
556
+ if (!shell) {
557
+ shell = document.createElement('div');
558
+ shell.setAttribute('role', 'group');
559
+ root.appendChild(shell);
560
+ }
561
+ shell.innerHTML = '';
562
+
563
+ if (state.timeOnly) {
564
+ renderTime(state, shell, locale, ui);
565
+ } else if (state.viewMode === 'months') renderMonthsView(state, shell, locale, ui);
566
+ else if (state.viewMode === 'years') renderYearsView(state, shell, locale, ui);
567
+ else {
568
+ renderDaysView(state, shell, locale, ui);
569
+ if (state.withTime && state.selectionMode === 'single') renderTime(state, shell, locale, ui);
570
+ }
571
+ const onDays = !state.timeOnly && state.viewMode === 'days';
572
+
573
+ // <footer> — Today | presets | Clear. Standalone time keeps Now/Clear
574
+ // in its inline columns, so it has no footer.
575
+ if (!state.timeOnly && (state.showNow || state.showClear || (onDays && Array.isArray(state.presets) && state.presets.length))) {
576
+ const footer = document.createElement('footer');
577
+ if (state.showNow) {
578
+ const todayBtn = document.createElement('button');
579
+ todayBtn.type = 'button'; todayBtn.className = 'sm ghost';
580
+ todayBtn.textContent = ui.today;
581
+ todayBtn.addEventListener('click', () => { state.viewMode = 'days'; state.select(D.clamp(D.today(), state.min, state.max)); });
582
+ footer.append(todayBtn);
583
+ }
584
+ if (onDays && Array.isArray(state.presets) && state.presets.length) footer.appendChild(buildPresets(state));
585
+ if (state.showClear) {
586
+ const clearBtn = document.createElement('button');
587
+ clearBtn.type = 'button'; clearBtn.className = 'sm ghost'; clearBtn.textContent = ui.clear;
588
+ clearBtn.addEventListener('click', () => state.clear());
589
+ footer.append(clearBtn);
590
+ }
591
+ shell.appendChild(footer);
592
+ }
593
+
594
+ }
595
+
596
+ function renderDaysView(state, shell, locale, ui) {
597
+ const { y, m } = state.view;
598
+ const count = Math.max(1, Math.min(4, state.monthCount || 1));
599
+ const today = D.today();
600
+ const firstDOW = state.firstDay != null ? state.firstDay : D.firstDayOfWeek(locale);
601
+ let fMonthYear, fMonth, wdFmt, dayFmt;
602
+ try { fMonthYear = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }); } catch (_) { }
603
+ try { fMonth = new Intl.DateTimeFormat(locale, { month: 'long' }); } catch (_) { }
604
+ try { wdFmt = new Intl.DateTimeFormat(locale, { weekday: 'short' }); } catch (_) { }
605
+ try { dayFmt = new Intl.DateTimeFormat(locale, { dateStyle: 'full' }); } catch (_) { }
606
+
607
+ // Header label: "June 2026", or "June – August 2026" when multi-month.
608
+ let label;
609
+ const first = D.make(y, m, 1);
610
+ const last = D.addMonths(first, count - 1);
611
+ if (fMonthYear) {
612
+ label = count === 1 ? tidy(fMonthYear.format(first), locale)
613
+ : tidy(last.getFullYear() === y && fMonth ? fMonth.format(first) : fMonthYear.format(first), locale) + ' – ' + tidy(fMonthYear.format(last), locale);
614
+ } else label = y + '-' + m;
615
+ buildHeader(shell, label,
616
+ () => state.setView(y, m - 1),
617
+ () => state.setView(y, m + 1),
618
+ () => { state.viewMode = 'months'; state.render(); }, ui);
619
+
620
+ // One <section> per visible month, side by side. Weekday <abbr> labels
621
+ // share the 7-column grid with the day buttons, so they always align.
622
+ const monthsWrap = document.createElement('div');
623
+ for (let k = 0; k < count; k++) {
624
+ const base = D.addMonths(first, k);
625
+ const yy = base.getFullYear(), mm = base.getMonth() + 1;
626
+ const block = document.createElement('section');
627
+
628
+ if (count > 1 && fMonth) {
629
+ const sub = document.createElement('small');
630
+ sub.textContent = tidy(fMonth.format(base), locale);
631
+ block.appendChild(sub);
632
+ }
633
+
634
+ const grid = document.createElement('div');
635
+ grid.setAttribute('role', 'grid');
636
+ for (let i = 0; i < 7; i++) {
637
+ const dow = (firstDOW + i) % 7;
638
+ const ref = D.make(2024, 9, 1 + dow); // 2024-09-01 is a Sunday
639
+ const cell = document.createElement('abbr');
640
+ cell.setAttribute('aria-hidden', 'true');
641
+ cell.textContent = wdFmt ? tidy(wdFmt.format(ref), locale) : ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][dow];
642
+ grid.appendChild(cell);
643
+ }
644
+ const lead = (base.getDay() - firstDOW + 7) % 7;
645
+ const gridStart = D.addDays(base, -lead);
646
+ for (let i = 0; i < 42; i++) {
647
+ const date = D.addDays(gridStart, i);
648
+ const inMonth = date.getMonth() === (mm - 1);
649
+ const btn = document.createElement('button');
650
+ btn.type = 'button';
651
+ if (!inMonth) btn.classList.add('outside');
652
+ btn.setAttribute('role', 'gridcell');
653
+ btn.value = D.toISO(date);
654
+ btn.setAttribute('tabindex', '-1');
655
+ btn.textContent = String(date.getDate());
656
+ if (dayFmt) btn.setAttribute('aria-label', dayFmt.format(date));
657
+ state.applyDayState(btn, date);
658
+ if (state.isDisabled(date)) btn.disabled = true;
659
+ btn.addEventListener('click', () => state.select(date));
660
+ if (state.selectionMode === 'range') {
661
+ btn.addEventListener('mouseenter', () => { if (state.rangeStart && !state.rangeEnd) { state.rangeHover = date; state.paintRange(); } });
662
+ }
663
+ grid.appendChild(btn);
664
+ }
665
+ block.appendChild(grid);
666
+ monthsWrap.appendChild(block);
667
+ }
668
+
669
+ // Roving tabindex: the primary selection (or today) is the single tab
670
+ // stop; prefer the in-month cell when the date appears twice (a month's
671
+ // trailing days repeat as the next month's leading `outside` cells).
672
+ const focusISO = state.selectionMode === 'range' ? (state.rangeStart && D.toISO(state.rangeStart))
673
+ : state.selectionMode === 'multiple' ? (state.selectedDates[0] && D.toISO(state.selectedDates[0]))
674
+ : (state.selected && D.toISO(state.selected));
675
+ const sel = 'button[value="' + (focusISO || D.toISO(today)) + '"]:not([disabled])';
676
+ const focusBtn = monthsWrap.querySelector(sel + ':not(.outside)') || monthsWrap.querySelector(sel) || monthsWrap.querySelector('[role=grid] button:not([disabled])');
677
+ if (focusBtn) focusBtn.setAttribute('tabindex', '0');
678
+ if (state.selectionMode === 'range') {
679
+ monthsWrap.addEventListener('mouseleave', () => { if (state.rangeStart && !state.rangeEnd) { state.rangeHover = null; state.paintRange(); } });
680
+ }
681
+ monthsWrap.addEventListener('keydown', (e) => handleGridKeys(e, state));
682
+ shell.appendChild(monthsWrap);
683
+ }
684
+
685
+ /* ---- Presets ------------------------------------------------- */
686
+ function applyPreset(state, p) {
687
+ if (!p || typeof p !== 'object') return;
688
+ if (state.selectionMode === 'range') {
689
+ const s = p.start ? D.fromISO(p.start) : (p.value ? D.fromISO(p.value) : null);
690
+ const e = p.end ? D.fromISO(p.end) : s;
691
+ if (!s) return;
692
+ state.rangeStart = s; state.rangeEnd = e; state.rangeHover = null;
693
+ state.view = { y: s.getFullYear(), m: s.getMonth() + 1 };
694
+ state.syncOut(); state.bump(); state.render();
695
+ if (state.mode === 'menu') state.close();
696
+ } else if (state.selectionMode === 'multiple') {
697
+ const arr = Array.isArray(p.dates) ? p.dates : (p.value ? [p.value] : []);
698
+ state.selectedDates = arr.map(x => D.fromISO(x)).filter(Boolean).sort((a, b) => D.compare(a, b));
699
+ if (state.selectedDates.length) state.view = { y: state.selectedDates[0].getFullYear(), m: state.selectedDates[0].getMonth() + 1 };
700
+ state.syncOut(); state.bump(); state.render();
701
+ } else {
702
+ const d = D.fromISO(p.value || p.start);
703
+ if (d) state.select(d);
704
+ }
705
+ }
706
+
707
+ // Preset chips, returned for placement in the footer (centered between
708
+ // Today and Clear) so they don't add an extra row.
709
+ function buildPresets(state) {
710
+ const wrap = document.createElement('div');
711
+ state.presets.forEach(p => {
712
+ const btn = document.createElement('button');
713
+ btn.type = 'button';
714
+ btn.textContent = (p && p.label) || ''; // untrusted-safe
715
+ btn.addEventListener('click', () => applyPreset(state, p));
716
+ wrap.appendChild(btn);
717
+ });
718
+ return wrap;
719
+ }
720
+
721
+ /* ---- Time of day ---------------------------------------------
722
+ * No native time input (its picker UI is unstylable). Two shapes:
723
+ * • Standalone (timeOnly): the dropdown IS the time picker, so the
724
+ * hour/minute/(AM-PM) columns render inline — no nested menu. Now and
725
+ * Clear (configurable) sit in the actions column.
726
+ * • Date+time: a ghost row of typed segments inside the calendar, with a
727
+ * columns picker opened by a popovertarget chevron. That menu is
728
+ * appended to <body> so the calendar's overflow can't clip it, yet the
729
+ * in-calendar invoker keeps it nested (it won't dismiss the calendar).
730
+ * setTime never triggers a full re-render (that would blur the segments).
731
+ * ---------------------------------------------------------------- */
732
+ function renderTime(state, shell, locale, ui) {
733
+ const h12 = uses12h(locale);
734
+ const cur = () => { const [h, mi] = state.time.split(':').map(Number); return { h: h || 0, mi: mi || 0 }; };
735
+ const dispH = (h24) => h12 ? (((h24 % 12) || 12)) : h24;
736
+
737
+ let segs = null; // { hIn, mIn, apBtn } when date+time
738
+ let colsRefs = null; // { h, m, ap } column elements
739
+
740
+ // Selectable options carry their text in `value` (hours, minutes,
741
+ // AM/PM); actions (Now/Clear) don't — CSS and refresh() key off it.
742
+ function mkOpt(text, isValue) {
743
+ const b = document.createElement('button');
744
+ b.type = 'button';
745
+ b.textContent = text;
746
+ if (isValue) b.value = text;
747
+ b.addEventListener('mousedown', (e) => e.preventDefault()); // keep focus
748
+ return b;
749
+ }
750
+ function mkCol() { return document.createElement('div'); }
751
+
752
+ // Builds the hour/minute/(AM-PM) columns into `host` — a `.time-options`
753
+ // wrapper: the popover <menu> (date+time) or an inline <div> (standalone).
754
+ // `withActions` adds Now/Clear (standalone only).
755
+ function buildColumns(host, withActions) {
756
+ host.classList.add('time-options');
757
+ const colH = mkCol(), colM = mkCol();
758
+ const hourVals = h12 ? [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] : Array.from({ length: 24 }, (_, i) => i);
759
+ hourVals.forEach(hv => {
760
+ const b = mkOpt(pad2(hv), true);
761
+ b.addEventListener('click', () => { const c = cur(); let h24 = hv; if (h12) { const pm = c.h >= 12; h24 = (hv % 12) + (pm ? 12 : 0); } state.setTime(h24, c.mi); refresh(); });
762
+ colH.appendChild(b);
763
+ });
764
+ for (let mv = 0; mv < 60; mv += 5) {
765
+ const b = mkOpt(pad2(mv), true);
766
+ b.addEventListener('click', () => { const c = cur(); state.setTime(c.h, mv); refresh(); });
767
+ colM.appendChild(b);
768
+ }
769
+ host.append(colH, colM);
770
+ let colAp = null;
771
+ if (h12) {
772
+ colAp = mkCol();
773
+ ['AM', 'PM'].forEach(ap => {
774
+ const b = mkOpt(ap, true);
775
+ b.addEventListener('click', () => { const c = cur(); const base = c.h % 12; state.setTime(ap === 'PM' ? base + 12 : base, c.mi); refresh(); });
776
+ colAp.appendChild(b);
777
+ });
778
+ host.appendChild(colAp);
779
+ }
780
+ if (withActions && (state.showNow || state.showClear)) {
781
+ const actions = colAp || mkCol();
782
+ if (!colAp) host.appendChild(actions);
783
+ if (state.showNow) { const b = mkOpt(ui.now, false); b.addEventListener('click', () => { const n = new Date(); state.setTime(n.getHours(), n.getMinutes()); refresh(); }); actions.appendChild(b); }
784
+ if (state.showClear) { const b = mkOpt(ui.clear, false); b.addEventListener('click', () => { state.clear(); }); actions.appendChild(b); }
785
+ }
786
+ colsRefs = { h: colH, m: colM, ap: colAp };
787
+ return host;
788
+ }
789
+
790
+ // Sync segments + column highlights from state.
791
+ function refresh() {
792
+ const c = cur();
793
+ if (segs) {
794
+ if (document.activeElement !== segs.hIn) segs.hIn.value = pad2(dispH(c.h));
795
+ if (document.activeElement !== segs.mIn) segs.mIn.value = pad2(c.mi);
796
+ if (segs.apBtn) segs.apBtn.textContent = c.h >= 12 ? 'PM' : 'AM';
797
+ }
798
+ if (colsRefs) {
799
+ // Exact matches only — a hand-typed 5:12 highlights no minute option.
800
+ const mk = (col, val) => { if (!col) return; col.querySelectorAll('button[value]').forEach(b => { if (b.value === val) b.setAttribute('aria-selected', 'true'); else b.removeAttribute('aria-selected'); }); };
801
+ mk(colsRefs.h, pad2(dispH(c.h)));
802
+ mk(colsRefs.m, pad2(c.mi));
803
+ if (colsRefs.ap) mk(colsRefs.ap, c.h >= 12 ? 'PM' : 'AM');
804
+ }
805
+ }
806
+
807
+ /* ---- Standalone: columns inline in the dropdown ---- */
808
+ if (state.timeOnly) {
809
+ shell.appendChild(buildColumns(document.createElement('div'), true));
810
+ refresh();
811
+ return;
812
+ }
813
+
814
+ /* ---- Date+time: segment field + body-appended columns menu ---- */
815
+ const wrap = document.createElement('fieldset');
816
+ const lab = document.createElement('label'); lab.textContent = ui.time;
817
+ const fieldBox = document.createElement('div'); fieldBox.setAttribute('role', 'group');
818
+ const mkSeg = (aria) => { const i = document.createElement('input'); i.type = 'text'; i.inputMode = 'numeric'; i.maxLength = 2; i.setAttribute('aria-label', aria); return i; };
819
+ const hIn = mkSeg('Hours'), mIn = mkSeg('Minutes');
820
+ const sep = document.createElement('span'); sep.textContent = ':';
821
+ fieldBox.append(hIn, sep, mIn);
822
+ let apBtn = null;
823
+ if (h12) { apBtn = document.createElement('button'); apBtn.type = 'button'; apBtn.setAttribute('aria-label', 'AM or PM'); fieldBox.appendChild(apBtn); }
824
+ segs = { hIn, mIn, apBtn };
825
+
826
+ // Columns menu — auto popover appended to <body> so the calendar's
827
+ // overflow can't clip it. The popovertarget chevron lives inside the
828
+ // calendar, so the menu nests under it (won't dismiss it on interaction).
829
+ const menu = buildColumns(document.createElement('menu'), false);
830
+ menu.setAttribute('popover', '');
831
+ menu.id = 'mnfst-dp-time-' + (++_uid);
832
+ if (state._timeMenuEl && state._timeMenuEl.isConnected) state._timeMenuEl.remove();
833
+ document.body.appendChild(menu);
834
+ state._timeMenuEl = menu;
835
+
836
+ const trigger = document.createElement('button');
837
+ trigger.type = 'button';
838
+ trigger.id = 'mnfst-dp-time-trigger-' + _uid;
839
+ trigger.setAttribute('popovertarget', menu.id);
840
+ trigger.setAttribute('aria-label', ui.time);
841
+ fieldBox.appendChild(trigger);
842
+ // The "Time" label is a native invoker too: label activation forwards
843
+ // the click to its associated button, whose popovertarget opens the menu.
844
+ lab.htmlFor = trigger.id;
845
+
846
+ const timeAnchor = '--mnfst-dp-time-' + _uid;
847
+ fieldBox.style.setProperty('anchor-name', timeAnchor);
848
+ menu.style.setProperty('position-anchor', timeAnchor);
849
+ menu.addEventListener('toggle', (e) => { if (e.newState === 'open') refresh(); });
850
+ state._closeTimeMenu = () => { try { if (menu.matches(':popover-open')) menu.hidePopover(); } catch (_) { } };
851
+
852
+ function commit() {
853
+ const c = cur();
854
+ let h = parseInt(hIn.value, 10); if (isNaN(h)) h = dispH(c.h);
855
+ let mi = parseInt(mIn.value, 10); if (isNaN(mi)) mi = c.mi;
856
+ mi = Math.max(0, Math.min(59, mi));
857
+ let h24; if (h12) { h = Math.max(1, Math.min(12, h)); const pm = c.h >= 12; h24 = (h % 12) + (pm ? 12 : 0); } else h24 = Math.max(0, Math.min(23, h));
858
+ state.setTime(h24, mi); refresh();
859
+ }
860
+ [hIn, mIn].forEach(inp => {
861
+ inp.addEventListener('focus', () => inp.select());
862
+ inp.addEventListener('input', () => { inp.value = inp.value.replace(/\D/g, ''); if (inp.value.length >= 2) { commit(); if (inp === hIn) mIn.focus(); } });
863
+ inp.addEventListener('change', commit);
864
+ inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); state._closeTimeMenu(); inp.blur(); } else if (e.key === 'Escape') { state._closeTimeMenu(); } });
865
+ });
866
+ if (apBtn) apBtn.addEventListener('click', () => { const c = cur(); state.setTime(c.h >= 12 ? c.h - 12 : c.h + 12, c.mi); refresh(); });
867
+
868
+ wrap.append(lab, fieldBox);
869
+ shell.appendChild(wrap);
870
+ refresh();
871
+ }
872
+
873
+ function monthOutOfRange(state, y, mo) {
874
+ if (state.min && D.compare(D.make(y, mo, D.daysInMonth(y, mo)), state.min) < 0) return true;
875
+ if (state.max && D.compare(D.make(y, mo, 1), state.max) > 0) return true;
876
+ return false;
877
+ }
878
+ function yearOutOfRange(state, yr) {
879
+ if (state.min && yr < state.min.getFullYear()) return true;
880
+ if (state.max && yr > state.max.getFullYear()) return true;
881
+ return false;
882
+ }
883
+
884
+ function renderMonthsView(state, shell, locale, ui) {
885
+ const y = state.view.y;
886
+ buildHeader(shell, String(y),
887
+ () => { state.view = { y: y - 1, m: state.view.m }; state.render(); },
888
+ () => { state.view = { y: y + 1, m: state.view.m }; state.render(); },
889
+ () => { state.viewMode = 'years'; state.render(); }, ui);
890
+
891
+ const cells = document.createElement('div');
892
+ cells.setAttribute('role', 'listbox');
893
+ let mFmt; try { mFmt = new Intl.DateTimeFormat(locale, { month: 'short' }); } catch (_) { mFmt = null; }
894
+ const today = D.today();
895
+ for (let mo = 1; mo <= 12; mo++) {
896
+ const btn = document.createElement('button');
897
+ btn.type = 'button'; btn.setAttribute('role', 'option');
898
+ btn.textContent = mFmt ? tidy(mFmt.format(D.make(y, mo, 1)), locale) : String(mo);
899
+ if (y === today.getFullYear() && mo === today.getMonth() + 1) btn.setAttribute('aria-current', 'true');
900
+ if (state.selected && state.selected.getFullYear() === y && state.selected.getMonth() + 1 === mo) btn.setAttribute('aria-selected', 'true');
901
+ if (monthOutOfRange(state, y, mo)) btn.disabled = true;
902
+ btn.addEventListener('click', () => { state.view = { y, m: mo }; state.viewMode = 'days'; state.render(); });
903
+ cells.appendChild(btn);
904
+ }
905
+ shell.appendChild(cells);
906
+ }
907
+
908
+ function renderYearsView(state, shell, locale, ui) {
909
+ const y = state.view.y;
910
+ const base = Math.floor(y / 10) * 10; // decade start
911
+ buildHeader(shell, base + '–' + (base + 9),
912
+ () => { state.view = { y: y - 10, m: state.view.m }; state.render(); },
913
+ () => { state.view = { y: y + 10, m: state.view.m }; state.render(); },
914
+ () => { state.viewMode = 'days'; state.render(); }, ui);
915
+
916
+ const cells = document.createElement('div');
917
+ cells.setAttribute('role', 'listbox');
918
+ const today = D.today();
919
+ for (let yr = base - 1; yr <= base + 10; yr++) { // 12 cells, leading/trailing greyed
920
+ const btn = document.createElement('button');
921
+ btn.type = 'button'; btn.setAttribute('role', 'option');
922
+ if (yr < base || yr > base + 9) btn.classList.add('outside');
923
+ btn.textContent = String(yr);
924
+ if (yr === today.getFullYear()) btn.setAttribute('aria-current', 'true');
925
+ if (state.selected && state.selected.getFullYear() === yr) btn.setAttribute('aria-selected', 'true');
926
+ if (yearOutOfRange(state, yr)) btn.disabled = true;
927
+ btn.addEventListener('click', () => { state.view = { y: yr, m: state.view.m }; state.viewMode = 'months'; state.render(); });
928
+ cells.appendChild(btn);
929
+ }
930
+ shell.appendChild(cells);
931
+ }
932
+
933
+ function handleGridKeys(e, state) {
934
+ const focused = e.target.closest('button[value]');
935
+ if (!focused) return;
936
+ let date = D.fromISO(focused.value);
937
+ let delta = 0;
938
+ switch (e.key) {
939
+ case 'ArrowLeft': delta = -1; break;
940
+ case 'ArrowRight': delta = 1; break;
941
+ case 'ArrowUp': delta = -7; break;
942
+ case 'ArrowDown': delta = 7; break;
943
+ case 'Home': delta = -date.getDay(); break;
944
+ case 'PageUp': date = D.addMonths(date, -1); break;
945
+ case 'PageDown': date = D.addMonths(date, 1); break;
946
+ case 'Enter': case ' ': e.preventDefault(); state.select(date); return;
947
+ case 'Escape': if (state.mode === 'menu') { e.preventDefault(); state.close(); } return;
948
+ default: return;
949
+ }
950
+ e.preventDefault();
951
+ if (delta) date = D.addDays(date, delta);
952
+ // Shift the view only when the target leaves the visible month window
953
+ // ([view.m, view.m + monthCount - 1] — multi-month shows several).
954
+ const count = Math.max(1, state.monthCount || 1);
955
+ const idx = (date.getFullYear() * 12 + date.getMonth()) - (state.view.y * 12 + (state.view.m - 1));
956
+ if (idx < 0) state.setView(date.getFullYear(), date.getMonth() + 1);
957
+ else if (idx >= count) state.setView(date.getFullYear(), date.getMonth() + 1 - (count - 1));
958
+ const target = state.rootEl.querySelector('[role=grid] button[value="' + D.toISO(date) + '"]');
959
+ if (target) { target.setAttribute('tabindex', '0'); target.focus(); }
960
+ }
961
+
962
+ /* ---- Config: the directive's value --------------------------- */
963
+ // An object value is reactive config: { months, min, max, disabled,
964
+ // presets }. min/max also read from the element's native attributes
965
+ // (an input's own min/max are semantic). Modal/dropdown opening of
966
+ // authored calendars is native HTML's job (popovertarget), so the value
967
+ // is no longer used to link a field to a calendar by id.
968
+ function applyAttrConfig(state, el) {
969
+ const min = el.getAttribute('min');
970
+ const max = el.getAttribute('max');
971
+ if (min) state.min = D.fromISO(min);
972
+ if (max) state.max = D.fromISO(max);
973
+ }
974
+ function bindConfigValue(state, el, expression) {
975
+ if (!expression) return;
976
+ Alpine.effect(() => {
977
+ let cfg;
978
+ try { cfg = Alpine.evaluate(el, expression); } catch (_) { return; }
979
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return;
980
+ let dirty = false;
981
+ if (cfg.months) { const mc = Math.max(1, Math.min(4, parseInt(cfg.months, 10) || 1)); if (mc !== state.monthCount) { state.monthCount = mc; dirty = true; } }
982
+ if (cfg.firstDay !== undefined) { const fd = parseFirstDay(cfg.firstDay); if (fd !== state.firstDay) { state.firstDay = fd; dirty = true; } }
983
+ if (cfg.now !== undefined) { const v = cfg.now !== false; if (v !== state.showNow) { state.showNow = v; dirty = true; } }
984
+ if (cfg.clear !== undefined) { const v = cfg.clear !== false; if (v !== state.showClear) { state.showClear = v; dirty = true; } }
985
+ if (cfg.min !== undefined) { state.min = cfg.min ? D.fromISO(cfg.min) : null; dirty = true; }
986
+ if (cfg.max !== undefined) { state.max = cfg.max ? D.fromISO(cfg.max) : null; dirty = true; }
987
+ if (cfg.disabled !== undefined && (Array.isArray(cfg.disabled) || typeof cfg.disabled === 'function')) { state.disabled = cfg.disabled; dirty = true; }
988
+ if (Array.isArray(cfg.presets)) { state.presets = cfg.presets; dirty = true; }
989
+ if (dirty) { state.bump(); if (state._mounted) state.render(); }
990
+ });
991
+ }
992
+ /* ---- Reactive chrome ------------------------------------------
993
+ * Re-render when locale, direction, or the resolved `_ui` text actually
994
+ * changes. An Alpine effect tracking $locale + the data store's version
995
+ * heartbeat covers async locale-source reloads settling after a switch,
996
+ * and `_ui` sources that finish loading post-mount. The resolved-output
997
+ * comparison keeps high-frequency _dataVersion bumps (e.g. realtime
998
+ * plugins) from re-rendering a calendar that didn't change.
999
+ * ---------------------------------------------------------------- */
1000
+ function bindReactiveRender(state) {
1001
+ if (state._uiEffectBound) return;
1002
+ state._uiEffectBound = true;
1003
+ Alpine.effect(() => {
1004
+ let locale = '', dir = '';
1005
+ try { const ls = Alpine.store('locale'); locale = ls?.current || document.documentElement.lang || ''; dir = ls?.direction || ''; } catch (_) { }
1006
+ try { void Alpine.store('data')?._dataVersion; } catch (_) { }
1007
+ const ui = window.ManifestUI.resolve('date', UI_FALLBACK);
1008
+ const key = locale + '|' + dir + '|' + JSON.stringify(ui);
1009
+ if (state._uiKey === key) return;
1010
+ const first = state._uiKey === undefined;
1011
+ state._uiKey = key;
1012
+ if (state._destroyed || first) return; // initial render happens at mount
1013
+ if (state._mounted) { state.render(); state.syncOut(); }
1014
+ });
1015
+ }
1016
+
1017
+ function bindModel(state, el, modelExpr) {
1018
+ const read = Alpine.evaluateLater(el, modelExpr);
1019
+ state.modelGet = (cb) => { let out; read(v => { out = v; if (cb) cb(v); }); return out; };
1020
+ state.modelSet = (v) => { try { Alpine.evaluate(el, `${modelExpr} = ${JSON.stringify(v)}`); } catch (_) { } };
1021
+ // React to external model changes. Compare against the full primary
1022
+ // value (date+time when withTime) and re-sync the field display —
1023
+ // syncOut writes the same value back to the model, which Alpine
1024
+ // treats as a no-op, so this cannot loop.
1025
+ Alpine.effect(() => { read(v => { const iso = (v instanceof Date) ? D.toISO(v) : v; const cur = state._primaryValue(); if (typeof iso === 'string' && iso !== cur) { const tm = iso.match(/T(\d{2}:\d{2})/); if (tm) state.time = tm[1]; const d = D.fromISO(iso); state.selected = d; if (d) state.view = { y: d.getFullYear(), m: d.getMonth() + 1 }; state.bump(); if (state._mounted) { state.render(); state.syncOut(); } } }); });
1026
+ }
1027
+
1028
+ function createDefaultMenu(fieldEl) {
1029
+ const menu = document.createElement('menu');
1030
+ menu.setAttribute('popover', '');
1031
+ menu.id = 'mnfst-dp-menu-' + (++_uid);
1032
+ document.body.appendChild(menu);
1033
+ return menu;
1034
+ }
1035
+
1036
+ /* ---- Wiring an <input>/<button> field to a generated dropdown -
1037
+ * The plugin generates and OWNS this menu's open/close because a text
1038
+ * <input> can't be a native popover invoker. It's a dropdown, not a
1039
+ * modal — authored <dialog>/<menu> calendars are inline and opened
1040
+ * natively (popovertarget). */
1041
+ function wireField(fieldEl, expression, modifiers, cleanup) {
1042
+ const calendar = createDefaultMenu(fieldEl);
1043
+ const state = (calendar._dateState = createState(calendar));
1044
+ state.field = fieldEl;
1045
+ state.mode = 'menu';
1046
+ if (modifiers.includes('range')) state.selectionMode = 'range';
1047
+ else if (modifiers.includes('multiple')) state.selectionMode = 'multiple';
1048
+ if (modifiers.includes('time')) state.withTime = true;
1049
+
1050
+ // Piggyback the input's authored type: it declares the format AND is
1051
+ // the no-JS fallback (native picker). We capture it, then take over
1052
+ // with text+readonly so the styled calendar owns the UX.
1053
+ if (fieldEl.tagName === 'INPUT') {
1054
+ const authored = (fieldEl.getAttribute('type') || '').toLowerCase();
1055
+ if (authored === 'time') state.timeOnly = true;
1056
+ else if (authored === 'datetime-local') state.withTime = true;
1057
+ applyAttrConfig(state, fieldEl); // native min/max first
1058
+ try { fieldEl.type = 'text'; } catch (_) { }
1059
+ fieldEl.readOnly = true; fieldEl.autocomplete = 'off';
1060
+ } else {
1061
+ applyAttrConfig(state, fieldEl);
1062
+ // Text-only triggers: the authored text is the placeholder the
1063
+ // selection swaps in and out of. Triggers with element children
1064
+ // are left untouched (syncOut skips them).
1065
+ state.fieldPlaceholder = fieldEl.firstElementChild ? '' : fieldEl.textContent.trim();
1066
+ }
1067
+
1068
+ // Object config in the value.
1069
+ bindConfigValue(state, fieldEl, expression);
1070
+
1071
+ // Synthesize a hidden input for form participation if the field carries
1072
+ // a name. The name moves to the hidden (ISO) input — otherwise the
1073
+ // visible field would submit its display text under the same key.
1074
+ // No-JS fallback is unaffected: this only runs once the plugin has.
1075
+ const nameAttr = fieldEl.getAttribute('name');
1076
+ if (nameAttr && !state.hiddenInput) {
1077
+ const hidden = document.createElement('input');
1078
+ hidden.type = 'hidden'; hidden.name = nameAttr;
1079
+ fieldEl.after(hidden);
1080
+ state.hiddenInput = hidden;
1081
+ fieldEl._dpSynthesizedHidden = hidden;
1082
+ fieldEl.removeAttribute('name');
1083
+ }
1084
+
1085
+ // x-model on the field wins over value.
1086
+ const modelExpr = fieldEl.getAttribute('x-model');
1087
+ if (modelExpr) bindModel(state, fieldEl, modelExpr);
1088
+
1089
+ if (!state._mounted) { state.seed(); state.syncOut(); state.render(); state._mounted = true; }
1090
+ if (calendar.id) _registry[calendar.id] = state.api;
1091
+ // Also resolve $date(fieldId) when the calendar is an auto-generated menu.
1092
+ if (fieldEl.id) _registry[fieldEl.id] = state.api;
1093
+
1094
+ // CSS anchor positioning (same wiring as the dropdowns plugin): the
1095
+ // field is the anchor, dropdown.css positions the menu bottom-start
1096
+ // with auto-flip fallbacks. We only fire showPopover() on click since
1097
+ // a text <input> can't carry popovertarget.
1098
+ const anchorName = '--mnfst-dp-' + (++_uid);
1099
+ fieldEl.style.setProperty('anchor-name', anchorName);
1100
+ calendar.style.setProperty('position-anchor', anchorName);
1101
+ fieldEl.setAttribute('aria-haspopup', 'menu');
1102
+ fieldEl.setAttribute('aria-controls', calendar.id);
1103
+ fieldEl.setAttribute('aria-expanded', 'false');
1104
+ calendar.addEventListener('toggle', (e) => fieldEl.setAttribute('aria-expanded', e.newState === 'open' ? 'true' : 'false'));
1105
+ fieldEl.addEventListener('click', () => state.open());
1106
+
1107
+ // Reactive chrome re-render (locale, direction, _ui). Auto-generated
1108
+ // menus never run the calendar-element path, so wire it here too.
1109
+ bindReactiveRender(state);
1110
+
1111
+ if (cleanup) cleanup(() => {
1112
+ if (fieldEl._dpSynthesizedHidden && fieldEl._dpSynthesizedHidden.isConnected) fieldEl._dpSynthesizedHidden.remove();
1113
+ if (state._timeMenuEl && state._timeMenuEl.isConnected) state._timeMenuEl.remove();
1114
+ if (calendar && calendar.isConnected) calendar.remove(); // the generated dropdown
1115
+ state._destroyed = true;
1116
+ });
1117
+ }
1118
+
1119
+ /* ---- Register directive + magic ----------------------------- */
1120
+ Alpine.directive('date', (el, { modifiers, expression }, { cleanup }) => {
1121
+ const tag = el.tagName;
1122
+
1123
+ // Inputs and buttons are field triggers; everything else IS a calendar.
1124
+ if (tag === 'INPUT' || tag === 'BUTTON') {
1125
+ wireField(el, expression, modifiers, cleanup);
1126
+ return;
1127
+ }
1128
+
1129
+ // Authored calendar — <div>, <menu popover>, <dialog popover>. Always
1130
+ // inline: the plugin renders into it; native HTML owns any
1131
+ // open/close (popovertarget, light-dismiss).
1132
+ const state = el._dateState || (el._dateState = createState(el));
1133
+ state.mode = 'inline';
1134
+ if (modifiers.includes('range')) state.selectionMode = 'range';
1135
+ else if (modifiers.includes('multiple')) state.selectionMode = 'multiple';
1136
+ if (modifiers.includes('time')) state.withTime = true;
1137
+ applyAttrConfig(state, el);
1138
+ bindConfigValue(state, el, expression);
1139
+
1140
+ // x-model / value bind directly on an inline calendar.
1141
+ const modelExpr = el.getAttribute('x-model');
1142
+ if (modelExpr) bindModel(state, el, modelExpr);
1143
+
1144
+ // Defer mount so linked fields can register first.
1145
+ setTimeout(() => {
1146
+ if (state._mounted) return;
1147
+ state.seed();
1148
+ state.render();
1149
+ state._mounted = true;
1150
+ if (el.id) _registry[el.id] = state.api;
1151
+ bindReactiveRender(state);
1152
+ }, 0);
1153
+
1154
+ cleanup(() => {
1155
+ if (el.id) delete _registry[el.id];
1156
+ if (state._timeMenuEl && state._timeMenuEl.isConnected) state._timeMenuEl.remove();
1157
+ state._destroyed = true;
1158
+ delete el._dateState;
1159
+ });
1160
+ });
1161
+
1162
+ Alpine.magic('date', (el) => {
1163
+ const local = findAncestorState(el);
1164
+ const byId = (id) => {
1165
+ if (!id) return local ? local.api : _nullApi;
1166
+ return _registry[id] || _nullApi;
1167
+ };
1168
+ return new Proxy(byId, {
1169
+ get(fn, prop) {
1170
+ if (prop === Symbol.toPrimitive || prop === 'toString' || prop === 'valueOf') return () => (local ? local.api.value : '');
1171
+ if (local && prop in local.api) return local.api[prop];
1172
+ return fn[prop];
1173
+ }
1174
+ });
1175
+ });
1176
+ }
1177
+
1178
+ /* ---- Bootstrap shim (loader contract) --------------------------- */
1179
+ let datepickerPluginInitialized = false;
1180
+ let datepickerAlpineHasWalked = false;
1181
+ document.addEventListener('alpine:initialized', () => { datepickerAlpineHasWalked = true; });
1182
+
1183
+ function ensureDatepickerPluginInitialized() {
1184
+ if (datepickerPluginInitialized) return;
1185
+ if (!window.Alpine || typeof window.Alpine.directive !== 'function') return;
1186
+ datepickerPluginInitialized = true;
1187
+ initializeDatepickerPlugin();
1188
+ if (datepickerAlpineHasWalked && typeof window.Alpine.initTree === 'function') {
1189
+ // querySelector can't prefix-match attribute names (x-date.range), so
1190
+ // scan attributes for the late-load self-walk.
1191
+ document.querySelectorAll('*').forEach(el => {
1192
+ if (el.__x) return;
1193
+ for (const a of el.attributes || []) {
1194
+ if (a.name === 'x-date' || a.name.startsWith('x-date.')) { window.Alpine.initTree(el); break; }
1195
+ }
1196
+ });
1197
+ }
1198
+ }
1199
+ window.ensureDatepickerPluginInitialized = ensureDatepickerPluginInitialized;
1200
+
1201
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ensureDatepickerPluginInitialized);
1202
+ document.addEventListener('alpine:init', ensureDatepickerPluginInitialized);
1203
+ if (window.Alpine && typeof window.Alpine.directive === 'function') setTimeout(ensureDatepickerPluginInitialized, 0);
1204
+ else {
1205
+ const check = setInterval(() => { if (window.Alpine?.directive) { clearInterval(check); ensureDatepickerPluginInitialized(); } }, 10);
1206
+ setTimeout(() => clearInterval(check), 5000);
1207
+ }
1208
+ })();