mnfst 0.5.121 → 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.
- package/lib/manifest.chart.css +195 -0
- package/lib/manifest.charts.js +593 -0
- package/lib/manifest.checkbox.css +2 -2
- package/lib/manifest.colorpicker.js +198 -41
- package/lib/manifest.css +755 -21
- package/lib/manifest.data.js +35 -7
- package/lib/manifest.datepicker.css +504 -0
- package/lib/manifest.datepicker.js +1208 -0
- package/lib/manifest.dialog.css +7 -4
- package/lib/manifest.dropdown.css +7 -10
- package/lib/manifest.integrity.json +9 -5
- package/lib/manifest.js +18 -4
- package/lib/manifest.localization.js +5 -1
- package/lib/manifest.min.css +1 -1
- package/lib/manifest.payments.js +583 -0
- package/lib/manifest.schema.json +77 -0
- package/lib/manifest.sidebar.css +7 -6
- package/lib/manifest.status.js +680 -0
- package/lib/manifest.theme.css +6 -4
- package/lib/manifest.toast.css +1 -1
- package/lib/manifest.tooltip.css +48 -16
- package/lib/manifest.utilities.css +3 -2
- package/lib/manifest.utilities.js +18 -2
- package/package.json +3 -1
|
@@ -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
|
+
})();
|