carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

1/* datetime.js — the natural-language date combobox. A sibling of
2 rastrillo.js and select.js, on exactly the same terms: first-party,
3 dependency-free, inert by default, and app-owned from the moment
4 rastrillo new writes it into static/.
5
6 Type "tomorrow", "next fri 9am", "25 Dec 6pm", "in 2 weeks" or
7 "14:30" and the combobox resolves it, previews the reading, and
8 writes it back to the native input on commit. The native input never
9 leaves the DOM: it keeps its name and its wire value, so every POST
10 is byte-identical to the un-enhanced form and the server keeps
11 parsing it with the same code. With scripts off the field is an
12 ordinary <input type="date">, and the browser's own picker still
13 opens.
14
15 Vocabulary:
16 data-rst-date on a date or datetime-local input: arm
17 the combobox
18 data-rst-time the same, on a time input — the script
19 reads a clock rather than a calendar
20 data-rst-date-words the parser vocabulary, one JSON object
21 of |-separated spellings (see
22 ui/funcs.go's dateWords)
23 data-rst-date-set the commit affordance's label
24 data-rst-date-hint guidance under the list, {example}
25 substituted here with a date this
26 locale's own formatter wrote
27 data-rst-date-pick label for the native-picker button
28 data-rst-date-results live-region text, {n} substituted
29 data-rst-date-result-one live-region text for exactly one
30 data-rst-date-quick-* the seven quick-pick labels: today,
31 tomorrow, next-week, plus-1h, plus-2h,
32 end-of-day, next-day
33 data-rst-range on a WRAPPER around two armed inputs:
34 the two pair up by DOM order (first is
35 the start, second the end). The end
36 gets quick picks relative to the
37 start. A value of "session" also seeds
38 an empty end to start + 1h when the
39 start is committed.
40
41 ui/partials/field-date.html, field-time.html, field-datetime.html
42 and field-daterange.html emit all of it. Every string comes from the
43 framework's base catalog (rastrillo.ui.date_*) and rides out on
44 attributes, because this markup is built in the browser where the
45 catalog is out of reach — so this file carries no English
46 VOCABULARY: not one word the parser matches on is written here. It
47 does carry English fallbacks for the eleven labels it puts on
48 screen, exactly as select.js does, so the file still works standing
49 alone in a page that forgot an attribute.
50
51 The parser is a pure function of (text, vocabulary, locale tables,
52 now). It knows no month names and no weekday names by heart: it asks
53 Intl for them, in the page's own language, and folds the digits the
54 same way, so a field enhanced in Hindi reads Devanagari digits and
55 Hindi month names without a table anyone has to maintain. The
56 vocabulary — "tomorrow", "next", "in", "ago" — is the half a machine
57 cannot derive, and that half arrives translated on the attribute.
58
59 The pure half is exported for Node at the bottom of the file, behind
60 a guard a browser never takes; ui/datetime_node.mjs drives it
61 against ui/testdata/datetime/*.json. */
62(function () {
63 "use strict";
64
65 /* ── the parser ──────────────────────────────────────────────────
66 Pure: no document, no state, no strings of its own. */
67
68 var COMBINING = /[\u0300-\u036f]/g;
69
70 // Scripts that separate words with spaces. A phrase may only match
71 // between word boundaries in these, so "at" cannot be found inside
72 // "atlas" — while an affixing script (Japanese 後, Chinese 后) is
73 // deliberately left free to attach straight onto a quantity. Digits
74 // are excluded on purpose: "9am" and "25dec" are one run to a
75 // tokenizer and two words to a person.
76 var LETTERISH = /[a-z\u00c0-\u024f\u0370-\u03ff\u0400-\u04ff\u0530-\u058f\u0590-\u05ff\u0600-\u06ff\u0900-\u097f\u0980-\u09ff\u0b80-\u0bff\u0e00-\u0e7f]/;
77 // The punctuation that separates words, and not only the ASCII of
78 // it: Arabic writes its comma \u060c and its semicolon \u061b, and CJK
79 // writes \uff0c\u3001\u3002. Left out, those characters fall through to the
80 // unknown-run branch and refuse a phrase that was punctuated
81 // correctly in its own script.
82 var BREAKS = /[\s,;\u060c\u061b\uff0c\u3001\u3002]/;
83
84 function pad(n) { return (n < 10 ? "0" : "") + n; }
85
86 function midnight(d) {
87 return new Date(d.getFullYear(), d.getMonth(), d.getDate());
88 }
89
90 function shiftDays(d, n) {
91 var x = midnight(d);
92 x.setDate(x.getDate() + n);
93 return x;
94 }
95
96 // Strictly the NEXT one: a weekday named on the day it falls means
97 // the one a week out, never today. Naming today has a word of its
98 // own in every catalog, so the ambiguity buys nothing.
99 function comingWeekday(now, target) {
100 var delta = (target - now.getDay() + 7) % 7;
101 return shiftDays(now, delta === 0 ? 7 : delta);
102 }
103
104 function lastWeekday(now, target) {
105 var delta = (now.getDay() - target + 7) % 7;
106 return shiftDays(now, -(delta === 0 ? 7 : delta));
107 }
108
109 // A day-and-month with no year means the next time it comes round.
110 function inferYear(now, mo, d) {
111 var y = now.getFullYear();
112 return new Date(y, mo, d) < midnight(now) ? y + 1 : y;
113 }
114
115 // Checked, never trusted: a 31st of February rolls silently forward
116 // in the Date constructor, which is how a typo becomes a date nobody
117 // meant. Built, read back, and refused if it moved.
118 function makeDate(y, mo, d, h, mi) {
119 // The carrier's wire format is four digits of year, so a year below
120 // a thousand writes "500-12-25" — which every date input rejects by
121 // silently emptying itself. A reading that clears the field is not
122 // a reading, and the floor lives here rather than beside each
123 // caller so no path can grow around it: the wire formats, the
124 // numeric d/m/y, "25 dec 0500" and a bare year all pass through.
125 if (y < 1000) return null;
126 if (mo < 0 || mo > 11 || d < 1 || d > 31) return null;
127 if (h < 0 || h > 23 || mi < 0 || mi > 59) return null;
128 var dt = new Date(y, mo, d, h, mi, 0, 0);
129 if (dt.getMonth() !== mo || dt.getDate() !== d) return null;
130 return dt;
131 }
132
133 // The numbering systems a person may type in, probed against the
134 // locale and unioned into the fold. The locale's default is not
135 // enough on its own: this ICU resolves plain "ar" and plain "hi" to
136 // latn, so those fields read "25" and refused ٢٥ and २५ — the digits
137 // their keyboards make. Unioning DIGITS is safe in a way that unioning
138 // words never is: a digit means the same number in every language, so
139 // an English field that also takes ٢٥ has guessed nothing. Systems
140 // that spell a number a word at a time are absent on purpose; they
141 // cannot be read back a character at a time either.
142 var NUMBERING = ["arab", "arabext", "beng", "deva"];
143
144 // One system's ten digits, merged in. False where it adds nothing, or
145 // cannot be folded at all: more than a character to a digit, a digit
146 // written twice, or a fold contradicting one already agreed.
147 function foldDigits(nf, map) {
148 var i, parts, k, digit, add = {}, differs = false;
149 for (i = 0; i <= 9; i++) {
150 parts = nf.formatToParts(i);
151 digit = "";
152 for (k = 0; k < parts.length; k++) if (parts[k].type === "integer") digit = parts[k].value;
153 if (digit.length !== 1) return false;
154 if (add[digit] !== undefined) return false;
155 if (map[digit] !== undefined && map[digit] !== String(i)) return false;
156 if (digit !== String(i)) differs = true;
157 add[digit] = String(i);
158 }
159 for (digit in add) if (Object.prototype.hasOwnProperty.call(add, digit)) map[digit] = add[digit];
160 return differs;
161 }
162
163 // The ten digits this locale writes, mapped back to ASCII. ar-EG
164 // counts in "١٢٣", which neither \d nor unary + accepts, and a
165 // NumberFormat in that locale printing 0-9 IS the table. null where
166 // there is nothing to fold, and null for a numbering system that
167 // does not spell a number one digit at a time — it cannot be read
168 // back one digit at a time either.
169 function digitFolder(locale) {
170 var map = {}, differs = false, base, root, i;
171 try {
172 base = new Intl.NumberFormat(locale, { useGrouping: false });
173 } catch (e) {
174 return null;
175 }
176 if (foldDigits(base, map)) differs = true;
177 // The tag the probes hang off, with any -u- extension the resolver
178 // already added trimmed off: a second one is an ill-formed tag.
179 try {
180 root = base.resolvedOptions().locale.split("-u-")[0];
181 } catch (e) {
182 root = "";
183 }
184 for (i = 0; root && i < NUMBERING.length; i++) {
185 try {
186 if (foldDigits(new Intl.NumberFormat(root + "-u-nu-" + NUMBERING[i], { useGrouping: false }), map)) differs = true;
187 } catch (e) {
188 /* a numbering system this engine does not know folds nothing */
189 }
190 }
191 if (!differs) return null;
192 return function (s) {
193 var out = "", j;
194 for (j = 0; j < s.length; j++) out += map[s.charAt(j)] !== undefined ? map[s.charAt(j)] : s.charAt(j);
195 return out;
196 };
197 }
198
199 // One normalizer, used on both sides — on the vocabulary and the
200 // Intl names as the tables are built, and on the typed text as it is
201 // read — so a person who leaves the accents off still meets the
202 // words the catalog spells with them.
203 function makeFold(digits) {
204 return function (s) {
205 var out = String(s === null || s === undefined ? "" : s);
206 if (out.normalize) out = out.normalize("NFD").replace(COMBINING, "");
207 out = out.toLowerCase();
208 if (digits) out = digits(out);
209 return out.replace(/\s+/g, " ").trim();
210 };
211 }
212
213 // What this locale calls each month (or weekday), long and short,
214 // index-aligned with the probe dates — which is what turns a matched
215 // word back into a number. Asked of Intl rather than tabulated,
216 // because a table per language is precisely the thing this file
217 // exists not to keep.
218 function nameTable(locale, type, dates, fold) {
219 var widths = ["long", "short"], out = [], i, c, w, opts, fmt, parts, k, value;
220 // Two shapes of every month name, because a month is not one word
221 // in every language: a Russian calendar header says декабрь and a
222 // Russian typing a date writes 25 декабря, and Intl only hands over
223 // that second form when a DAY is in the format. Asking both ways
224 // and keeping both answers is how a DERIVED table covers a case the
225 // grammar decides — no genitive is written down here, and Ukrainian
226 // and Greek are fixed by the same two lines.
227 var contexts = type === "month" ? [null, "numeric"] : [null];
228 for (i = 0; i < dates.length; i++) out.push([]);
229 for (c = 0; c < contexts.length; c++)
230 for (w = 0; w < widths.length; w++) {
231 opts = { timeZone: "UTC" };
232 opts[type] = widths[w];
233 if (contexts[c]) opts.day = contexts[c];
234 try {
235 fmt = new Intl.DateTimeFormat(locale, opts);
236 } catch (e) {
237 continue;
238 }
239 for (i = 0; i < dates.length; i++) {
240 parts = fmt.formatToParts(dates[i]);
241 value = "";
242 for (k = 0; k < parts.length; k++) if (parts[k].type === type) value = parts[k].value;
243 value = fold(value);
244 if (!value) continue;
245 // A NAME has to be a word. Japanese and Chinese write their
246 // months as numbers, and Intl reports that as
247 // {month:"8"}+{literal:"\u6708"} — so the "name" of August comes
248 // back as the bare digit 8, and a table holding it turns every
249 // typed number into a month. It did: ja "2025" tokenized as
250 // month 2 plus 025 and committed 25 February 2027 without a
251 // word of warning, which is the one thing this parser promises
252 // never to do. Anything that is only digits and separators is
253 // refused here rather than trusted; a calendar whose months are
254 // numbers is one this grammar cannot read, and saying so is the
255 // honest answer.
256 if (!/[^\d\s.,\/\-]/.test(value)) continue;
257 if (out[i].indexOf(value) === -1) out[i].push(value);
258 // An abbreviation's full stop is dropped as often as it is
259 // typed ("16 Aug." retyped as "16 Aug"), so both spellings are
260 // in the table rather than tolerated by one regex somewhere.
261 if (value.charAt(value.length - 1) === "." && value.length > 1) {
262 value = value.slice(0, -1);
263 if (out[i].indexOf(value) === -1) out[i].push(value);
264 }
265 }
266 }
267 return out;
268 }
269
270 // The options the field's own display formats with. Up here in the
271 // pure half rather than beside the page code that uses it, because
272 // the parser has to read that display back: what this field writes,
273 // it must be able to re-read, and a reading that cannot is how an
274 // in-place edit turns into a refusal.
275 function displayOptions(kind) {
276 var opts = kind === "time"
277 ? { hour: "numeric", minute: "2-digit" }
278 : { weekday: "short", day: "numeric", month: "short", year: "numeric" };
279 if (kind === "datetime") {
280 opts.hour = "numeric";
281 opts.minute = "2-digit";
282 }
283 return opts;
284 }
285
286 // What Intl writes that nobody typed: the literals it threads between
287 // the numbers (ja 年月日, ru's "г.", pt's "de", the brackets round a
288 // Japanese weekday) and the dayPeriod names it chooses, which are not
289 // always the ones a catalog spells — Intl writes yue's afternoon as
290 // 下晝 where the catalog says 下午. Derived from the same options the
291 // display uses, plus the day-and-month shapes, which is the form a
292 // person types ("25 de marzo"). Literals come back ignorable and
293 // dayPeriods come back as am/pm, so the field can read its own
294 // writing without a per-language table anybody maintains.
295 function displayTokens(locale, fold) {
296 var probes = [displayOptions("date"), displayOptions("datetime"), displayOptions("time"),
297 { day: "numeric", month: "long" }, { day: "numeric", month: "short" }];
298 var when = [new Date(Date.UTC(2026, 11, 25, 15, 30)), new Date(Date.UTC(2026, 2, 3, 9, 5))];
299 var out = { literal: {}, am: {}, pm: {} }, p, i, fmt, parts, k, v, c, run;
300 function keep(bag, s) { s = fold(s); if (s) bag[s] = true; }
301 for (p = 0; p < probes.length; p++) {
302 probes[p].timeZone = "UTC";
303 try {
304 fmt = new Intl.DateTimeFormat(locale, probes[p]);
305 } catch (e) {
306 continue;
307 }
308 for (i = 0; i < when.length; i++) {
309 parts = fmt.formatToParts(when[i]);
310 for (k = 0; k < parts.length; k++) {
311 v = parts[k].value;
312 if (parts[k].type === "dayPeriod") {
313 keep(when[i].getUTCHours() < 12 ? out.am : out.pm, v);
314 continue;
315 }
316 if (parts[k].type !== "literal") continue;
317 // One literal is not always one token: ja hands back "日(" in
318 // a single part. Split into letter runs and single marks so
319 // the day counter stays a day counter and the bracket becomes
320 // its own ignorable thing.
321 for (c = 0, run = ""; c <= v.length; c++) {
322 if (c < v.length && letterish(v.charAt(c))) { run += v.charAt(c); continue; }
323 keep(out.literal, run);
324 run = "";
325 if (c < v.length && !BREAKS.test(v.charAt(c))) keep(out.literal, v.charAt(c));
326 }
327 }
328 }
329 }
330 return out;
331 }
332
333 var TABLES = {};
334
335 // Everything derived from a locale, built once and cached: the fold,
336 // the month and weekday names, the wall-clock formatters the field
337 // shows its value with.
338 function tables(locale) {
339 var key = locale || "";
340 if (TABLES[key]) return TABLES[key];
341 var loc = locale || undefined;
342 var fold = makeFold(digitFolder(loc));
343 var monthDates = [], weekDates = [], i;
344 for (i = 0; i < 12; i++) monthDates.push(new Date(Date.UTC(2026, i, 15)));
345 // 1 Feb 2026 is a Sunday, so the seven probes land index-aligned
346 // with Date.prototype.getDay.
347 for (i = 0; i < 7; i++) weekDates.push(new Date(Date.UTC(2026, 1, 1 + i)));
348 var t = {
349 locale: loc,
350 fold: fold,
351 months: nameTable(loc, "month", monthDates, fold),
352 weekdays: nameTable(loc, "weekday", weekDates, fold),
353 display: displayTokens(loc, fold)
354 };
355 // Does this locale call its months anything at all? Japanese does
356 // not — it numbers them, and nameTable will not hold a bare digit
357 // as a name — so the table is empty and the grammar reads 12月25日
358 // another way. Derived: no list of numbering calendars kept here.
359 t.namedMonths = false;
360 for (i = 0; i < t.months.length; i++) if (t.months[i].length) t.namedMonths = true;
361 TABLES[key] = t;
362 return t;
363 }
364
365 // The vocabulary and the Intl names as one list, longest phrase
366 // first — so a plain scan cannot let "m" win over "may", and a
367 // multi-word entry ("hôm nay", "منتصف الليل", "seo chugainn") is
368 // matched whole. Splitting is on "|" only: "meio-dia" is one entry
369 // and stays one.
370 function index(vocab, tbl) {
371 var list = [], name, alts, i, m, k;
372 for (name in vocab) {
373 if (!Object.prototype.hasOwnProperty.call(vocab, name)) continue;
374 alts = String(vocab[name]).split("|");
375 for (i = 0; i < alts.length; i++) {
376 var text = tbl.fold(alts[i]);
377 if (text) list.push({ text: text, role: "word", kind: name, at: -1 });
378 }
379 }
380 // The display's own vocabulary, on the same list: a literal is a
381 // word the reading may skip, and a dayPeriod is am or pm however
382 // Intl chose to spell it. Both are additive — a spelling already
383 // carrying a kind keeps it and gains this one, because matchAt
384 // merges every entry of the same length.
385 var seen = ["literal", "am", "pm"], kind;
386 for (i = 0; i < seen.length; i++) {
387 kind = seen[i] === "literal" ? "ignore" : seen[i];
388 for (name in tbl.display[seen[i]])
389 if (Object.prototype.hasOwnProperty.call(tbl.display[seen[i]], name))
390 list.push({ text: name, role: "word", kind: kind, at: -1 });
391 }
392 for (m = 0; m < tbl.months.length; m++)
393 for (k = 0; k < tbl.months[m].length; k++)
394 list.push({ text: tbl.months[m][k], role: "month", kind: "", at: m });
395 for (m = 0; m < tbl.weekdays.length; m++)
396 for (k = 0; k < tbl.weekdays[m].length; k++)
397 list.push({ text: tbl.weekdays[m][k], role: "weekday", kind: "", at: m });
398 list.sort(function (a, b) { return b.text.length - a.text.length; });
399 return list;
400 }
401
402 // A separator is never a letter, whatever block it lives in. The
403 // Arabic comma sits at \u060c, inside the same range as the letters
404 // either side of it, so a class drawn by range alone reads
405 // "\u063a\u062f\u0627\u060c 6" as one unbroken word and refuses a phrase that was
406 // punctuated correctly.
407 function letterish(c) {
408 return !!c && !BREAKS.test(c) && LETTERISH.test(c);
409 }
410
411 function boundaryOK(s, from, to) {
412 if (from > 0 && letterish(s.charAt(from - 1)) && letterish(s.charAt(from))) return false;
413 if (to < s.length && letterish(s.charAt(to)) && letterish(s.charAt(to - 1))) return false;
414 return true;
415 }
416
417 // The longest phrase starting here, as one token. Every entry of that
418 // same length is merged in, because one spelling can wear two hats:
419 // Vietnamese "sau" is both "next" and "in", and which one it means is
420 // a question the grammar answers, not the scanner.
421 function matchAt(s, pos, idx) {
422 var best = -1, tok = null, i, e;
423 for (i = 0; i < idx.length; i++) {
424 e = idx[i];
425 if (best >= 0 && e.text.length < best) break;
426 if (s.slice(pos, pos + e.text.length) !== e.text) continue;
427 if (!boundaryOK(s, pos, pos + e.text.length)) continue;
428 if (best < 0) {
429 best = e.text.length;
430 tok = { t: "word", kinds: {}, month: -1, weekday: -1 };
431 }
432 if (e.role === "word") tok.kinds[e.kind] = true;
433 else if (e.role === "month") tok.month = e.at;
434 else tok.weekday = e.at;
435 }
436 return tok ? { tok: tok, len: best } : null;
437 }
438
439 function scan(s, idx) {
440 var toks = [], pos = 0, n = s.length, m, hit, start, glued = false;
441 // Whether nothing at all stood between this token and the one
442 // before it. A counter glued to its number belongs to that number
443 // (12月, 25日); the same word with a space in front of it is a noun
444 // being counted ("3 months"). A fact about the typing, not about
445 // any language, which is why it can be recorded without knowing
446 // which language was typed.
447 function push(tok) {
448 tok.glued = glued;
449 toks.push(tok);
450 glued = true;
451 }
452 while (pos < n) {
453 if (BREAKS.test(s.charAt(pos))) { pos++; glued = false; continue; }
454 m = /^(\d{1,2}):(\d{2})/.exec(s.slice(pos));
455 if (m) {
456 push({ t: "clock", h: +m[1], mi: +m[2] });
457 pos += m[0].length;
458 continue;
459 }
460 hit = matchAt(s, pos, idx);
461 if (hit) { push(hit.tok); pos += hit.len; continue; }
462 m = /^\d+/.exec(s.slice(pos));
463 if (m) {
464 push({ t: "num", n: +m[0], digits: m[0].length });
465 pos += m[0].length;
466 continue;
467 }
468 start = pos;
469 while (pos < n && !BREAKS.test(s.charAt(pos)) && !/\d/.test(s.charAt(pos))) {
470 if (pos > start && matchAt(s, pos, idx)) break;
471 pos++;
472 }
473 if (pos === start) pos++;
474 push({ t: "other", raw: s.slice(start, pos) });
475 }
476 return toks;
477 }
478
479 // parse(text, vocab, tbl, now, ctx) -> {date, hasDate, hasTime} or
480 // null. Never a guess: text this grammar does not understand comes
481 // back null, so nothing is armed and nothing is committed.
482 //
483 // ctx.prev is the value this field is already showing; ctx.base is
484 // the day a wired range's START is on. The two are not
485 // interchangeable. A phrase naming no date at all ("5pm") lands on
486 // the START's day — that is what makes a time-only end stay in the
487 // same session. A phrase naming PART of a date ("2030") keeps the day
488 // THIS field is showing, because the part it left out is the part it
489 // means to keep.
490 function parse(text, vocab, tbl, now, ctx) {
491 ctx = ctx || {};
492 var raw = tbl.fold(text);
493 if (!raw) return null;
494 // Cached against the vocabulary itself, not just the locale: a
495 // page could in principle carry two, and a stale index would parse
496 // the second field with the first field's words.
497 var key = "";
498 for (var name in vocab) if (Object.prototype.hasOwnProperty.call(vocab, name)) key += name + "=" + vocab[name] + "\u0000";
499 if (tbl.indexKey !== key) {
500 tbl.index = index(vocab, tbl);
501 tbl.indexKey = key;
502 }
503 var idx = tbl.index;
504
505 var prev = ctx.prev || null;
506 var base = ctx.base || null;
507 var anchor = midnight(base || prev || now);
508 var own = midnight(prev || base || now);
509 var clockH = prev ? prev.getHours() : 9;
510 var clockM = prev ? prev.getMinutes() : 0;
511
512 var m, d;
513
514 // The wire formats, read straight back: what the native carrier
515 // holds, and what a person pastes out of one form into another.
516 m = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[t ](\d{1,2}):(\d{2})(?::\d{2})?)?$/.exec(raw);
517 if (m) {
518 d = makeDate(+m[1], +m[2] - 1, +m[3],
519 m[4] === undefined ? clockH : +m[4],
520 m[4] === undefined ? clockM : +m[5]);
521 return d ? { date: d, hasDate: true, hasTime: m[4] !== undefined } : null;
522 }
523 // Year first, with the separators a keyboard reaches for rather
524 // than the wire's hyphen. Nothing else in any calendar puts four
525 // digits in front of a date, so 2026/12/25 and 2026.12.25 need no
526 // day-or-month guess: it is how ja, zh and yue write one down.
527 m = /^(\d{4})[\/.](\d{1,2})[\/.](\d{1,2})$/.exec(raw);
528 if (m) {
529 d = makeDate(+m[1], +m[2] - 1, +m[3], clockH, clockM);
530 return d ? { date: d, hasDate: true, hasTime: false } : null;
531 }
532 // Numeric d/m[/y], day first — and swapped where only the other
533 // reading can be true, so a pasted 12/25/2027 is understood rather
534 // than refused.
535 m = /^(\d{1,2})[\/.\-](\d{1,2})(?:[\/.\-](\d{2}|\d{4}))?$/.exec(raw);
536 if (m) {
537 var day = +m[1], mon = +m[2] - 1, swap;
538 if (mon > 11 && day <= 12) { swap = day; day = +m[2]; mon = swap - 1; }
539 var year = m[3] === undefined ? inferYear(now, mon, day)
540 : (+m[3] < 100 ? 2000 + +m[3] : +m[3]);
541 d = makeDate(year, mon, day, clockH, clockM);
542 return d ? { date: d, hasDate: true, hasTime: false } : null;
543 }
544
545 var toks = scan(raw, idx), i;
546 if (!toks.length) return null;
547 for (i = 0; i < toks.length; i++) if (toks[i].t === "other") return null;
548
549 var used = [];
550 function find(kind) {
551 for (var k = 0; k < toks.length; k++)
552 if (!used[k] && toks[k].t === "word" && toks[k].kinds[kind]) return k;
553 return -1;
554 }
555 function findRole(role) {
556 for (var k = 0; k < toks.length; k++) {
557 if (used[k] || toks[k].t !== "word" || toks[k][role] < 0) continue;
558 // A weekday name being counted is not a weekday: nobody writes
559 // a date by putting a number in front of one. A MONTH name is
560 // the opposite case and is left alone — "25dec" is how people
561 // type one.
562 if (role === "weekday" && k > 0 && toks[k].glued && isCounter(k, k - 1)) continue;
563 return k;
564 }
565 return -1;
566 }
567 function findKind(t) {
568 for (var k = 0; k < toks.length; k++) if (!used[k] && toks[k].t === t) return k;
569 return -1;
570 }
571 function after(k) {
572 return k + 1 < toks.length && !used[k + 1] ? k + 1 : -1;
573 }
574 function before(k) {
575 return k > 0 && !used[k - 1] ? k - 1 : -1;
576 }
577 function unit() {
578 var names = ["minute", "hour", "day", "week", "month"], k, at;
579 for (k = 0; k < names.length; k++) {
580 at = find(names[k]);
581 if (at >= 0) return { name: names[k], at: at };
582 }
583 return null;
584 }
585 // Is the name at k being counted rather than named? A number in
586 // front of it says so, and so does a next/last particle written
587 // straight onto a word that is a unit as well as a name: 来月 is
588 // next MONTH and 翌日 is the next DAY, and both asked for a weekday
589 // until this line, because 月 and 日 are also how Japanese
590 // abbreviates Monday and Sunday.
591 function isCounter(k, p) {
592 if (toks[p].t === "num") return true;
593 if (toks[p].t !== "word") return false;
594 if (!toks[p].kinds.next && !toks[p].kinds.last) return false;
595 return !!(toks[k].kinds.day || toks[k].kinds.week || toks[k].kinds.month);
596 }
597 function isWord(k, kind) {
598 return k >= 0 && toks[k].t === "word" && !!toks[k].kinds[kind];
599 }
600 // The counter glued to a number already claimed — the 日 of 25日,
601 // the 号 of 25号. It belongs to that number, and leaving it behind
602 // failed the leftover check on a date written correctly. Glued is
603 // the whole test: "3 days" has a space and stays a duration.
604 function claimCounter(at, kind) {
605 var nx = at >= 0 ? after(at) : -1;
606 if (nx < 0 || toks[nx].t !== "word" || !toks[nx].glued) return false;
607 if (!toks[nx].kinds[kind]) return false;
608 used[nx] = true;
609 return true;
610 }
611 // A number glued to the month counter where months have no names:
612 // the 12 of 12月25日. Gated on the empty table so no language that
613 // CALLS its months something reaches here — without it, English
614 // "3months" would read as March.
615 function counterMonth() {
616 if (tbl.namedMonths) return -1;
617 for (var k = 0; k < toks.length; k++) {
618 if (used[k] || toks[k].t !== "num" || toks[k].digits > 2) continue;
619 var nx = after(k);
620 if (nx >= 0 && toks[nx].t === "word" && toks[nx].glued && toks[nx].kinds.month) return k;
621 }
622 return -1;
623 }
624
625 var date = null, hasDate = false, hasTime = false, h = 0, mi = 0;
626
627 // A quantity is what tells "in"/"ago" apart from "next"/"last"
628 // where one spelling carries both — Vietnamese "sau" is "next"
629 // with nothing counted and "in" with a number beside it.
630 var quantified = findKind("num") >= 0;
631 var into = find("in"), back = find("ago");
632 // Both directions at once is not a phrase, it is a contradiction:
633 // "in 2 weeks ago" and "через 2 недели назад" name no instant, and
634 // the sign was being picked by whichever branch ran second — ago
635 // won, silently, and the reading looked confident. Two DISTINCT
636 // tokens is the test, because one spelling is allowed to carry
637 // both kinds (Vietnamese "sau" does) and a single word wearing two
638 // hats is still one word.
639 if (into >= 0 && back >= 0 && into !== back) return null;
640 if (quantified && (into >= 0 || back >= 0)) {
641 var u = unit();
642 if (u) {
643 var qi = findKind("num");
644 var qty = toks[qi].n, sign = back >= 0 ? -1 : 1;
645 used[qi] = true;
646 used[u.at] = true;
647 if (into >= 0) used[into] = true;
648 if (back >= 0) used[back] = true;
649 if (u.name === "minute" || u.name === "hour") {
650 // An offset counted in clock units moves the clock, so it
651 // names a time as well as a day.
652 date = new Date(now.getTime() + sign * qty * (u.name === "hour" ? 3600000 : 60000));
653 hasDate = true;
654 hasTime = true;
655 h = date.getHours();
656 mi = date.getMinutes();
657 } else {
658 date = midnight(now);
659 if (u.name === "day") date.setDate(date.getDate() + sign * qty);
660 else if (u.name === "week") date.setDate(date.getDate() + sign * qty * 7);
661 else date.setMonth(date.getMonth() + sign * qty);
662 hasDate = true;
663 }
664 }
665 }
666
667 // The clock. Claimed before the calendar so "25 dec 6pm" cannot
668 // read its 6 as a second day number.
669 if (!hasTime) {
670 var noon = find("noon"), mid = find("midnight");
671 if (noon >= 0) { h = 12; mi = 0; hasTime = true; used[noon] = true; }
672 else if (mid >= 0) { h = 0; mi = 0; hasTime = true; used[mid] = true; }
673 else {
674 var ci = findKind("clock");
675 if (ci >= 0) {
676 h = toks[ci].h;
677 mi = toks[ci].mi;
678 used[ci] = true;
679 hasTime = true;
680 } else {
681 for (i = 0; i < toks.length && !hasTime; i++) {
682 if (used[i] || toks[i].t !== "num") continue;
683 var nx = after(i), pv = before(i);
684 if (isWord(nx, "am") || isWord(nx, "pm") || isWord(pv, "am") || isWord(pv, "pm")) {
685 h = toks[i].n;
686 mi = 0;
687 used[i] = true;
688 hasTime = true;
689 // A half-of-the-day marker and a clock marker can both
690 // sit on the same hour: \u5348\u5f8c6\u6642 and \u4e0b\u53486\u70b9 are "6 in the
691 // afternoon" with the hour word still attached. Claiming
692 // the number and leaving the \u6642 behind failed the
693 // leftover check and refused a perfectly ordinary phrase.
694 if (isWord(nx, "hour")) {
695 used[nx] = true;
696 var mnx = after(nx);
697 if (mnx >= 0 && toks[mnx].t === "num" && (after(mnx) < 0 || isWord(after(mnx), "minute"))) {
698 mi = toks[mnx].n;
699 used[mnx] = true;
700 if (after(mnx) >= 0) used[after(mnx)] = true;
701 }
702 }
703 } else if (isWord(nx, "hour")) {
704 h = toks[i].n;
705 mi = 0;
706 used[i] = true;
707 used[nx] = true;
708 hasTime = true;
709 var mn = after(nx);
710 if (mn >= 0 && toks[mn].t === "num" && (after(mn) < 0 || isWord(after(mn), "minute"))) {
711 mi = toks[mn].n;
712 used[mn] = true;
713 if (after(mn) >= 0) used[after(mn)] = true;
714 }
715 } else if (isWord(pv, "at") || isWord(nx, "at")) {
716 // The particle sits on either side of the hour: English
717 // and Spanish put it in front ("at 6", "a las 6") and
718 // Hindi, Bengali and Japanese put it behind (6 बजे, ৬ টায়,
719 // 6時に). Reading only the front one refused half the
720 // world's ordinary way of naming a time.
721 h = toks[i].n;
722 mi = 0;
723 used[i] = true;
724 used[isWord(pv, "at") ? pv : nx] = true;
725 hasTime = true;
726 }
727 }
728 }
729 if (hasTime) {
730 var pm = find("pm"), am = find("am");
731 // A half-of-the-day marker says the clock beside it is a
732 // 12-hour one, and a 12-hour clock has twelve hours on it.
733 // The fold below is modular, so without this line every
734 // impossible hour came back as a plausible one: "25pm" was
735 // 13:00 and "99am" was 03:00 — a guess dressed as a reading,
736 // and the leftover check could not see it because every token
737 // had been claimed.
738 //
739 // Zero is one of the twelve. Japanese and Chinese write
740 // midnight and noon as 午前0時 and 午後0時, 上午0点 and 下午0点 —
741 // ordinary usage, not a typo — and the modular fold already
742 // says the right thing about them: 0 with a morning marker is
743 // midnight, 0 with an afternoon one is noon, exactly as 12
744 // reads the other way round. That also lets English "0am"
745 // through, which is coherent (it IS midnight) and much
746 // simpler than a rule that would have to know which
747 // languages say it.
748 if (pm >= 0 || am >= 0) {
749 if (h < 0 || h > 12) return null;
750 if (pm >= 0) { used[pm] = true; h = (h % 12) + 12; }
751 else { used[am] = true; h = h % 12; }
752 }
753 }
754 }
755 if (hasTime && (h > 23 || mi > 59)) return null;
756 }
757
758 // The calendar.
759 if (!hasDate) {
760 var tod = find("today"), tom = find("tomorrow"), yes = find("yesterday");
761 if (tom >= 0) { date = shiftDays(now, 1); used[tom] = true; hasDate = true; }
762 else if (yes >= 0) { date = shiftDays(now, -1); used[yes] = true; hasDate = true; }
763 else if (tod >= 0) { date = midnight(now); used[tod] = true; hasDate = true; }
764 }
765 if (!hasDate) {
766 var next = find("next"), last = find("last"), dup;
767 var cm = counterMonth();
768 var wi = findRole("weekday");
769 var mo = findRole("month");
770 if (wi >= 0 && mo >= 0) {
771 if (wi === mo) {
772 // ONE spelling wearing both hats: Spanish "mar" is March and
773 // it is Tuesday, and the scanner merges same-length matches
774 // rather than picking for you. Twice in a row is this field's
775 // own writing — "mar, 3 mar 2026" is Tuesday and then March —
776 // so a second month-naming token settles it and the first is
777 // the decoration. Otherwise a day number beside it settles it
778 // ("25 mar" is a date and nothing else), and with no number
779 // to count, a weekday is what a person means.
780 for (dup = wi + 1; dup < toks.length; dup++)
781 if (!used[dup] && toks[dup].t === "word" && toks[dup].month >= 0) { mo = dup; break; }
782 if (mo !== wi) { used[wi] = true; wi = -1; }
783 else if (findKind("num") >= 0) wi = -1;
784 else mo = -1;
785 } else {
786 // A weekday sitting beside a month and a day is decoration,
787 // not the date: it is how this field writes its own value
788 // back ("Fri, 28 Aug 2026"), and the only way to retype a
789 // year in place is for the weekday beside it to be consumed
790 // rather than obeyed. Obeying it would move the date to the
791 // coming Friday and silently throw the typed year away.
792 used[wi] = true;
793 wi = -1;
794 }
795 } else if (wi >= 0 && cm >= 0) {
796 // Same rule where the months are numbered rather than named:
797 // 2026年12月25日(金) and 2026年12月25日周五 are a full date with
798 // the weekday written beside it, and the weekday is the part
799 // that follows from the rest.
800 used[wi] = true;
801 wi = -1;
802 }
803 if (wi >= 0) {
804 used[wi] = true;
805 if (last >= 0) { used[last] = true; date = lastWeekday(now, toks[wi].weekday); }
806 else {
807 if (next >= 0) used[next] = true;
808 date = comingWeekday(now, toks[wi].weekday);
809 }
810 hasDate = true;
811 } else {
812 if (mo >= 0) {
813 used[mo] = true;
814 var dnum = -1, ynum = -1, dat = -1, k;
815 for (k = 0; k < toks.length; k++) {
816 if (used[k] || toks[k].t !== "num") continue;
817 if (toks[k].digits === 4) {
818 if (ynum >= 0) break;
819 ynum = toks[k].n;
820 used[k] = true;
821 } else if (dnum < 0) {
822 dnum = toks[k].n;
823 dat = k;
824 used[k] = true;
825 } else if (ynum < 0) {
826 ynum = toks[k].n < 100 ? 2000 + toks[k].n : toks[k].n;
827 used[k] = true;
828 } else break;
829 }
830 if (dnum < 0) return null;
831 claimCounter(dat, "day");
832 date = makeDate(ynum >= 0 ? ynum : inferYear(now, toks[mo].month, dnum),
833 toks[mo].month, dnum, 0, 0);
834 if (!date) return null;
835 hasDate = true;
836 } else if (cm >= 0) {
837 // A calendar that numbers its months writes the number with
838 // its counter, and the counter is doing the work a name does
839 // everywhere else. The day beside it is written the same way,
840 // with or without its own counter.
841 var cmon = toks[cm].n - 1, cday = -1, cyear = -1, ck;
842 used[cm] = true;
843 used[cm + 1] = true;
844 ck = after(cm + 1);
845 if (ck >= 0 && toks[ck].t === "num" && toks[ck].digits <= 2) {
846 cday = toks[ck].n;
847 used[ck] = true;
848 claimCounter(ck, "day");
849 }
850 if (cday < 0) return null;
851 // The year goes in front here (2026年12月25日), so it is
852 // whatever four digits are still unspoken for. Without this
853 // the field could write a full date it could not read back.
854 for (ck = 0; ck < toks.length && cyear < 0; ck++) {
855 if (used[ck] || toks[ck].t !== "num" || toks[ck].digits !== 4) continue;
856 cyear = toks[ck].n;
857 used[ck] = true;
858 }
859 date = makeDate(cyear >= 0 ? cyear : inferYear(now, cmon, cday), cmon, cday, 0, 0);
860 if (!date) return null;
861 hasDate = true;
862 } else if (next >= 0 || last >= 0) {
863 var u2 = unit();
864 if (u2 && (u2.name === "day" || u2.name === "week" || u2.name === "month")) {
865 used[u2.at] = true;
866 var dir = last >= 0 ? -1 : 1;
867 used[last >= 0 ? last : next] = true;
868 date = midnight(now);
869 if (u2.name === "day") date.setDate(date.getDate() + dir);
870 else if (u2.name === "week") date.setDate(date.getDate() + 7 * dir);
871 else date.setMonth(date.getMonth() + dir);
872 hasDate = true;
873 }
874 }
875 }
876 }
877 if (!hasDate) {
878 // Four digits on their own move the year and nothing else — the
879 // same bargain a lone time strikes with the date. Focus selects
880 // the whole value, so "2030" is not a fragment someone left
881 // behind: it is the entire gesture of changing a year.
882 var only = -1, count = 0;
883 for (i = 0; i < toks.length; i++) {
884 if (used[i] || toks[i].t !== "num") continue;
885 count++;
886 if (toks[i].digits === 4) only = i;
887 }
888 // Four digits, and a year makeDate's floor will accept — checked
889 // here too so a refused year never claims its token and never
890 // reads as a date at all.
891 if (count === 1 && only >= 0 && toks[only].n >= 1000) {
892 used[only] = true;
893 date = new Date(own.getFullYear(), own.getMonth(), own.getDate());
894 date.setFullYear(toks[only].n);
895 hasDate = true;
896 }
897 }
898
899 // Anything left over is a word this reading did not account for,
900 // and a reading that ignores half the sentence is a guess. Two
901 // exceptions: the "at" particle, which is optional everywhere it
902 // appears and required nowhere; and a literal the display itself
903 // writes, which nobody typed on purpose and which carries no
904 // meaning to drop.
905 for (i = 0; i < toks.length; i++) {
906 if (used[i]) continue;
907 if (toks[i].t !== "word") return null;
908 if (toks[i].month >= 0 || toks[i].weekday >= 0) return null;
909 for (var kind in toks[i].kinds) if (kind !== "at" && kind !== "ignore") return null;
910 }
911 if (!hasDate && !hasTime) return null;
912
913 var on = hasDate ? date : anchor;
914 var out = makeDate(on.getFullYear(), on.getMonth(), on.getDate(),
915 hasTime ? h : clockH, hasTime ? mi : clockM);
916 return out ? { date: out, hasDate: hasDate, hasTime: hasTime } : null;
917 }
918
919 /* ── the page ────────────────────────────────────────────────────
920 Everything below is behind the document guard, so Node loads the
921 parser above and nothing else. */
922
923 if (typeof document !== "undefined") {
924
925 var CAL_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
926 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' +
927 '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>';
928
929 var seq = 0;
930
931 function toWire(kind, d) {
932 var day = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate());
933 var clock = pad(d.getHours()) + ":" + pad(d.getMinutes());
934 if (kind === "date") return day;
935 if (kind === "time") return clock;
936 return day + "T" + clock;
937 }
938
939 function fromWire(kind, value, now) {
940 var s = String(value || ""), m;
941 if (kind === "time") {
942 m = /^(\d{2}):(\d{2})/.exec(s);
943 return m ? new Date(now.getFullYear(), now.getMonth(), now.getDate(), +m[1], +m[2]) : null;
944 }
945 m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/.exec(s);
946 if (!m) return null;
947 return new Date(+m[1], +m[2] - 1, +m[3], m[4] ? +m[4] : 0, m[5] ? +m[5] : 0);
948 }
949
950 // displayOptions is up in the pure half, because the parser reads
951 // these back: one set of options, formatted here and derived there.
952 function formatter(locale, kind) {
953 try {
954 return new Intl.DateTimeFormat(locale, displayOptions(kind));
955 } catch (e) {
956 return null;
957 }
958 }
959
960 // Does this browser open a picker for this input? showPicker cannot
961 // be asked: HTML has it RETURN silently — no error, no event —
962 // where the browser has no picker for the type, which is exactly
963 // how a button wired to it ends up looking like a control and doing
964 // nothing. So the presence of the method is the only signal a page
965 // gets, and every field that has it gets the button.
966 //
967 // Recorded rather than guarded, because it will be rediscovered
968 // otherwise: measured on Firefox 152 (macOS), a date and a
969 // datetime-local both open the panel and a TIME opens nothing. The
970 // button is offered there anyway, on the same reasoning that lets a
971 // time field share the calendar's label — one affordance, in one
972 // place, on every date field. If a dead clock is reported, this
973 // paragraph is where the fix starts.
974 function canPick(el) { return !!el.showPicker; }
975
976 function attr(el, name, fallback) {
977 var v = el.getAttribute(name);
978 return v === null || v === "" ? fallback : v;
979 }
980
981 function enhance(native) {
982 if (native.dataset.rstEnhanced) return null; // idempotent: safe to re-scan
983 native.dataset.rstEnhanced = "true";
984
985 var kind = native.type === "time" ? "time" : (native.type === "date" ? "date" : "datetime");
986 var locale = document.documentElement.getAttribute("lang") || undefined;
987 var tbl = tables(locale);
988 var fmt = formatter(locale, kind);
989 var vocab = {};
990 try {
991 vocab = JSON.parse(attr(native, "data-rst-date-words", "{}")) || {};
992 } catch (e) {
993 vocab = {};
994 }
995
996 var id = native.id || "rst-dtp-" + ++seq;
997 var listId = id + "-listbox";
998 var setLabel = attr(native, "data-rst-date-set", "Set");
999 var hintText = attr(native, "data-rst-date-hint", "");
1000 var pickLabel = attr(native, "data-rst-date-pick", "Open the calendar");
1001 var manyFmt = attr(native, "data-rst-date-results", "{n} suggestions");
1002 var oneFmt = attr(native, "data-rst-date-result-one", "1 suggestion");
1003 var quick = {
1004 today: attr(native, "data-rst-date-quick-today", "Today"),
1005 tomorrow: attr(native, "data-rst-date-quick-tomorrow", "Tomorrow"),
1006 nextWeek: attr(native, "data-rst-date-quick-next-week", "In a week"),
1007 plus1h: attr(native, "data-rst-date-quick-plus-1h", "An hour later"),
1008 plus2h: attr(native, "data-rst-date-quick-plus-2h", "Two hours later"),
1009 endOfDay: attr(native, "data-rst-date-quick-end-of-day", "End of that day"),
1010 nextDay: attr(native, "data-rst-date-quick-next-day", "Same time next day")
1011 };
1012
1013 var wrap = document.createElement("div");
1014 wrap.setAttribute("rst-dtp", "");
1015
1016 var input = document.createElement("input");
1017 input.type = "text";
1018 input.setAttribute("rst-input", "");
1019 input.setAttribute("rst-dtp-input", "");
1020 input.id = id + "-combo";
1021 input.autocomplete = "off";
1022 input.spellcheck = false;
1023 input.setAttribute("role", "combobox");
1024 input.setAttribute("aria-expanded", "false");
1025 input.setAttribute("aria-controls", listId);
1026 input.setAttribute("aria-autocomplete", "list");
1027
1028 var list = document.createElement("ul");
1029 list.setAttribute("rst-dtp-list", "");
1030 list.id = listId;
1031 list.hidden = true;
1032 list.setAttribute("role", "listbox");
1033
1034 var status = document.createElement("p");
1035 status.className = "rst-sr-only";
1036 status.setAttribute("role", "status");
1037 status.setAttribute("aria-live", "polite");
1038
1039 native.parentNode.insertBefore(wrap, native);
1040 wrap.appendChild(input);
1041 wrap.appendChild(list);
1042 wrap.appendChild(status);
1043 wrap.appendChild(native);
1044
1045 // The label named the native input; it now names the control the
1046 // user actually types into. Without this the combobox has no
1047 // accessible name at all.
1048 var label = native.id ? document.querySelector('label[for="' + native.id + '"]') : null;
1049 if (label) label.setAttribute("for", input.id);
1050 else if (native.getAttribute("aria-label")) input.setAttribute("aria-label", native.getAttribute("aria-label"));
1051 if (native.getAttribute("aria-describedby"))
1052 input.setAttribute("aria-describedby", native.getAttribute("aria-describedby"));
1053
1054 // Hidden, never removed: it is what the form submits.
1055 native.removeAttribute("rst-input");
1056 native.classList.add("rst-sr-only");
1057 native.setAttribute("tabindex", "-1");
1058 native.setAttribute("aria-hidden", "true");
1059
1060 var pick = null;
1061 if (canPick(native)) {
1062 pick = document.createElement("button");
1063 pick.type = "button";
1064 pick.setAttribute("rst-dtp-pick", "");
1065 pick.setAttribute("aria-label", pickLabel);
1066 pick.innerHTML = CAL_SVG;
1067 wrap.insertBefore(pick, list);
1068 }
1069
1070 var rows = []; // [{li, date, hasTime}] — selectable rows only
1071 var active = -1;
1072 var baseOf = null; // range end: () => Date|null, the start's value
1073 var onCommit = null;
1074 var isEnd = false;
1075
1076 function current() { return fromWire(kind, native.value, new Date()); }
1077 function show(d) { return fmt ? fmt.format(d) : toWire(kind, d); }
1078 function paint() {
1079 var d = current();
1080 input.value = d ? show(d) : "";
1081 }
1082
1083 function setActive(i) {
1084 active = i;
1085 for (var k = 0; k < rows.length; k++) rows[k].li.classList.toggle("is-active", k === i);
1086 if (i >= 0) input.setAttribute("aria-activedescendant", rows[i].li.id);
1087 else input.removeAttribute("aria-activedescendant");
1088 }
1089
1090 // extra is the row's variant ("set") or a second attribute
1091 // ("rst-dtp-quick"): the vocabulary spells a variant in the
1092 // value slot and a kind as an attribute of its own, so one
1093 // parameter cannot be both, and the caller says which.
1094 function addRow(label, meta, date, hasTime, variant, extra) {
1095 var li = document.createElement("li");
1096 li.setAttribute("rst-dtp-row", variant || "");
1097 if (extra) li.setAttribute(extra, "");
1098 li.id = listId + "-" + rows.length;
1099 li.setAttribute("role", "option");
1100 // The row that IS the committed value says so in the
1101 // accessibility tree, which is what aria-selected means on a
1102 // listbox option — the highlight below is a separate thing,
1103 // moved by the arrow keys.
1104 li.setAttribute("aria-selected", toWire(kind, date) === native.value ? "true" : "false");
1105 var main = document.createElement("span");
1106 main.setAttribute("rst-dtp-label", "");
1107 main.textContent = label;
1108 li.appendChild(main);
1109 if (meta) {
1110 var m = document.createElement("span");
1111 m.setAttribute("rst-dtp-set", "");
1112 m.textContent = meta;
1113 li.appendChild(m);
1114 }
1115 li.addEventListener("mousedown", function (e) {
1116 e.preventDefault(); // keep focus; blur would undo the pick
1117 commit(date, hasTime);
1118 });
1119 list.appendChild(li);
1120 rows.push({ li: li, date: date, hasTime: hasTime });
1121 }
1122
1123 function picks(now) {
1124 var start = baseOf ? baseOf() : null;
1125 var out = [], at;
1126 if (isEnd && start && kind !== "date") {
1127 return [
1128 { label: quick.plus1h, date: new Date(start.getTime() + 3600000) },
1129 { label: quick.plus2h, date: new Date(start.getTime() + 7200000) },
1130 { label: quick.endOfDay, date: new Date(start.getFullYear(), start.getMonth(), start.getDate(), 23, 59) },
1131 { label: quick.nextDay, date: new Date(start.getTime() + 86400000) }
1132 ];
1133 }
1134 if (kind === "time") {
1135 var from = current() || now;
1136 return [
1137 { label: quick.plus1h, date: new Date(from.getTime() + 3600000) },
1138 { label: quick.plus2h, date: new Date(from.getTime() + 7200000) },
1139 { label: quick.endOfDay, date: new Date(from.getFullYear(), from.getMonth(), from.getDate(), 23, 59) }
1140 ];
1141 }
1142 at = function (d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 9, 0); };
1143 out.push({ label: quick.today, date: at(now) });
1144 out.push({ label: quick.tomorrow, date: at(shiftDays(now, 1)) });
1145 out.push({ label: quick.nextWeek, date: at(shiftDays(now, 7)) });
1146 return out;
1147 }
1148
1149 function ctx() {
1150 return { prev: current(), base: baseOf ? baseOf() : null };
1151 }
1152
1153 function rebuild(query) {
1154 var now = new Date();
1155 list.textContent = "";
1156 rows = [];
1157 var text = String(query || "").trim();
1158 var read = text ? parse(text, vocab, tbl, now, ctx()) : null;
1159 if (read) addRow(show(read.date), setLabel, read.date, read.hasTime, "set");
1160 var quickPicks = picks(now), i, skip = read ? toWire(kind, read.date) : "";
1161 for (i = 0; i < quickPicks.length; i++) {
1162 if (toWire(kind, quickPicks[i].date) === skip) continue;
1163 addRow(quickPicks[i].label, show(quickPicks[i].date), quickPicks[i].date, kind !== "date", "", "rst-dtp-quick");
1164 }
1165 if (hintText) {
1166 var hint = document.createElement("li");
1167 hint.setAttribute("rst-dtp-hint", "");
1168 hint.setAttribute("aria-hidden", "true");
1169 hint.textContent = hintText.replace("{example}", show(new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9, 0)));
1170 list.appendChild(hint);
1171 }
1172 status.textContent = rows.length === 1 ? oneFmt : manyFmt.replace("{n}", String(rows.length));
1173 // Text that failed to parse leaves NOTHING armed: a quick pick
1174 // sitting under the cursor would make Enter on a date the
1175 // grammar did not understand commit today instead of refusing.
1176 setActive(rows.length && (read || !text) ? 0 : -1);
1177 }
1178
1179 function open() {
1180 list.hidden = false;
1181 input.setAttribute("aria-expanded", "true");
1182 rebuild(input.value);
1183 }
1184
1185 function close() {
1186 list.hidden = true;
1187 input.setAttribute("aria-expanded", "false");
1188 input.removeAttribute("aria-activedescendant");
1189 active = -1;
1190 }
1191
1192 function commit(date, hasTime) {
1193 native.value = toWire(kind, date);
1194 native.dispatchEvent(new Event("change", { bubbles: true }));
1195 input.value = show(date);
1196 close();
1197 if (onCommit) onCommit(date, hasTime);
1198 }
1199
1200 function commitText() {
1201 var text = input.value.trim();
1202 if (!text) {
1203 // Cleared on purpose: an emptied field empties the carrier,
1204 // and a required native then fails validation the way it
1205 // would with no script at all.
1206 input.value = "";
1207 if (native.value) {
1208 native.value = "";
1209 native.dispatchEvent(new Event("change", { bubbles: true }));
1210 }
1211 return;
1212 }
1213 var read = parse(text, vocab, tbl, new Date(), ctx());
1214 if (read) commit(read.date, read.hasTime);
1215 else paint(); // unreadable text is not a value: put the old one back
1216 }
1217
1218 input.addEventListener("focus", function () {
1219 input.select();
1220 open();
1221 });
1222 input.addEventListener("click", open);
1223 input.addEventListener("input", function () { open(); });
1224 input.addEventListener("keydown", function (e) {
1225 switch (e.key) {
1226 case "ArrowDown":
1227 e.preventDefault();
1228 if (list.hidden) return open();
1229 if (rows.length) setActive(Math.min(active + 1, rows.length - 1));
1230 break;
1231 case "ArrowUp":
1232 e.preventDefault();
1233 if (list.hidden) return open();
1234 if (rows.length) setActive(Math.max(active - 1, 0));
1235 break;
1236 case "Enter":
1237 e.preventDefault();
1238 if (!list.hidden && active >= 0) commit(rows[active].date, rows[active].hasTime);
1239 else { commitText(); close(); }
1240 break;
1241 case "Escape":
1242 e.preventDefault();
1243 paint();
1244 close();
1245 break;
1246 }
1247 });
1248
1249 // Enter belongs to the combobox, never to the form: a commit that
1250 // also submits the page throws the commit away and posts a value
1251 // nobody finished choosing. The keydown handler above already
1252 // calls preventDefault, which normally stops this event being
1253 // generated at all — it is stopped here as well because a
1254 // SYNTHETIC key injection (CDP, and every browser drive built on
1255 // it) delivers keydown and keypress as two independent events,
1256 // so the second one arrives with the first one's refusal
1257 // discarded. Measured, not guessed: a keypress listener on the
1258 // page reports defaultPrevented=false for exactly that Enter.
1259 input.addEventListener("keypress", function (e) {
1260 if (e.key === "Enter") e.preventDefault();
1261 });
1262
1263 wrap.addEventListener("focusout", function (e) {
1264 if (wrap.contains(e.relatedTarget)) return;
1265 commitText();
1266 close();
1267 });
1268
1269 if (pick) {
1270 pick.addEventListener("click", function () {
1271 close();
1272 try {
1273 native.showPicker();
1274 } catch (err) {
1275 /* no user gesture, or no picker — nothing to break */
1276 }
1277 });
1278 }
1279 // A pick made in the native panel lands in the carrier; repaint.
1280 native.addEventListener("change", function () {
1281 if (document.activeElement !== input) paint();
1282 });
1283
1284 paint();
1285
1286 return {
1287 get value() { return current(); },
1288 commit: commit,
1289 follow: function (fn) { baseOf = fn; isEnd = true; },
1290 committed: function (fn) { onCommit = fn; }
1291 };
1292 }
1293
1294 // Two armed inputs inside one [data-rst-range] are a range: first
1295 // is the start, second the end, by DOM order. The end's quick picks
1296 // are relative to the start's current value, and a time typed into
1297 // the end with no date lands on the start's day.
1298 function wireRange(wrapper, start, end) {
1299 end.follow(function () { return start.value; });
1300 if (wrapper.getAttribute("data-rst-range") !== "session") return;
1301 // "session" ranges (a talk, a meeting) seed an empty or backwards
1302 // end to an hour later. Server-side nothing is seeded, so a
1303 // submission with scripts off is exactly what was typed.
1304 start.committed(function (d) {
1305 var have = end.value;
1306 if (!have || have <= d) end.commit(new Date(d.getTime() + 3600000), true);
1307 });
1308 }
1309
1310 function scanPage(root) {
1311 var armed = root.querySelectorAll("input[data-rst-date], input[data-rst-time]");
1312 var made = [], i, k, wrappers, inside, pair;
1313 for (i = 0; i < armed.length; i++) made.push([armed[i], enhance(armed[i])]);
1314 wrappers = root.querySelectorAll("[data-rst-range]");
1315 for (i = 0; i < wrappers.length; i++) {
1316 inside = wrappers[i].querySelectorAll("input[data-rst-date], input[data-rst-time]");
1317 pair = [];
1318 for (k = 0; k < inside.length && pair.length < 2; k++) {
1319 for (var j = 0; j < made.length; j++)
1320 if (made[j][0] === inside[k] && made[j][1]) pair.push(made[j][1]);
1321 }
1322 if (pair.length === 2) wireRange(wrappers[i], pair[0], pair[1]);
1323 }
1324 }
1325
1326 if (document.readyState === "loading") {
1327 document.addEventListener("DOMContentLoaded", function () { scanPage(document); });
1328 } else {
1329 scanPage(document);
1330 }
1331 }
1332
1333 // The pure half, for ui/datetime_node.mjs. A browser has no `module`
1334 // and never takes this branch.
1335 if (typeof module !== "undefined" && module && module.exports) {
1336 module.exports = { parse: parse, tables: tables, displayOptions: displayOptions };
1337 }
1338})();
1339