oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1/* calendar.js — the month grid the date fields open when you press
2 their calendar button. A sibling of rastrillo.js, select.js and
3 datetime.js, on exactly the same terms: first-party, dependency-free,
4 inert by default, and app-owned from the moment rastrillo new writes
5 it into static/.
6
7 WHY IT IS ITS OWN FILE. datetime.js used to hand this job to
8 native.showPicker(), which is one line and, in a form, close to
9 useless: the panel it opens belongs to the browser, it cannot be
10 styled to match the page, it does not exist at all for a time input
11 on some engines, and it opens over the very field you were typing
12 into. Replacing it takes a real widget — six weeks of days, a month
13 that pages, a roving tabindex, a live preview of what the text
14 beside it currently reads as — and datetime.js was already at the
15 size where its own test says "split something out before it stops
16 being readable". This is the split. Nothing here parses anything,
17 and nothing in datetime.js draws a grid.
18
19 THE SEAM. This file publishes one function on the page:
20
21 window.rastrilloCalendar(cfg) -> a panel
22
23 datetime.js reads it at enhance time and falls back to the native
24 picker when it is absent, so a page that links one script and not
25 the other still works — the same defensive posture both files take
26 towards a missing attribute. cfg is documented on makePanel below.
27
28 The grid is a real <table> with real column headers, because that is
29 what a calendar is, and role="grid" over the top with a roving
30 tabindex so exactly one day is in the tab order at a time. Arrow
31 keys walk days and weeks, Page keys walk months, Shift+Page walks
32 years, Home and End reach the ends of the week, and in a
33 right-to-left page the horizontal arrows swap over, because "left"
34 means "back" only in a page that runs the other way.
35
36 It knows no month names and no weekday names by heart: it asks Intl
37 for them in the page's own language, numbers its days with the
38 locale's own digits, and starts its weeks on the day that locale
39 starts weeks on. The three functions that decide all of it are pure
40 and exported for Node at the bottom of the file, behind a guard a
41 browser never takes; ui/datetime_node.mjs drives them with
42 --calendar. */
43(function (global) {
44 "use strict";
45
46 /* ── the arithmetic ──────────────────────────────────────────────
47 Pure: no document, no state, no strings of its own. This is the
48 half of a calendar that can be quietly WRONG — a grid that drops a
49 day in a zone that moved its clocks that morning, a week that
50 starts on the wrong day, headings that do not describe their own
51 columns — so it is the half a test can reach without a browser. */
52
53 // The day a week starts on here: 0 for Sunday through 6 for
54 // Saturday, the numbering getDay uses. Asked of Intl where Intl will
55 // answer — getWeekInfo is the standard spelling and weekInfo the
56 // older property one, and both sit above this framework's browser
57 // floor — and Monday where it will not.
58 //
59 // Monday is the ISO week's first day and most of the world's, and a
60 // table of exceptions per country is precisely the thing these files
61 // exist not to keep. It is also the cheapest possible thing to be
62 // wrong about, because the column HEADINGS are derived from the same
63 // number: a locale this browser has no week info for gets a correct
64 // calendar that happens to start on Monday, never a calendar whose
65 // headings lie about its columns.
66 function firstDayOfWeek(locale) {
67 var info = null, loc;
68 try {
69 loc = new Intl.Locale(locale || "en");
70 info = typeof loc.getWeekInfo === "function" ? loc.getWeekInfo() : loc.weekInfo;
71 } catch (e) {
72 info = null;
73 }
74 // weekInfo counts Monday as 1 through Sunday as 7; getDay counts
75 // Sunday as 0 through Saturday as 6. Seven modulo seven is zero,
76 // which is exactly where Sunday belongs.
77 if (info && typeof info.firstDay === "number" && info.firstDay >= 1 && info.firstDay <= 7) {
78 return info.firstDay % 7;
79 }
80 return 1;
81 }
82
83 // The six weeks a calendar draws for one month: always 42 days,
84 // beginning on this locale's own first day of the week.
85 //
86 // Counted in whole days through the Date constructor rather than by
87 // adding milliseconds, because a day is not 86,400,000 milliseconds
88 // long in a zone that moves its clocks: a grid built by arithmetic
89 // on instants loses a day one Sunday in spring and repeats one in
90 // autumn, in every zone that observes it. The constructor normalises
91 // a day number out of range into the neighbouring month, which is
92 // how the leading and trailing days arrive with no second branch.
93 //
94 // Six rows rather than as many as the month happens to need, so the
95 // panel keeps one height all year. A grid that grows a row while you
96 // are reaching for a date moves the date you were reaching for.
97 function monthGrid(year, month, first) {
98 var start = new Date(year, month, 1);
99 var back = (start.getDay() - first + 7) % 7;
100 var out = [], i;
101 for (i = 0; i < 42; i++) out.push(new Date(year, month, 1 - back + i));
102 return out;
103 }
104
105 // The seven column headings, in this locale's own words and in its
106 // own order: a short form for the eye and a long one for a screen
107 // reader, which is the pair an accessible calendar needs and neither
108 // half of which is written down here.
109 function weekdayNames(locale, first) {
110 var out = [], i, day, probe, shortFmt = null, longFmt = null;
111 try {
112 shortFmt = new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" });
113 longFmt = new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" });
114 } catch (e) {
115 shortFmt = null;
116 longFmt = null;
117 }
118 for (i = 0; i < 7; i++) {
119 day = (first + i) % 7;
120 // 1 Feb 2026 is a Sunday, so the probes land index-aligned with
121 // getDay — the same trick datetime.js builds its weekday table
122 // on, and the reason both files can talk about "day 0" and mean
123 // the same day.
124 probe = new Date(Date.UTC(2026, 1, 1 + day));
125 out.push({
126 day: day,
127 short: shortFmt ? shortFmt.format(probe) : String(day),
128 long: longFmt ? longFmt.format(probe) : String(day)
129 });
130 }
131 return out;
132 }
133
134 /* ── the panel ───────────────────────────────────────────────────
135 Everything below is behind the document guard, so Node loads the
136 arithmetic above and nothing else. */
137
138 if (typeof document !== "undefined") {
139
140 var CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
141 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' +
142 '<path d="M15 5l-7 7 7 7"/></svg>';
143
144
145 function pad(n) { return (n < 10 ? "0" : "") + n; }
146
147 // A day, as the only key a calendar ever needs to compare by.
148 // Timestamps are the wrong comparison here and wrong in a way that
149 // shows up twice a year: two Dates an hour apart across a clock
150 // change are the same square on the grid.
151 function dayKey(d) {
152 return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate());
153 }
154
155 function sameDay(a, b) { return !!a && !!b && dayKey(a) === dayKey(b); }
156
157 function shift(d, days) {
158 return new Date(d.getFullYear(), d.getMonth(), d.getDate() + days);
159 }
160
161 function shiftMonths(d, months) {
162 // Clamped rather than rolled over: 31 January plus a month is 28
163 // February, not 3 March. A Page key that lands you in the month
164 // after the one you asked for has moved the cursor somewhere you
165 // cannot see it.
166 var y = d.getFullYear(), m = d.getMonth() + months, day = d.getDate();
167 var last = new Date(y, m + 1, 0).getDate();
168 return new Date(y, m, day > last ? last : day);
169 }
170
171 function fmt(locale, opts) {
172 try {
173 return new Intl.DateTimeFormat(locale, opts);
174 } catch (e) {
175 return null;
176 }
177 }
178
179 // makePanel(cfg) builds one calendar and hands back the handful of
180 // things its owner needs to drive it.
181 //
182 // cfg.id string — prefix for the ids this panel generates
183 // cfg.locale BCP-47 tag, or undefined for the browser's own
184 // cfg.labels { calendar, prev, next } — every visible or
185 // spoken string, already translated. There are no
186 // others: the month names, the weekday names and
187 // the digits all come from Intl.
188 // cfg.value () -> Date|null — the committed date, asked for
189 // fresh on every open so the panel cannot show a
190 // stale selection
191 // cfg.bounds () -> { min: Date|null, max: Date|null }, also
192 // asked fresh: a range's end is bounded by its
193 // start, and the start moves
194 // cfg.onPick (Date) -> void — a day was chosen
195 // cfg.onDismiss () -> void — Escape, or focus should go home
196 //
197 // Returns { el, open, close, isOpen, show, focusGrid }.
198 function makePanel(cfg) {
199 var locale = cfg.locale;
200 var first = firstDayOfWeek(locale);
201 var heads = weekdayNames(locale, first);
202 var titleFmt = fmt(locale, { month: "long", year: "numeric" });
203 var fullFmt = fmt(locale, { weekday: "long", day: "numeric", month: "long", year: "numeric" });
204 var numFmt;
205 try {
206 numFmt = new Intl.NumberFormat(locale, { useGrouping: false });
207 } catch (e) {
208 numFmt = null;
209 }
210
211 var view = null; // a Date inside the month on screen
212 var cursor = null; // the day the roving tabindex sits on
213 var preview = null; // what the text beside the panel currently reads as
214 var cells = []; // [{td, date}] in grid order
215
216 var el = document.createElement("div");
217 el.setAttribute("rst-cal", "");
218 el.hidden = true;
219 el.setAttribute("role", "dialog");
220 el.setAttribute("aria-label", cfg.labels.calendar);
221
222 var head = document.createElement("div");
223 head.setAttribute("rst-cal-head", "");
224
225 function navButton(label, which) {
226 var b = document.createElement("button");
227 b.type = "button";
228 b.setAttribute("rst-cal-nav", which);
229 b.setAttribute("aria-label", label);
230 b.innerHTML = CHEVRON;
231 return b;
232 }
233
234 var prevBtn = navButton(cfg.labels.prev, "prev");
235 var nextBtn = navButton(cfg.labels.next, "next");
236
237 var title = document.createElement("div");
238 title.setAttribute("rst-cal-title", "");
239 title.id = cfg.id + "-cal-title";
240 // The month is announced as it changes, which is the only way a
241 // screen-reader user paging through the year knows where they
242 // are — the grid's own cells each name their full date, but
243 // nothing else says "September 2026" out loud.
244 title.setAttribute("aria-live", "polite");
245
246 head.appendChild(prevBtn);
247 head.appendChild(title);
248 head.appendChild(nextBtn);
249
250 var table = document.createElement("table");
251 table.setAttribute("rst-cal-grid", "");
252 table.setAttribute("role", "grid");
253 table.setAttribute("aria-labelledby", title.id);
254
255 var thead = document.createElement("thead");
256 var headRow = document.createElement("tr");
257 headRow.setAttribute("role", "row");
258 for (var h = 0; h < heads.length; h++) {
259 var th = document.createElement("th");
260 th.setAttribute("scope", "col");
261 th.setAttribute("role", "columnheader");
262 // Two spellings, one cell: the short form for the eye, the
263 // full name for a screen reader. An abbr attribute would have
264 // been tidier and is read inconsistently, and a bare "Mo" read
265 // aloud is not a weekday.
266 var eye = document.createElement("span");
267 eye.setAttribute("aria-hidden", "true");
268 eye.textContent = heads[h].short;
269 var said = document.createElement("span");
270 said.className = "rst-sr-only";
271 said.textContent = heads[h].long;
272 th.appendChild(eye);
273 th.appendChild(said);
274 headRow.appendChild(th);
275 }
276 thead.appendChild(headRow);
277
278 var tbody = document.createElement("tbody");
279 table.appendChild(thead);
280 table.appendChild(tbody);
281 el.appendChild(head);
282 el.appendChild(table);
283
284 function outOfBounds(d) {
285 var b = cfg.bounds ? cfg.bounds() : null;
286 if (!b) return false;
287 if (b.min && dayKey(d) < dayKey(b.min)) return true;
288 if (b.max && dayKey(d) > dayKey(b.max)) return true;
289 return false;
290 }
291
292 // A month is reachable if any day in it is. Asked of the first
293 // and last day rather than of the 1st alone, because a min in
294 // the middle of a month still leaves the rest of it pickable.
295 function monthReachable(d) {
296 var b = cfg.bounds ? cfg.bounds() : null;
297 if (!b) return true;
298 var lo = new Date(d.getFullYear(), d.getMonth(), 1);
299 var hi = new Date(d.getFullYear(), d.getMonth() + 1, 0);
300 if (b.min && dayKey(hi) < dayKey(b.min)) return false;
301 if (b.max && dayKey(lo) > dayKey(b.max)) return false;
302 return true;
303 }
304
305 function render() {
306 var today = new Date();
307 var chosen = cfg.value ? cfg.value() : null;
308 var days = monthGrid(view.getFullYear(), view.getMonth(), first);
309 var i, row, td, d, focusable = -1;
310
311 title.textContent = titleFmt ? titleFmt.format(view)
312 : view.getFullYear() + "-" + pad(view.getMonth() + 1);
313
314 prevBtn.disabled = !monthReachable(shiftMonths(new Date(view.getFullYear(), view.getMonth(), 1), -1));
315 nextBtn.disabled = !monthReachable(shiftMonths(new Date(view.getFullYear(), view.getMonth(), 1), 1));
316
317 tbody.textContent = "";
318 cells = [];
319 for (i = 0; i < days.length; i++) {
320 if (i % 7 === 0) {
321 row = document.createElement("tr");
322 row.setAttribute("role", "row");
323 tbody.appendChild(row);
324 }
325 d = days[i];
326 td = document.createElement("td");
327 td.setAttribute("rst-cal-day", "");
328 td.setAttribute("role", "gridcell");
329 td.tabIndex = -1;
330 td.textContent = numFmt ? numFmt.format(d.getDate()) : String(d.getDate());
331 // The cell says its whole date, so a screen reader announces
332 // "Saturday 5 September 2026" rather than "5" — which is all
333 // the visible number can ever mean on its own.
334 td.setAttribute("aria-label", fullFmt ? fullFmt.format(d) : dayKey(d));
335 td.setAttribute("data-rst-day", dayKey(d));
336
337 if (d.getMonth() !== view.getMonth()) td.classList.add("is-outside");
338 if (sameDay(d, today)) {
339 td.classList.add("is-today");
340 td.setAttribute("aria-current", "date");
341 }
342 // aria-selected is the committed value and nothing else. The
343 // preview below is a different claim — "this is what the
344 // text currently reads as" — and saying both with the same
345 // attribute would tell a screen reader a date had been
346 // chosen every time somebody typed a letter.
347 td.setAttribute("aria-selected", sameDay(d, chosen) ? "true" : "false");
348 if (sameDay(d, chosen)) td.classList.add("is-selected");
349 if (sameDay(d, preview)) td.classList.add("is-preview");
350 if (outOfBounds(d)) {
351 td.classList.add("is-disabled");
352 td.setAttribute("aria-disabled", "true");
353 }
354 if (sameDay(d, cursor)) focusable = i;
355 row.appendChild(td);
356 cells.push({ td: td, date: d });
357 }
358 // Exactly one day in the tab order. If the cursor has walked
359 // off this month — which it does the moment you page — the
360 // first day of the month takes it, so Tab never lands on
361 // nothing.
362 if (focusable < 0) {
363 for (i = 0; i < cells.length; i++) {
364 if (cells[i].date.getMonth() === view.getMonth()) { focusable = i; break; }
365 }
366 if (focusable >= 0) cursor = cells[focusable].date;
367 }
368 if (focusable >= 0) cells[focusable].td.tabIndex = 0;
369 }
370
371 function cellFor(d) {
372 var key = dayKey(d), i;
373 for (i = 0; i < cells.length; i++) if (dayKey(cells[i].date) === key) return cells[i].td;
374 return null;
375 }
376
377 // Move the cursor, redrawing the month first where the new day
378 // is not on screen. focus says whether the keyboard is driving:
379 // typing in the text field moves the cursor too, and stealing
380 // focus out of the field mid-word would be unusable.
381 function moveTo(d, focus) {
382 cursor = d;
383 if (d.getFullYear() !== view.getFullYear() || d.getMonth() !== view.getMonth()) {
384 view = new Date(d.getFullYear(), d.getMonth(), 1);
385 }
386 render();
387 if (focus) {
388 var td = cellFor(d);
389 if (td) td.focus();
390 }
391 }
392
393 function pick(d) {
394 if (outOfBounds(d)) return;
395 if (cfg.onPick) cfg.onPick(new Date(d.getFullYear(), d.getMonth(), d.getDate()));
396 }
397
398 function dayAt(node) {
399 while (node && node !== tbody) {
400 if (node.getAttribute && node.getAttribute("data-rst-day")) return node;
401 node = node.parentNode;
402 }
403 return null;
404 }
405
406 tbody.addEventListener("mousedown", function (e) {
407 // Keep focus where it is: the field behind this panel is a
408 // combobox, and a blur on the way to a click would commit the
409 // half-typed text before the click ever landed.
410 var td = dayAt(e.target);
411 if (td) e.preventDefault();
412 });
413
414 tbody.addEventListener("click", function (e) {
415 var td = dayAt(e.target), i;
416 if (!td) return;
417 for (i = 0; i < cells.length; i++) if (cells[i].td === td) return pick(cells[i].date);
418 });
419
420 // Which way is forward. In a page that runs right to left the
421 // horizontal arrows swap, because "left" means "back" only in a
422 // page that runs the other way.
423 function rtl() {
424 try {
425 return getComputedStyle(el).direction === "rtl";
426 } catch (e) {
427 return false;
428 }
429 }
430
431 table.addEventListener("keydown", function (e) {
432 var step = rtl() ? -1 : 1, at = cursor || view;
433 switch (e.key) {
434 case "ArrowLeft": e.preventDefault(); return moveTo(shift(at, -step), true);
435 case "ArrowRight": e.preventDefault(); return moveTo(shift(at, step), true);
436 case "ArrowUp": e.preventDefault(); return moveTo(shift(at, -7), true);
437 case "ArrowDown": e.preventDefault(); return moveTo(shift(at, 7), true);
438 case "Home":
439 e.preventDefault();
440 return moveTo(shift(at, -((at.getDay() - first + 7) % 7)), true);
441 case "End":
442 e.preventDefault();
443 return moveTo(shift(at, 6 - ((at.getDay() - first + 7) % 7)), true);
444 case "PageUp":
445 e.preventDefault();
446 return moveTo(shiftMonths(at, e.shiftKey ? -12 : -1), true);
447 case "PageDown":
448 e.preventDefault();
449 return moveTo(shiftMonths(at, e.shiftKey ? 12 : 1), true);
450 case "Enter":
451 case " ":
452 e.preventDefault();
453 return pick(at);
454 case "Escape":
455 e.preventDefault();
456 if (cfg.onDismiss) cfg.onDismiss();
457 return;
458 }
459 });
460
461 prevBtn.addEventListener("mousedown", function (e) { e.preventDefault(); });
462 nextBtn.addEventListener("mousedown", function (e) { e.preventDefault(); });
463 prevBtn.addEventListener("click", function () {
464 view = shiftMonths(new Date(view.getFullYear(), view.getMonth(), 1), -1);
465 render();
466 });
467 nextBtn.addEventListener("click", function () {
468 view = shiftMonths(new Date(view.getFullYear(), view.getMonth(), 1), 1);
469 render();
470 });
471
472 return {
473 el: el,
474
475 // Open on a day: the one passed, else the committed value,
476 // else today. Asked fresh every time, because the value can
477 // have changed while the panel was shut.
478 open: function (at) {
479 var start = at || (cfg.value ? cfg.value() : null) || new Date();
480 // Opening ON a day the text already named marks it as the
481 // preview, because that is what it is: what the words say,
482 // not what has been chosen. Opening on the committed value or
483 // on today marks nothing — there is no reading to preview.
484 preview = at ? new Date(at.getFullYear(), at.getMonth(), at.getDate()) : null;
485 cursor = new Date(start.getFullYear(), start.getMonth(), start.getDate());
486 view = new Date(start.getFullYear(), start.getMonth(), 1);
487 render();
488 el.hidden = false;
489 },
490
491 close: function () {
492 el.hidden = true;
493 preview = null;
494 },
495
496 isOpen: function () { return !el.hidden; },
497
498 // The live preview: what the text beside the panel currently
499 // reads as, marked on the grid and paged to without taking
500 // focus. Null clears the mark and leaves the month where it is
501 // — text that stopped parsing is not a reason to jump the
502 // calendar somewhere else.
503 show: function (d) {
504 if (el.hidden) return;
505 preview = d ? new Date(d.getFullYear(), d.getMonth(), d.getDate()) : null;
506 if (preview) moveTo(preview, false);
507 else render();
508 },
509
510 focusGrid: function () {
511 if (el.hidden) return false;
512 var td = cursor ? cellFor(cursor) : null;
513 if (!td) return false;
514 td.focus();
515 return true;
516 }
517 };
518 }
519
520 global.rastrilloCalendar = makePanel;
521 }
522
523 // The pure half, for ui/datetime_node.mjs --calendar. A browser has
524 // no `module` and never takes this branch.
525 if (typeof module !== "undefined" && module && module.exports) {
526 module.exports = {
527 firstDayOfWeek: firstDayOfWeek,
528 monthGrid: monthGrid,
529 weekdayNames: weekdayNames
530 };
531 }
532})(typeof globalThis !== "undefined" ? globalThis : this);
533