rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

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