oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

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