| 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 | |