@yuneta/gobj-ui 2.6.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +331 -11
  2. package/dist/gobj-ui.cjs.js +21386 -17919
  3. package/dist/gobj-ui.es.js +26059 -22613
  4. package/index.js +45 -9
  5. package/package.json +4 -4
  6. package/src/c_g6_nodes_tree.js +128 -40
  7. package/src/c_yui_form.css +9 -0
  8. package/src/c_yui_form.js +113 -53
  9. package/src/c_yui_gobj_tree_js.js +58 -36
  10. package/src/c_yui_json.css +139 -0
  11. package/src/c_yui_json.js +928 -0
  12. package/src/c_yui_json_graph.js +110 -20
  13. package/src/c_yui_map.js +18 -36
  14. package/src/c_yui_nav.js +6 -9
  15. package/src/c_yui_period.css +184 -0
  16. package/src/c_yui_period.js +1441 -0
  17. package/src/c_yui_shell.css +120 -0
  18. package/src/c_yui_shell.js +672 -111
  19. package/src/c_yui_treedb_graph.js +639 -37
  20. package/src/c_yui_treedb_schema.js +478 -0
  21. package/src/c_yui_treedb_topic_with_form.css +18 -0
  22. package/src/c_yui_treedb_topic_with_form.js +153 -103
  23. package/src/c_yui_treedb_topics.css +73 -0
  24. package/src/c_yui_treedb_topics.js +1070 -29
  25. package/src/c_yui_window.css +22 -0
  26. package/src/c_yui_window.js +182 -97
  27. package/src/c_yui_window_manager.js +38 -4
  28. package/src/json_view_helpers.js +214 -0
  29. package/src/json_view_helpers.test.js +135 -0
  30. package/src/route_map_model.js +317 -0
  31. package/src/route_map_model.test.js +209 -0
  32. package/src/route_resolver.js +24 -1
  33. package/src/route_resolver.test.js +40 -1
  34. package/src/shell_modals.js +162 -52
  35. package/src/shell_route_map.css +225 -0
  36. package/src/shell_route_map.js +363 -0
  37. package/src/tabulator.css +245 -0
  38. package/src/yui_dev.js +130 -8
  39. package/src/yui_frontend_view.js +106 -0
  40. package/src/yui_icons.css +62 -0
  41. package/src/yui_inputs.css +8 -3
  42. package/src/yui_inputs.js +51 -8
  43. package/src/yui_tabulator_i18n.js +128 -0
  44. package/src/yui_theme.js +147 -0
  45. package/src/yui_time.js +666 -0
  46. package/src/yui_time.test.js +330 -0
  47. package/src/yui_toolbar.css +28 -0
  48. package/src/yui_toolbar.js +86 -43
  49. package/src/c_yui_main.css +0 -501
  50. package/src/c_yui_main.js +0 -1623
  51. package/src/c_yui_routing.css +0 -18
  52. package/src/c_yui_routing.js +0 -837
  53. package/src/c_yui_tabs.js +0 -487
  54. package/src/themes.js +0 -215
  55. package/src/ytable.css +0 -63
  56. package/src/ytable.js +0 -505
@@ -0,0 +1,1441 @@
1
+ /***********************************************************************
2
+ * c_yui_period.js
3
+ *
4
+ * The date navigator: pick a GRANULARITY, then walk it.
5
+ *
6
+ * [ All | Hour | Day | Week | Year | ⋯ | Custom ]
7
+ * |< < Week 27 > >|
8
+ * 2026-07-13 00:00:00 → 2026-07-19 23:59:59
9
+ *
10
+ * Two rows, and the second one is the point: once the user says "week",
11
+ * moving through time is ONE tap — no calendar, no typing two
12
+ * timestamps that must agree with each other. The calendar is still
13
+ * there (the label opens it) for the jump that is not one step away.
14
+ *
15
+ * Container-agnostic, like C_YUI_PAGER: the gclass owns its chrome and
16
+ * the parent mounts `gobj_read_attr(period, "$container")` wherever it
17
+ * wants — a modal, a toolbar, a card head.
18
+ *
19
+ * WHAT IT KNOWS NOTHING ABOUT is what a period is: that lives in
20
+ * yui_time.js as (unit, count), so an app that wants quarters, semesters,
21
+ * bimesters or 15-minute buckets DECLARES them:
22
+ *
23
+ * gobj_create("period", C_YUI_PERIOD, {
24
+ * periods: ["hour", "day", "week", "month", "year"],
25
+ * more_periods: ["bimester", "quarter", "semester", "decade"],
26
+ * rolling: ["1h", "24h", "7d"],
27
+ * with_span: true,
28
+ * with_custom: true,
29
+ * ms: false // the consumer's time unit
30
+ * }, parent);
31
+ *
32
+ * and gets the arrows, the labels and the calendar for free.
33
+ *
34
+ * It publishes ONE event:
35
+ *
36
+ * EV_PERIOD_CHANGED {mode, anchor, from, to}
37
+ *
38
+ * `from`/`to` are in the CONSUMER's unit (seconds, or milliseconds when
39
+ * `ms`), 0 meaning "unbounded" — which is exactly how a match condition
40
+ * reads a missing end, so a query builder can forward them untouched.
41
+ * A rolling window ("last 24h") deliberately leaves `to` at 0: pinning
42
+ * it to the instant of the click would freeze a live card.
43
+ *
44
+ * Modes that are NOT buckets — "span" (everything), "custom" (the host's
45
+ * own from/to inputs, mounted in the optional `$custom` slot) and the
46
+ * rolling windows — live in ST_FLAT, where the arrows do not exist. That
47
+ * is why they are a STATE and not an `if`: an EV_NEXT arriving there is
48
+ * a bug in the caller, and the FSM says so out loud instead of quietly
49
+ * doing nothing.
50
+ *
51
+ * Copyright (c) 2026, ArtGins.
52
+ * All Rights Reserved.
53
+ ***********************************************************************/
54
+ import {
55
+ SDATA,
56
+ SDATA_END,
57
+ data_type_t,
58
+ event_flag_t,
59
+ gclass_create,
60
+ log_error,
61
+ createElement2,
62
+ gobj_read_attr,
63
+ gobj_write_attr,
64
+ gobj_read_pointer_attr,
65
+ gobj_read_bool_attr,
66
+ gobj_read_integer_attr,
67
+ gobj_parent,
68
+ gobj_subscribe_event,
69
+ gobj_unsubscribe_event,
70
+ gobj_send_event,
71
+ gobj_publish_event,
72
+ gobj_change_state,
73
+ gobj_short_name,
74
+ } from "@yuneta/gobj-js";
75
+
76
+ import {yui_shell_of} from "./c_yui_shell.js";
77
+
78
+ import {
79
+ YUI_PERIODS_DEFAULT,
80
+ YUI_ROLLING,
81
+ safe_locale,
82
+ epoch_to_ms,
83
+ period_spec,
84
+ period_start,
85
+ period_shift,
86
+ period_bounds,
87
+ period_bounds_epoch,
88
+ rolling_bounds,
89
+ fmt_epoch,
90
+ period_name,
91
+ period_label,
92
+ day_number,
93
+ start_of_iso_week,
94
+ iso_week,
95
+ } from "./yui_time.js";
96
+
97
+ import i18next, {t} from "i18next";
98
+
99
+ import "./c_yui_period.css";
100
+
101
+
102
+ /***************************************************************
103
+ * Constants
104
+ ***************************************************************/
105
+ const GCLASS_NAME = "C_YUI_PERIOD";
106
+
107
+ /* The modes with no bucket behind them. They share ST_FLAT. */
108
+ const MODE_SPAN = "span";
109
+ const MODE_CUSTOM = "custom";
110
+
111
+ /***************************************************************
112
+ * Data
113
+ ***************************************************************/
114
+ const attrs_table = [
115
+ SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
116
+
117
+ SDATA(data_type_t.DTP_JSON, "periods", 0, null, "Granularities in the control: ids of yui_time's catalog, or specs {id,unit,count}"),
118
+ SDATA(data_type_t.DTP_JSON, "more_periods", 0, null, "Granularities behind the overflow menu (quarter, semester, …)"),
119
+ SDATA(data_type_t.DTP_JSON, "rolling", 0, null, "Rolling windows offered as modes: ids of yui_time's YUI_ROLLING"),
120
+ SDATA(data_type_t.DTP_BOOLEAN, "with_span", 0, false, "Offer a 'span' mode: no bounds at all"),
121
+ SDATA(data_type_t.DTP_BOOLEAN, "with_custom", 0, false, "Offer a 'custom' mode: reveals the $custom slot"),
122
+ SDATA(data_type_t.DTP_BOOLEAN, "with_resolved",0, true, "Print the two timestamps the current mode resolves to"),
123
+
124
+ SDATA(data_type_t.DTP_STRING, "mode", 0, "", "Current mode: a period id, 'span', 'custom' or a rolling id"),
125
+ SDATA(data_type_t.DTP_INTEGER, "anchor", 0, 0, "Instant the current bucket holds, in MILLISECONDS (0 = now)"),
126
+ SDATA(data_type_t.DTP_BOOLEAN, "ms", 0, false, "The CONSUMER keeps its timestamps in milliseconds (else seconds)"),
127
+ SDATA(data_type_t.DTP_INTEGER, "min", 0, 0, "Oldest instant the data holds, in the consumer's unit (0 = unknown)"),
128
+ SDATA(data_type_t.DTP_INTEGER, "max", 0, 0, "Newest instant the data holds, in the consumer's unit (0 = unknown)"),
129
+
130
+ /* OUTPUT, read-only: the bounds the current mode asks for, in the
131
+ * consumer's unit, 0 = unbounded. Kept in sync with every change, so a
132
+ * host that reads the picker when the user confirms a dialog (instead of
133
+ * reacting to EV_PERIOD_CHANGED) needs no accessor of its own. */
134
+ SDATA(data_type_t.DTP_INTEGER, "from", 0, 0, "Current lower bound (read-only output)"),
135
+ SDATA(data_type_t.DTP_INTEGER, "to", 0, 0, "Current upper bound, inclusive (read-only output)"),
136
+
137
+ /*---------------- UI ----------------*/
138
+ SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "HTMLElement root, mounted by the parent"),
139
+ SDATA(data_type_t.DTP_POINTER, "$custom", 0, null, "HTMLElement of the host's own from/to block, shown in 'custom' mode"),
140
+ SDATA_END()
141
+ ];
142
+
143
+ let PRIVATE_DATA = {
144
+ modes: null, /* every mode of the control, in order */
145
+ $modes: null,
146
+ $more: null,
147
+ $nav: null,
148
+ $first: null,
149
+ $prev: null,
150
+ $next: null,
151
+ $latest: null,
152
+ $label: null,
153
+ $resolved: null,
154
+ $calendar: null, /* the popover, null when closed */
155
+ cal_view: 0, /* the month/year the popover is BROWSING */
156
+ on_dismiss: null, /* document listener that closes the popover */
157
+ on_more_dismiss: null, /* same, for the overflow menu */
158
+ modes_ro: null /* ResizeObserver re-checking the strip's fades */
159
+ };
160
+
161
+ let __gclass__ = null;
162
+
163
+
164
+
165
+
166
+ /******************************
167
+ * Framework Methods
168
+ ******************************/
169
+
170
+
171
+
172
+
173
+ /***************************************************************
174
+ * Framework Method: Create
175
+ ***************************************************************/
176
+ function mt_create(gobj)
177
+ {
178
+ let priv = gobj.priv;
179
+
180
+ priv.modes = collect_modes(gobj);
181
+
182
+ if(!gobj_read_attr(gobj, "mode")) {
183
+ /* No mode asked for: the first one of the control. */
184
+ gobj_write_attr(gobj, "mode", priv.modes.length? priv.modes[0].id : MODE_SPAN);
185
+ }
186
+ if(!gobj_read_integer_attr(gobj, "anchor")) {
187
+ gobj_write_attr(gobj, "anchor", Date.now());
188
+ }
189
+
190
+ build_ui(gobj);
191
+
192
+ /*
193
+ * CHILD subscription model
194
+ */
195
+ let subscriber = gobj_read_pointer_attr(gobj, "subscriber");
196
+ if(!subscriber) {
197
+ subscriber = gobj_parent(gobj);
198
+ }
199
+ gobj_subscribe_event(gobj, null, {}, subscriber);
200
+ }
201
+
202
+ /***************************************************************
203
+ * Framework Method: Start
204
+ ***************************************************************/
205
+ function mt_start(gobj)
206
+ {
207
+ let priv = gobj.priv;
208
+ let mode = cur_mode(gobj);
209
+
210
+ /* The FSM starts in the first state declared; the mode we start ON
211
+ * decides whether that is where we belong. */
212
+ gobj_change_state(gobj, (mode && mode.kind === "bucket")? "ST_BUCKET" : "ST_FLAT");
213
+
214
+ /* The composed labels ("Week 27", month names) are t()-built at render
215
+ * time, so a language switch must repaint them. Subscribe to the shell
216
+ * DIRECTLY, like C_YUI_TREEDB_TOPIC_WITH_FORM: a host that mounts a
217
+ * bare picker gets the repaint without having to forward the event
218
+ * (a host that forwards anyway just repaints twice, harmlessly). */
219
+ let shell = yui_shell_of(gobj);
220
+ if(shell) {
221
+ gobj_subscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
222
+ }
223
+
224
+ /* The strip's overflow depends on the width the PARENT gives it, which
225
+ * is unknown until mounted — and changes with the viewport and with the
226
+ * language. Observe it instead of guessing. */
227
+ if(typeof ResizeObserver !== "undefined") {
228
+ priv.modes_ro = new ResizeObserver(() => {
229
+ update_modes_fade(gobj);
230
+ });
231
+ priv.modes_ro.observe(priv.$modes);
232
+ }
233
+
234
+ /* Bounds, but NO event: nobody asked for anything yet, and a host
235
+ * that fires a query on every EV_PERIOD_CHANGED would run one before
236
+ * the user touched the picker. */
237
+ refresh_bounds(gobj);
238
+ repaint(gobj);
239
+ }
240
+
241
+ /***************************************************************
242
+ * Framework Method: Stop
243
+ ***************************************************************/
244
+ function mt_stop(gobj)
245
+ {
246
+ let priv = gobj.priv;
247
+
248
+ let shell = yui_shell_of(gobj);
249
+ if(shell) {
250
+ gobj_unsubscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
251
+ }
252
+ if(priv.modes_ro) {
253
+ priv.modes_ro.disconnect();
254
+ priv.modes_ro = null;
255
+ }
256
+ close_calendar(gobj);
257
+ close_more(gobj);
258
+ }
259
+
260
+ /***************************************************************
261
+ * Framework Method: Destroy
262
+ ***************************************************************/
263
+ function mt_destroy(gobj)
264
+ {
265
+ close_calendar(gobj);
266
+ close_more(gobj);
267
+ }
268
+
269
+
270
+
271
+
272
+ /***************************
273
+ * Local Methods
274
+ ***************************/
275
+
276
+
277
+
278
+
279
+ /***************************************************************
280
+ * Every mode the control offers, in the order it shows them:
281
+ * span, the buckets, the rolling windows, the overflow ones, custom.
282
+ *
283
+ * A mode is {id, kind, spec}: `kind` is what the FSM cares about
284
+ * ("bucket" walks, everything else is flat), `spec` is the (unit,count)
285
+ * of a bucket and null for the rest.
286
+ ***************************************************************/
287
+ function collect_modes(gobj)
288
+ {
289
+ let modes = [];
290
+
291
+ if(gobj_read_bool_attr(gobj, "with_span")) {
292
+ modes.push({id: MODE_SPAN, kind: "flat", spec: null, overflow: false});
293
+ }
294
+
295
+ let add_buckets = (list, overflow) => {
296
+ for(let p of (list || [])) {
297
+ let spec = period_spec(p);
298
+ if(!spec) {
299
+ log_error(`${gobj_short_name(gobj)}: not a period: ${JSON.stringify(p)}`);
300
+ continue;
301
+ }
302
+ modes.push({id: spec.id, kind: "bucket", spec: spec, overflow: overflow});
303
+ }
304
+ };
305
+
306
+ let periods = gobj_read_attr(gobj, "periods");
307
+ add_buckets((periods && periods.length)? periods : YUI_PERIODS_DEFAULT, false);
308
+
309
+ for(let r of (gobj_read_attr(gobj, "rolling") || [])) {
310
+ let roll = (typeof r === "string")? YUI_ROLLING[r] : r;
311
+ if(!roll || !roll.secs) {
312
+ log_error(`${gobj_short_name(gobj)}: not a rolling window: ${JSON.stringify(r)}`);
313
+ continue;
314
+ }
315
+ modes.push({id: roll.id, kind: "rolling", spec: null, roll: roll, overflow: false});
316
+ }
317
+
318
+ add_buckets(gobj_read_attr(gobj, "more_periods"), true);
319
+
320
+ /* "custom" is always a valid STATE, even when it gets no button: a range
321
+ * that matches no bucket — typed by hand, or restored from a saved view —
322
+ * has to be representable, and falling back to another mode would claim
323
+ * the user asked for something they did not ("All" highlighted while the
324
+ * query carries a week). `with_custom` decides whether it is also
325
+ * OFFERED; without a button it is simply the state where no granularity
326
+ * is lit and the arrows are dead. */
327
+ modes.push({
328
+ id: MODE_CUSTOM,
329
+ kind: "flat",
330
+ spec: null,
331
+ overflow: false,
332
+ hidden: !gobj_read_bool_attr(gobj, "with_custom")
333
+ });
334
+
335
+ return modes;
336
+ }
337
+
338
+ function find_mode(gobj, id)
339
+ {
340
+ for(let m of gobj.priv.modes) {
341
+ if(m.id === id) {
342
+ return m;
343
+ }
344
+ }
345
+ return null;
346
+ }
347
+
348
+ function cur_mode(gobj)
349
+ {
350
+ return find_mode(gobj, gobj_read_attr(gobj, "mode"));
351
+ }
352
+
353
+ /***************************************************************
354
+ * The locale the picker FORMATS with: the app's language — the one
355
+ * t() answers in — so "Semana" never sits next to "July 2026" (a
356
+ * browser in one language under a UI switched to another mixes the
357
+ * two otherwise). safe_locale falls back to the browser's when
358
+ * i18next has no language yet, and guards Firefox's literal
359
+ * "undefined" string.
360
+ ***************************************************************/
361
+ function ui_locale()
362
+ {
363
+ return safe_locale(i18next.language);
364
+ }
365
+
366
+ /***************************************************************
367
+ * The i18n name of a mode, for the segmented control.
368
+ ***************************************************************/
369
+ function mode_name(mode)
370
+ {
371
+ if(!mode) {
372
+ return "";
373
+ }
374
+ if(mode.kind === "rolling") {
375
+ return t(mode.roll.label || mode.id);
376
+ }
377
+ if(mode.kind === "bucket") {
378
+ return period_name(mode.spec, t);
379
+ }
380
+ return t(mode.id); /* span / custom */
381
+ }
382
+
383
+ /***************************************************************
384
+ * The newest instant the navigator may reach, in MILLISECONDS.
385
+ *
386
+ * `now`, unless the data ends earlier: walking a key that stops in
387
+ * march into "next month" ten times only paints empty buckets, and the
388
+ * "latest" arrow would land nowhere near the last record.
389
+ ***************************************************************/
390
+ function limit_ms(gobj)
391
+ {
392
+ let now = Date.now();
393
+ let max = gobj_read_integer_attr(gobj, "max");
394
+ if(!max) {
395
+ return now;
396
+ }
397
+ let max_ms = epoch_to_ms(max, gobj_read_bool_attr(gobj, "ms"));
398
+ return Math.min(max_ms, now);
399
+ }
400
+
401
+ /***************************************************************
402
+ * The bounds the current mode asks for, in the CONSUMER's unit
403
+ * (0 = unbounded). This is the whole payload of EV_PERIOD_CHANGED.
404
+ ***************************************************************/
405
+ function cur_bounds(gobj)
406
+ {
407
+ let mode = cur_mode(gobj);
408
+ let ms = gobj_read_bool_attr(gobj, "ms");
409
+
410
+ if(!mode || mode.id === MODE_SPAN) {
411
+ return {from: 0, to: 0};
412
+ }
413
+ if(mode.kind === "rolling") {
414
+ return rolling_bounds(mode.roll, ms);
415
+ }
416
+ if(mode.kind === "bucket") {
417
+ return period_bounds_epoch(mode.spec, gobj_read_integer_attr(gobj, "anchor"), ms);
418
+ }
419
+ /* custom: the host owns the values in its $custom slot; the picker
420
+ * claims no bounds of its own. */
421
+ return {from: 0, to: 0};
422
+ }
423
+
424
+ /***************************************************************
425
+ * Publish the bounds as ATTRS, for the host that reads the picker when
426
+ * its dialog is confirmed instead of reacting to every keystroke of the
427
+ * navigator.
428
+ ***************************************************************/
429
+ function refresh_bounds(gobj)
430
+ {
431
+ let b = cur_bounds(gobj);
432
+ gobj_write_attr(gobj, "from", b.from);
433
+ gobj_write_attr(gobj, "to", b.to);
434
+ return b;
435
+ }
436
+
437
+ /***************************************************************
438
+ * Tell the world where we are. The kw is plain JSON on purpose (the
439
+ * machine trace dumps it, and a DOM node in there would break the very
440
+ * trace the FSM exists to feed).
441
+ ***************************************************************/
442
+ function publish_period(gobj)
443
+ {
444
+ let b = refresh_bounds(gobj);
445
+ gobj_publish_event(gobj, "EV_PERIOD_CHANGED", {
446
+ mode: gobj_read_attr(gobj, "mode"),
447
+ anchor: gobj_read_integer_attr(gobj, "anchor"),
448
+ from: b.from,
449
+ to: b.to
450
+ });
451
+ }
452
+
453
+
454
+ /*---------------------------------------------*
455
+ * UI
456
+ *---------------------------------------------*/
457
+
458
+ /***************************************************************
459
+ * The chrome, built once: the segmented control, the overflow menu
460
+ * and the navigator. What CHANGES on every mode/anchor lives in
461
+ * repaint().
462
+ ***************************************************************/
463
+ function build_ui(gobj)
464
+ {
465
+ let priv = gobj.priv;
466
+
467
+ priv.$modes = createElement2(
468
+ ["div", {class: "buttons has-addons is-flex-wrap-nowrap mb-0 YUI_PERIOD_MODES"}, []]);
469
+
470
+ for(let mode of priv.modes) {
471
+ if(mode.overflow || mode.hidden) {
472
+ continue;
473
+ }
474
+ priv.$modes.appendChild(build_mode_button(gobj, mode));
475
+ }
476
+
477
+ /* Scroll plumbing, not an action: it changes nothing outside the strip.
478
+ * The fade classes are the "there is more" hint a 4px scrollbar fails
479
+ * to give on a phone. */
480
+ priv.$modes.addEventListener("scroll", () => {
481
+ update_modes_fade(gobj);
482
+ }, {passive: true});
483
+
484
+ /* The strip SCROLLS when the granularities do not fit (see the css), and
485
+ * a scrolling box CLIPS what hangs out of it — so the overflow menu is a
486
+ * sibling of the strip, not a member of it. */
487
+ let $modes_row = createElement2(
488
+ ["div", {class: "is-flex is-align-items-center mb-0 YUI_PERIOD_MODES_ROW"},
489
+ [priv.$modes]]);
490
+
491
+ if(priv.modes.some((m) => m.overflow)) {
492
+ $modes_row.appendChild(build_more_menu(gobj));
493
+ }
494
+
495
+ /* |< < LABEL > >| — the two ends and the two steps. The ends are
496
+ * what makes a long key navigable at all: the oldest record of a topic
497
+ * can be years back, and stepping there one bucket at a time is not a
498
+ * navigation, it is a punishment. */
499
+ priv.$first = nav_button(gobj, "YUI_PERIOD_FIRST", "yi-backward-step", "oldest period",
500
+ () => gobj_send_event(gobj, "EV_FIRST", {}, gobj));
501
+ priv.$prev = nav_button(gobj, "YUI_PERIOD_PREV", "yi-chevron-left", "previous period",
502
+ () => gobj_send_event(gobj, "EV_PREV", {}, gobj));
503
+ priv.$next = nav_button(gobj, "YUI_PERIOD_NEXT", "yi-chevron-right", "next period",
504
+ () => gobj_send_event(gobj, "EV_NEXT", {}, gobj));
505
+ priv.$latest = nav_button(gobj, "YUI_PERIOD_LATEST", "yi-forward-step", "latest period",
506
+ () => gobj_send_event(gobj, "EV_LATEST", {}, gobj));
507
+
508
+ /* The label is the biggest thing on screen and it is a BUTTON: it says
509
+ * where you are ("Yesterday", "Week 27", "July") and it opens the
510
+ * calendar. Everything else in the row is chrome around it. */
511
+ /* The small calendar glyph is the AFFORDANCE: nothing else says "this
512
+ * opens a calendar" before the first click — desktop has a tooltip,
513
+ * a phone has neither hover nor title. */
514
+ priv.$label = createElement2(
515
+ ["button", {class: "button is-ghost YUI_PERIOD_LABEL", type: "button",
516
+ title: t("pick a date"), "aria-label": t("pick a date"),
517
+ "data-i18n-title": "pick a date", "data-i18n-aria-label": "pick a date"},
518
+ [["span", {class: "icon YUI_PERIOD_LABEL_ICON"},
519
+ [["i", {class: "yi-calendar-days"}]]],
520
+ ["span", {class: "YUI_PERIOD_LABEL_TEXT"}, ""]]
521
+ ]);
522
+ priv.$label.addEventListener("click", () => {
523
+ gobj_send_event(gobj, "EV_PICK_DATE", {}, gobj);
524
+ });
525
+
526
+ /* What the label RESOLVES to. A name is for the user; the two timestamps
527
+ * below it are what the query actually carries, and the user is entitled
528
+ * to see them before asking for them. */
529
+ /* A host that puts the resolved range in its OWN (editable) inputs asks
530
+ * for it to be left out — the same two timestamps twice, one of them
531
+ * read-only, is noise. */
532
+ if(gobj_read_bool_attr(gobj, "with_resolved")) {
533
+ priv.$resolved = createElement2(
534
+ ["p", {class: "has-text-centered is-family-monospace YUI_PERIOD_RESOLVED"}, ""]);
535
+ }
536
+
537
+ /* NO `is-flex` here, and that is not a style choice: `.is-flex` and
538
+ * `.is-hidden` are BOTH `!important` in Bulma, and is-flex wins — the
539
+ * navigator stayed on screen in the modes that have nothing to walk,
540
+ * offering arrows for a period that did not exist. The row is laid out
541
+ * from the css instead (no !important), so `is-hidden` can win. */
542
+ priv.$nav = createElement2(
543
+ ["div", {class: "mt-2 YUI_PERIOD_NAV"},
544
+ [priv.$first, priv.$prev, priv.$label, priv.$next, priv.$latest]]);
545
+
546
+ let $container = createElement2(
547
+ ["div", {class: `${GCLASS_NAME} YUI_PERIOD`}, [$modes_row, priv.$nav]]);
548
+ if(priv.$resolved) {
549
+ $container.appendChild(priv.$resolved);
550
+ }
551
+
552
+ let $custom = gobj_read_pointer_attr(gobj, "$custom");
553
+ if($custom) {
554
+ $container.appendChild($custom);
555
+ }
556
+
557
+ gobj_write_attr(gobj, "$container", $container);
558
+ }
559
+
560
+ function build_mode_button(gobj, mode)
561
+ {
562
+ let $btn = createElement2(
563
+ ["button", {class: `button YUI_PERIOD_MODE YUI_PERIOD_MODE_${mode.id.toUpperCase()}`,
564
+ type: "button"},
565
+ [["span", {class: "YUI_PERIOD_MODE_TEXT"}, mode_name(mode)]]
566
+ ]);
567
+ $btn.addEventListener("click", () => {
568
+ gobj_send_event(gobj, "EV_SET_MODE", {mode: mode.id}, gobj);
569
+ });
570
+ mode.$btn = $btn;
571
+ return $btn;
572
+ }
573
+
574
+ /***************************************************************
575
+ * The overflow menu: the granularities an app enabled but that would
576
+ * push the control off a phone screen (quarter, semester, decade…).
577
+ ***************************************************************/
578
+ function build_more_menu(gobj)
579
+ {
580
+ let priv = gobj.priv;
581
+
582
+ let $items = createElement2(["div", {class: "dropdown-content YUI_PERIOD_MORE_ITEMS"}, []]);
583
+ for(let mode of priv.modes) {
584
+ if(!mode.overflow) {
585
+ continue;
586
+ }
587
+ let $item = createElement2(
588
+ ["a", {class: `dropdown-item YUI_PERIOD_MODE ` +
589
+ `YUI_PERIOD_MODE_${mode.id.toUpperCase()}`, href: "#"},
590
+ [["span", {class: "YUI_PERIOD_MODE_TEXT"}, mode_name(mode)]]
591
+ ]);
592
+ $item.addEventListener("click", (ev) => {
593
+ ev.preventDefault();
594
+ close_more(gobj);
595
+ gobj_send_event(gobj, "EV_SET_MODE", {mode: mode.id}, gobj);
596
+ });
597
+ mode.$btn = $item;
598
+ $items.appendChild($item);
599
+ }
600
+
601
+ let $trigger = createElement2(
602
+ ["button", {class: "button YUI_PERIOD_MORE_BTN", type: "button",
603
+ title: t("more periods"), "aria-label": t("more periods"),
604
+ "data-i18n-title": "more periods", "data-i18n-aria-label": "more periods"},
605
+ [["span", {class: "icon"}, [["i", {class: "yi-ellipsis"}]]]]
606
+ ]);
607
+
608
+ priv.$more = createElement2(
609
+ ["div", {class: "dropdown is-right YUI_PERIOD_MORE"},
610
+ [
611
+ ["div", {class: "dropdown-trigger"}, [$trigger]],
612
+ ["div", {class: "dropdown-menu"}, [$items]]
613
+ ]
614
+ ]);
615
+
616
+ $trigger.addEventListener("click", () => {
617
+ if(priv.$more.classList.contains("is-active")) {
618
+ close_more(gobj);
619
+ } else {
620
+ open_more(gobj);
621
+ }
622
+ });
623
+
624
+ return priv.$more;
625
+ }
626
+
627
+ /***************************************************************
628
+ * The overflow menu dismisses like the calendar popover: anything
629
+ * outside closes it, Escape included — and that Escape stops there
630
+ * (capture phase), for the same reason as the calendar's: one
631
+ * Escape, one thing closed. Without this the menu only closed by
632
+ * re-clicking the trigger, and on a phone its open items sat over
633
+ * the navigator swallowing taps meant for the label.
634
+ ***************************************************************/
635
+ function open_more(gobj)
636
+ {
637
+ let priv = gobj.priv;
638
+ if(!priv.$more) {
639
+ return;
640
+ }
641
+ close_calendar(gobj); /* one popover at a time */
642
+ priv.$more.classList.add("is-active");
643
+
644
+ if(priv.on_more_dismiss) {
645
+ return;
646
+ }
647
+ priv.on_more_dismiss = (ev) => {
648
+ if(ev.type === "keydown") {
649
+ if(ev.key !== "Escape") {
650
+ return;
651
+ }
652
+ ev.preventDefault();
653
+ ev.stopPropagation();
654
+ }
655
+ if(ev.type === "pointerdown" && priv.$more.contains(ev.target)) {
656
+ return;
657
+ }
658
+ close_more(gobj);
659
+ };
660
+ document.addEventListener("pointerdown", priv.on_more_dismiss, true);
661
+ document.addEventListener("keydown", priv.on_more_dismiss, true);
662
+ }
663
+
664
+ function close_more(gobj)
665
+ {
666
+ let priv = gobj.priv;
667
+
668
+ if(priv.on_more_dismiss) {
669
+ document.removeEventListener("pointerdown", priv.on_more_dismiss, true);
670
+ document.removeEventListener("keydown", priv.on_more_dismiss, true);
671
+ priv.on_more_dismiss = null;
672
+ }
673
+ if(priv.$more) {
674
+ priv.$more.classList.remove("is-active");
675
+ }
676
+ }
677
+
678
+ function nav_button(gobj, logical, icon, key, on_click)
679
+ {
680
+ let $btn = createElement2(
681
+ ["button", {class: `button is-ghost ${logical}`, type: "button",
682
+ title: t(key), "aria-label": t(key),
683
+ "data-i18n-title": key, "data-i18n-aria-label": key},
684
+ [["span", {class: "icon"}, [["i", {class: icon}]]]]
685
+ ]);
686
+ $btn.addEventListener("click", on_click);
687
+ return $btn;
688
+ }
689
+
690
+ /***************************************************************
691
+ * Paint what the state says: which mode is active, what the navigator
692
+ * is parked on, and which arrows lead anywhere.
693
+ *
694
+ * Bulma's helpers carry !important, so visibility is a CLASS
695
+ * (`is-hidden`) — an inline style.display would lose to them.
696
+ ***************************************************************/
697
+ function repaint(gobj)
698
+ {
699
+ let priv = gobj.priv;
700
+ let mode = cur_mode(gobj);
701
+
702
+ for(let m of priv.modes) {
703
+ if(!m.$btn) {
704
+ continue;
705
+ }
706
+ m.$btn.classList.toggle("is-active", m === mode);
707
+ m.$btn.classList.toggle("is-link", m === mode && !m.overflow);
708
+ }
709
+ if(mode && mode.$btn) {
710
+ scroll_mode_into_view(gobj, mode.$btn);
711
+ }
712
+ if(priv.$more) {
713
+ /* An overflow granularity in use must SAY so on the trigger: the
714
+ * control shows no active segment otherwise, and the user cannot
715
+ * tell a quarter from a month by the arrows alone. */
716
+ priv.$more.classList.toggle("is-link", !!(mode && mode.overflow));
717
+ }
718
+
719
+ /* The navigator STAYS, disabled, in the modes with nothing to walk: it
720
+ * used to disappear, and every mode change re-flowed the whole card under
721
+ * the cursor. A control that greys out says "not now"; one that vanishes
722
+ * makes you re-find everything else. */
723
+ let is_bucket = !!(mode && mode.kind === "bucket");
724
+ priv.$nav.classList.toggle("yui-period-nav-off", !is_bucket);
725
+ priv.$label.disabled = !is_bucket;
726
+
727
+ let $custom = gobj_read_pointer_attr(gobj, "$custom");
728
+ if($custom) {
729
+ $custom.classList.toggle("is-hidden", !(mode && mode.id === MODE_CUSTOM));
730
+ }
731
+
732
+ let ms = gobj_read_bool_attr(gobj, "ms");
733
+
734
+ /* The flat modes have no bucket to name, but they still ASK for
735
+ * something, and the user is entitled to read it. */
736
+ if(!is_bucket) {
737
+ priv.$first.disabled = true;
738
+ priv.$prev.disabled = true;
739
+ priv.$next.disabled = true;
740
+ priv.$latest.disabled = true;
741
+
742
+ let $text = priv.$label.querySelector(".YUI_PERIOD_LABEL_TEXT");
743
+ if($text) {
744
+ /* No bucket, so no date to name — and naming one anyway ("Today")
745
+ * next to a mode that asks for EVERYTHING would be a lie. */
746
+ $text.textContent = "—";
747
+ }
748
+
749
+ if(!priv.$resolved) {
750
+ return;
751
+ }
752
+ if(mode && mode.kind === "rolling") {
753
+ let r = cur_bounds(gobj);
754
+ priv.$resolved.textContent = `${fmt_epoch(r.from, ms)} → …`;
755
+ } else if(mode && mode.id === MODE_CUSTOM) {
756
+ priv.$resolved.textContent = t("the range typed below");
757
+ } else {
758
+ priv.$resolved.textContent = t("no time limits");
759
+ }
760
+ return;
761
+ }
762
+
763
+ let anchor = gobj_read_integer_attr(gobj, "anchor");
764
+ let $text = priv.$label.querySelector(".YUI_PERIOD_LABEL_TEXT");
765
+ if($text) {
766
+ $text.textContent = period_label(mode.spec, anchor, t, ui_locale());
767
+ }
768
+
769
+ if(priv.$resolved) {
770
+ let b = period_bounds_epoch(mode.spec, anchor, ms);
771
+ priv.$resolved.textContent = `${fmt_epoch(b.from, ms)} → ${fmt_epoch(b.to, ms)}`;
772
+ }
773
+
774
+ let limit = limit_ms(gobj);
775
+ let at_end = period_start(mode.spec, anchor).getTime() >=
776
+ period_start(mode.spec, limit).getTime();
777
+ priv.$next.disabled = at_end;
778
+ priv.$latest.disabled = at_end;
779
+
780
+ let min = gobj_read_integer_attr(gobj, "min");
781
+ if(min) {
782
+ let min_ms = epoch_to_ms(min, ms);
783
+ let at_start = period_start(mode.spec, anchor).getTime() <=
784
+ period_start(mode.spec, min_ms).getTime();
785
+ priv.$prev.disabled = at_start;
786
+ priv.$first.disabled = at_start;
787
+ } else {
788
+ /* The data's extent is unknown (an old backend, a key nobody has
789
+ * reported yet): there is no oldest bucket to jump to, so the jump
790
+ * is dead — but stepping back is not. */
791
+ priv.$prev.disabled = false;
792
+ priv.$first.disabled = true;
793
+ }
794
+ }
795
+
796
+
797
+ /***************************************************************
798
+ * Bring the active granularity inside the visible part of the strip.
799
+ *
800
+ * The strip only overflows on a narrow screen, and there the active mode
801
+ * can sit past either edge — "Custom" at the right, "Hour" at the left of
802
+ * a strip the user already pushed. Scrolling the CONTAINER (not
803
+ * scrollIntoView, which walks up and drags the page/dialog with it) is the
804
+ * only part of this that must not be guessed.
805
+ ***************************************************************/
806
+ function scroll_mode_into_view(gobj, $btn)
807
+ {
808
+ let $m = gobj.priv.$modes;
809
+ if(!$m || $m.scrollWidth <= $m.clientWidth + 1) {
810
+ return;
811
+ }
812
+ /* Rects, NOT offsetLeft: the strip is not a positioned element, so the
813
+ * button's offsetParent is some ancestor of it and offsetLeft would be
814
+ * measured from the wrong origin. */
815
+ let strip = $m.getBoundingClientRect();
816
+ let btn = $btn.getBoundingClientRect();
817
+ let left = btn.left - strip.left + $m.scrollLeft;
818
+ let right = left + btn.width;
819
+ if(left < $m.scrollLeft) {
820
+ $m.scrollLeft = left;
821
+ } else if(right > $m.scrollLeft + $m.clientWidth) {
822
+ $m.scrollLeft = right - $m.clientWidth;
823
+ }
824
+ }
825
+
826
+ /***************************************************************
827
+ * The "there is more" hint of the scrolling strip: fade the edge
828
+ * that hides content. Only the classes live here; the mask is css.
829
+ ***************************************************************/
830
+ function update_modes_fade(gobj)
831
+ {
832
+ let $m = gobj.priv.$modes;
833
+ if(!$m) {
834
+ return;
835
+ }
836
+ let overflow = $m.scrollWidth > $m.clientWidth + 1;
837
+ let at_start = $m.scrollLeft <= 1;
838
+ let at_end = $m.scrollLeft + $m.clientWidth >= $m.scrollWidth - 1;
839
+ $m.classList.toggle("yui-period-fade-left", overflow && !at_start);
840
+ $m.classList.toggle("yui-period-fade-right", overflow && !at_end);
841
+ }
842
+
843
+
844
+ /*---------------------------------------------*
845
+ * The calendar popover
846
+ *---------------------------------------------*/
847
+
848
+ /***************************************************************
849
+ * Which grid the popover shows is decided by the bucket's UNIT: you
850
+ * pick a quarter by pointing at a month, and a decade by pointing at a
851
+ * year — a day grid would be asking the wrong question.
852
+ ***************************************************************/
853
+ function grid_kind(spec)
854
+ {
855
+ if(spec.unit === "year") {
856
+ return "years";
857
+ }
858
+ if(spec.unit === "month") {
859
+ return "months";
860
+ }
861
+ return "days";
862
+ }
863
+
864
+ /***************************************************************
865
+ * Browsing the grid (‹ July 2026 ›) is PLUMBING, not an action — it
866
+ * changes nothing outside the popover. PICKING is the action, and it
867
+ * leaves through EV_DATE_PICKED like everything else.
868
+ ***************************************************************/
869
+ function open_calendar(gobj)
870
+ {
871
+ let priv = gobj.priv;
872
+ let mode = cur_mode(gobj);
873
+ if(!mode || mode.kind !== "bucket") {
874
+ return;
875
+ }
876
+ close_calendar(gobj);
877
+ close_more(gobj); /* one popover at a time */
878
+
879
+ priv.cal_view = gobj_read_integer_attr(gobj, "anchor");
880
+
881
+ priv.$calendar = createElement2(["div", {class: "box p-2 YUI_PERIOD_CALENDAR"}, []]);
882
+ render_calendar(gobj);
883
+
884
+ priv.$nav.appendChild(priv.$calendar);
885
+
886
+ /* Anything outside closes it, Escape included — and the Escape that
887
+ * closes the popover STOPS THERE. We listen in the capture phase, so we
888
+ * run before whatever hosts us: without swallowing it, the same keypress
889
+ * travelled on to the shell's escape chain and closed the whole dialog
890
+ * the calendar was opened from. One Escape, one thing closed. */
891
+ priv.on_dismiss = (ev) => {
892
+ if(ev.type === "keydown") {
893
+ if(ev.key !== "Escape") {
894
+ return;
895
+ }
896
+ ev.preventDefault();
897
+ ev.stopPropagation();
898
+ }
899
+ if(ev.type === "pointerdown" && priv.$calendar && priv.$calendar.contains(ev.target)) {
900
+ return;
901
+ }
902
+ if(ev.type === "pointerdown" && priv.$label.contains(ev.target)) {
903
+ return;
904
+ }
905
+ close_calendar(gobj);
906
+ };
907
+ document.addEventListener("pointerdown", priv.on_dismiss, true);
908
+ document.addEventListener("keydown", priv.on_dismiss, true);
909
+ }
910
+
911
+ function close_calendar(gobj)
912
+ {
913
+ let priv = gobj.priv;
914
+
915
+ if(priv.on_dismiss) {
916
+ document.removeEventListener("pointerdown", priv.on_dismiss, true);
917
+ document.removeEventListener("keydown", priv.on_dismiss, true);
918
+ priv.on_dismiss = null;
919
+ }
920
+ if(priv.$calendar) {
921
+ priv.$calendar.remove();
922
+ priv.$calendar = null;
923
+ }
924
+ }
925
+
926
+ function render_calendar(gobj)
927
+ {
928
+ let priv = gobj.priv;
929
+ let mode = cur_mode(gobj);
930
+ if(!priv.$calendar || !mode || mode.kind !== "bucket") {
931
+ return;
932
+ }
933
+ let kind = grid_kind(mode.spec);
934
+ let view = new Date(priv.cal_view);
935
+ let locale = ui_locale();
936
+
937
+ let step_view = (delta) => {
938
+ let d = new Date(priv.cal_view);
939
+ if(kind === "days") {
940
+ priv.cal_view = new Date(d.getFullYear(), d.getMonth() + delta, 1).getTime();
941
+ } else if(kind === "months") {
942
+ priv.cal_view = new Date(d.getFullYear() + delta, 0, 1).getTime();
943
+ } else {
944
+ priv.cal_view = new Date(d.getFullYear() + delta * 12, 0, 1).getTime();
945
+ }
946
+ render_calendar(gobj);
947
+ };
948
+
949
+ let heading;
950
+ if(kind === "days") {
951
+ heading = new Intl.DateTimeFormat(locale, {month: "long", year: "numeric"}).format(view);
952
+ } else if(kind === "months") {
953
+ heading = String(view.getFullYear());
954
+ } else {
955
+ let base = Math.floor(view.getFullYear() / 12) * 12;
956
+ heading = `${base} – ${base + 11}`;
957
+ }
958
+
959
+ let $back = nav_button(gobj, "YUI_PERIOD_CAL_PREV", "yi-chevron-left", "previous period",
960
+ () => step_view(-1));
961
+ let $fwd = nav_button(gobj, "YUI_PERIOD_CAL_NEXT", "yi-chevron-right", "next period",
962
+ () => step_view(1));
963
+
964
+ let $head = createElement2(
965
+ ["div", {class: "is-flex is-align-items-center is-justify-content-space-between " +
966
+ "mb-2 YUI_PERIOD_CAL_HEAD"},
967
+ [$back,
968
+ ["span", {class: "has-text-weight-semibold YUI_PERIOD_CAL_TITLE"}, heading],
969
+ $fwd]
970
+ ]);
971
+
972
+ let $grid = (kind === "days")? build_days_grid(gobj, view, locale)
973
+ : (kind === "months")? build_months_grid(gobj, view, locale)
974
+ : build_years_grid(gobj, view);
975
+
976
+ let $today = createElement2(
977
+ ["button", {class: "button is-ghost mt-2 YUI_PERIOD_CAL_TODAY", type: "button"},
978
+ [["span", {"data-i18n": "today"}, t("today")]]
979
+ ]);
980
+ $today.addEventListener("click", () => {
981
+ gobj_send_event(gobj, "EV_DATE_PICKED", {anchor: Date.now()}, gobj);
982
+ });
983
+
984
+ priv.$calendar.replaceChildren($head, $grid, $today);
985
+ }
986
+
987
+ function cell_button(gobj, label, anchor_ms, selected, logical, aria)
988
+ {
989
+ /* `aria` is the full name of the instant ("14 July 2026"): the visible
990
+ * label is a bare number, which a screen reader hears with no month or
991
+ * year around it. */
992
+ let $btn = createElement2(
993
+ /* The ONE place `is-small` is earned: 42 cells in a popover. */
994
+ ["button", {class: `button is-small ${selected? "is-link" : "is-ghost"} ${logical}`,
995
+ type: "button", style: "width:100%;",
996
+ title: aria || "", "aria-label": aria || ""}, String(label)]);
997
+ $btn.addEventListener("click", () => {
998
+ gobj_send_event(gobj, "EV_DATE_PICKED", {anchor: anchor_ms}, gobj);
999
+ });
1000
+ return $btn;
1001
+ }
1002
+
1003
+ /***************************************************************
1004
+ * Hovering a cell previews the BUCKET a click would pick: a week
1005
+ * rings its whole row, a quarter its three months — what you are
1006
+ * about to get, before committing. Pointer plumbing, not an
1007
+ * action: it changes nothing outside the popover.
1008
+ ***************************************************************/
1009
+ function wire_bucket_preview(spec, cells)
1010
+ {
1011
+ for(let c of cells) {
1012
+ c.$btn.addEventListener("mouseenter", () => {
1013
+ let b = period_bounds(spec, c.ts);
1014
+ for(let o of cells) {
1015
+ o.$btn.classList.toggle("yui-period-preview",
1016
+ o.ts >= b.from && o.ts <= b.to);
1017
+ }
1018
+ });
1019
+ c.$btn.addEventListener("mouseleave", () => {
1020
+ for(let o of cells) {
1021
+ o.$btn.classList.remove("yui-period-preview");
1022
+ }
1023
+ });
1024
+ }
1025
+ }
1026
+
1027
+ /***************************************************************
1028
+ * The day grid: Monday-first (ISO, like the week bucket), the days of
1029
+ * the neighbouring months greyed but LIVE — clicking one is how you
1030
+ * reach the last week of the previous month without going back first.
1031
+ ***************************************************************/
1032
+ function build_days_grid(gobj, view, locale)
1033
+ {
1034
+ let mode = cur_mode(gobj);
1035
+ let anchor = gobj_read_integer_attr(gobj, "anchor");
1036
+ let sel = period_bounds(mode.spec, anchor);
1037
+
1038
+ /* The week-number gutter earns its place only when the bucket IS a
1039
+ * week: the number is then the NAME of what a click picks. */
1040
+ let with_weeks = (mode.spec.unit === "week");
1041
+ let columns = with_weeks
1042
+ ? "grid-template-columns:2.5ch repeat(7, 1fr);"
1043
+ : "grid-template-columns:repeat(7, 1fr);";
1044
+
1045
+ let first = new Date(view.getFullYear(), view.getMonth(), 1);
1046
+ let start = start_of_iso_week(first);
1047
+ let today = day_number(new Date());
1048
+
1049
+ let $rows = [];
1050
+ let cells = [];
1051
+
1052
+ let $dow = [];
1053
+ if(with_weeks) {
1054
+ $dow.push(["div", {class: "YUI_PERIOD_CAL_DOW"}, ""]);
1055
+ }
1056
+ for(let i = 0; i < 7; i++) {
1057
+ let d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
1058
+ $dow.push(["div", {class: "has-text-centered is-size-7 has-text-grey YUI_PERIOD_CAL_DOW"},
1059
+ new Intl.DateTimeFormat(locale, {weekday: "narrow"}).format(d)]);
1060
+ }
1061
+ $rows.push(["div", {class: "YUI_PERIOD_CAL_WEEK",
1062
+ style: `display:grid; ${columns} gap:2px;`},
1063
+ $dow]);
1064
+
1065
+ let day_name = new Intl.DateTimeFormat(locale,
1066
+ {day: "numeric", month: "long", year: "numeric"});
1067
+
1068
+ for(let w = 0; w < 6; w++) {
1069
+ let $cells = [];
1070
+ if(with_weeks) {
1071
+ let monday = new Date(start.getFullYear(), start.getMonth(),
1072
+ start.getDate() + w * 7);
1073
+ let wk = iso_week(monday).week;
1074
+ let wk_name = t("week {{n}}", {n: wk});
1075
+ let $wk = createElement2(
1076
+ ["button", {class: "button is-small is-ghost is-size-7 has-text-grey " +
1077
+ "YUI_PERIOD_CAL_WEEKNUM",
1078
+ type: "button", style: "width:100%;",
1079
+ title: wk_name, "aria-label": wk_name}, String(wk)]);
1080
+ $wk.addEventListener("click", () => {
1081
+ gobj_send_event(gobj, "EV_DATE_PICKED", {anchor: monday.getTime()}, gobj);
1082
+ });
1083
+ $cells.push($wk);
1084
+ }
1085
+ for(let i = 0; i < 7; i++) {
1086
+ let d = new Date(start.getFullYear(), start.getMonth(),
1087
+ start.getDate() + w * 7 + i);
1088
+ let ts = d.getTime();
1089
+ /* Selected = this day falls INSIDE the current bucket, so a
1090
+ * week bucket lights its whole row and a 15-minute one lights
1091
+ * the single day that holds it. */
1092
+ let selected = ts >= sel.from && ts <= sel.to;
1093
+ let $btn = cell_button(gobj, d.getDate(), ts, selected, "YUI_PERIOD_CAL_DAY",
1094
+ day_name.format(d));
1095
+ if(d.getMonth() !== view.getMonth()) {
1096
+ $btn.classList.add("has-text-grey-light");
1097
+ }
1098
+ if(day_number(d) === today) {
1099
+ $btn.classList.add("has-text-weight-bold", "YUI_PERIOD_CAL_TODAY_CELL");
1100
+ }
1101
+ cells.push({ts: ts, $btn: $btn});
1102
+ $cells.push($btn);
1103
+ }
1104
+ $rows.push(["div", {class: "YUI_PERIOD_CAL_WEEK",
1105
+ style: `display:grid; ${columns} gap:2px;`},
1106
+ $cells]);
1107
+ }
1108
+
1109
+ wire_bucket_preview(mode.spec, cells);
1110
+ return createElement2(["div", {class: "YUI_PERIOD_CAL_GRID"}, $rows]);
1111
+ }
1112
+
1113
+ function build_months_grid(gobj, view, locale)
1114
+ {
1115
+ let mode = cur_mode(gobj);
1116
+ let sel = period_bounds(mode.spec, gobj_read_integer_attr(gobj, "anchor"));
1117
+
1118
+ let short_name = new Intl.DateTimeFormat(locale, {month: "short"});
1119
+ let full_name = new Intl.DateTimeFormat(locale, {month: "long", year: "numeric"});
1120
+
1121
+ let cells = [];
1122
+ let $cells = [];
1123
+ for(let m = 0; m < 12; m++) {
1124
+ let d = new Date(view.getFullYear(), m, 1);
1125
+ let ts = d.getTime();
1126
+ let selected = ts >= sel.from && ts <= sel.to;
1127
+ let $btn = cell_button(gobj, short_name.format(d), ts, selected,
1128
+ "YUI_PERIOD_CAL_MONTH", full_name.format(d));
1129
+ cells.push({ts: ts, $btn: $btn});
1130
+ $cells.push($btn);
1131
+ }
1132
+ wire_bucket_preview(mode.spec, cells);
1133
+ return createElement2(
1134
+ ["div", {class: "YUI_PERIOD_CAL_GRID",
1135
+ style: "display:grid; grid-template-columns:repeat(3, 1fr); gap:4px;"},
1136
+ $cells]);
1137
+ }
1138
+
1139
+ function build_years_grid(gobj, view)
1140
+ {
1141
+ let mode = cur_mode(gobj);
1142
+ let sel = period_bounds(mode.spec, gobj_read_integer_attr(gobj, "anchor"));
1143
+
1144
+ let base = Math.floor(view.getFullYear() / 12) * 12;
1145
+ let cells = [];
1146
+ let $cells = [];
1147
+ for(let i = 0; i < 12; i++) {
1148
+ let d = new Date(base + i, 0, 1);
1149
+ let ts = d.getTime();
1150
+ let selected = ts >= sel.from && ts <= sel.to;
1151
+ let $btn = cell_button(gobj, base + i, ts, selected,
1152
+ "YUI_PERIOD_CAL_YEAR", String(base + i));
1153
+ cells.push({ts: ts, $btn: $btn});
1154
+ $cells.push($btn);
1155
+ }
1156
+ wire_bucket_preview(mode.spec, cells);
1157
+ return createElement2(
1158
+ ["div", {class: "YUI_PERIOD_CAL_GRID",
1159
+ style: "display:grid; grid-template-columns:repeat(3, 1fr); gap:4px;"},
1160
+ $cells]);
1161
+ }
1162
+
1163
+
1164
+
1165
+
1166
+ /***************************
1167
+ * Actions
1168
+ ***************************/
1169
+
1170
+
1171
+
1172
+
1173
+ /***************************************************************
1174
+ * A granularity was chosen. The anchor SURVIVES the change — switching
1175
+ * from "week 27" to "month" must land on the month that holds week 27,
1176
+ * not jump back to today: the user is looking at march for a reason.
1177
+ ***************************************************************/
1178
+ function ac_set_mode(gobj, event, kw, src)
1179
+ {
1180
+ let mode = find_mode(gobj, kw.mode);
1181
+ if(!mode) {
1182
+ log_error(`${gobj_short_name(gobj)}: unknown mode: ${kw.mode}`);
1183
+ return -1;
1184
+ }
1185
+ close_calendar(gobj);
1186
+ close_more(gobj);
1187
+ gobj_write_attr(gobj, "mode", mode.id);
1188
+
1189
+ /* A rolling window is always "now", and coming back from one with a
1190
+ * stale anchor would silently show an old bucket. */
1191
+ if(mode.kind === "rolling") {
1192
+ gobj_write_attr(gobj, "anchor", Date.now());
1193
+ }
1194
+
1195
+ gobj_change_state(gobj, (mode.kind === "bucket")? "ST_BUCKET" : "ST_FLAT");
1196
+ repaint(gobj);
1197
+ publish_period(gobj);
1198
+ return 0;
1199
+ }
1200
+
1201
+ /***************************************************************
1202
+ *
1203
+ ***************************************************************/
1204
+ function ac_prev(gobj, event, kw, src)
1205
+ {
1206
+ let mode = cur_mode(gobj);
1207
+ gobj_write_attr(gobj, "anchor",
1208
+ period_shift(mode.spec, gobj_read_integer_attr(gobj, "anchor"), -1));
1209
+ close_calendar(gobj);
1210
+ repaint(gobj);
1211
+ publish_period(gobj);
1212
+ return 0;
1213
+ }
1214
+
1215
+ /***************************************************************
1216
+ *
1217
+ ***************************************************************/
1218
+ function ac_next(gobj, event, kw, src)
1219
+ {
1220
+ let mode = cur_mode(gobj);
1221
+ gobj_write_attr(gobj, "anchor",
1222
+ period_shift(mode.spec, gobj_read_integer_attr(gobj, "anchor"), 1));
1223
+ close_calendar(gobj);
1224
+ repaint(gobj);
1225
+ publish_period(gobj);
1226
+ return 0;
1227
+ }
1228
+
1229
+ /***************************************************************
1230
+ * Home: the bucket holding the newest thing there is to look at.
1231
+ ***************************************************************/
1232
+ function ac_latest(gobj, event, kw, src)
1233
+ {
1234
+ gobj_write_attr(gobj, "anchor", limit_ms(gobj));
1235
+ close_calendar(gobj);
1236
+ repaint(gobj);
1237
+ publish_period(gobj);
1238
+ return 0;
1239
+ }
1240
+
1241
+ /***************************************************************
1242
+ * The other end: the bucket holding the OLDEST thing there is. It only
1243
+ * exists when the host said where the data starts (`min`) — without it
1244
+ * there is no "first", and the button is dead rather than lying.
1245
+ ***************************************************************/
1246
+ function ac_first(gobj, event, kw, src)
1247
+ {
1248
+ let min = gobj_read_integer_attr(gobj, "min");
1249
+ if(!min) {
1250
+ log_error(`${gobj_short_name(gobj)}: EV_FIRST with no known start of data`);
1251
+ return -1;
1252
+ }
1253
+ gobj_write_attr(gobj, "anchor", epoch_to_ms(min, gobj_read_bool_attr(gobj, "ms")));
1254
+ close_calendar(gobj);
1255
+ repaint(gobj);
1256
+ publish_period(gobj);
1257
+ return 0;
1258
+ }
1259
+
1260
+ /***************************************************************
1261
+ *
1262
+ ***************************************************************/
1263
+ function ac_pick_date(gobj, event, kw, src)
1264
+ {
1265
+ if(gobj.priv.$calendar) {
1266
+ close_calendar(gobj);
1267
+ return 0;
1268
+ }
1269
+ open_calendar(gobj);
1270
+ return 0;
1271
+ }
1272
+
1273
+ /***************************************************************
1274
+ * A cell of the calendar was clicked: it names an INSTANT, and the
1275
+ * bucket that holds it is the one we move to.
1276
+ ***************************************************************/
1277
+ function ac_date_picked(gobj, event, kw, src)
1278
+ {
1279
+ let anchor = parseInt(kw.anchor, 10);
1280
+ if(Number.isNaN(anchor)) {
1281
+ log_error(`${gobj_short_name(gobj)}: EV_DATE_PICKED without an anchor`);
1282
+ return -1;
1283
+ }
1284
+ gobj_write_attr(gobj, "anchor", anchor);
1285
+ close_calendar(gobj);
1286
+ repaint(gobj);
1287
+ publish_period(gobj);
1288
+ return 0;
1289
+ }
1290
+
1291
+ /***************************************************************
1292
+ * The HOST changed what the picker is pointing at: its time unit (`ms`),
1293
+ * or the extent of the data (`min`/`max`) — a Rows dialog that switches
1294
+ * from the `t` axis to `tm` is looking at the same key through a
1295
+ * different clock, with a different span.
1296
+ *
1297
+ * The attrs are the host's to write; this is how it says "now re-read
1298
+ * them". Without it the arrows would still be armed against the OLD
1299
+ * extent, and the bounds published in the OLD unit.
1300
+ ***************************************************************/
1301
+ function ac_refresh(gobj, event, kw, src)
1302
+ {
1303
+ let mode = cur_mode(gobj);
1304
+ if(!mode) {
1305
+ log_error(`${gobj_short_name(gobj)}: unknown mode: ${gobj_read_attr(gobj, "mode")}`);
1306
+ return -1;
1307
+ }
1308
+
1309
+ /* The host may have written `mode` too — it is an attr, and a re-aimed
1310
+ * picker usually lands on a different one. Deliberately SILENT: it
1311
+ * publishes nothing, because nobody asked for a query yet; the host is
1312
+ * re-arming the picker, not answering with it. */
1313
+ gobj_change_state(gobj, (mode.kind === "bucket")? "ST_BUCKET" : "ST_FLAT");
1314
+
1315
+ close_calendar(gobj);
1316
+ refresh_bounds(gobj);
1317
+ repaint(gobj);
1318
+ return 0;
1319
+ }
1320
+
1321
+ /***************************************************************
1322
+ * The app switched language. Every label here is composed at build
1323
+ * time (a month name, "Week 27") and carries no key, so `refresh_language`
1324
+ * cannot reach it: it has to be re-rendered.
1325
+ ***************************************************************/
1326
+ function ac_language_changed(gobj, event, kw, src)
1327
+ {
1328
+ let priv = gobj.priv;
1329
+
1330
+ for(let mode of priv.modes) {
1331
+ if(!mode.$btn) {
1332
+ continue;
1333
+ }
1334
+ let $text = mode.$btn.querySelector(".YUI_PERIOD_MODE_TEXT");
1335
+ if($text) {
1336
+ $text.textContent = mode_name(mode);
1337
+ }
1338
+ }
1339
+ repaint(gobj);
1340
+ if(priv.$calendar) {
1341
+ render_calendar(gobj);
1342
+ }
1343
+ return 0;
1344
+ }
1345
+
1346
+ /***************************************************************
1347
+ * FSM
1348
+ ***************************************************************/
1349
+ /*---------------------------------------------*
1350
+ * Global methods table
1351
+ *---------------------------------------------*/
1352
+ const gmt = {
1353
+ mt_create: mt_create,
1354
+ mt_start: mt_start,
1355
+ mt_stop: mt_stop,
1356
+ mt_destroy: mt_destroy
1357
+ };
1358
+
1359
+ /***************************************************************
1360
+ * Create the GClass
1361
+ ***************************************************************/
1362
+ function create_gclass(gclass_name)
1363
+ {
1364
+ if(__gclass__) {
1365
+ log_error(`GClass ALREADY created: ${gclass_name}`);
1366
+ return -1;
1367
+ }
1368
+
1369
+ /*---------------------------------------------*
1370
+ * States
1371
+ *
1372
+ * ST_BUCKET a period is selected: it can be walked.
1373
+ * ST_FLAT span / custom / a rolling window: there is nothing to
1374
+ * walk, and an arrow arriving here is a caller's bug —
1375
+ * the FSM says so instead of no-op'ing.
1376
+ *---------------------------------------------*/
1377
+ const states = [
1378
+ ["ST_BUCKET", [
1379
+ ["EV_SET_MODE", ac_set_mode, null],
1380
+ ["EV_PREV", ac_prev, null],
1381
+ ["EV_NEXT", ac_next, null],
1382
+ ["EV_FIRST", ac_first, null],
1383
+ ["EV_LATEST", ac_latest, null],
1384
+ ["EV_PICK_DATE", ac_pick_date, null],
1385
+ ["EV_DATE_PICKED", ac_date_picked, null],
1386
+ ["EV_REFRESH", ac_refresh, null],
1387
+ ["EV_LANGUAGE_CHANGED", ac_language_changed, null]
1388
+ ]],
1389
+ ["ST_FLAT", [
1390
+ ["EV_SET_MODE", ac_set_mode, null],
1391
+ ["EV_REFRESH", ac_refresh, null],
1392
+ ["EV_LANGUAGE_CHANGED", ac_language_changed, null]
1393
+ ]]
1394
+ ];
1395
+
1396
+ /*---------------------------------------------*
1397
+ * Events
1398
+ *---------------------------------------------*/
1399
+ const event_types = [
1400
+ ["EV_SET_MODE", 0],
1401
+ ["EV_PREV", 0],
1402
+ ["EV_NEXT", 0],
1403
+ ["EV_FIRST", 0],
1404
+ ["EV_LATEST", 0],
1405
+ ["EV_PICK_DATE", 0],
1406
+ ["EV_DATE_PICKED", 0],
1407
+ ["EV_REFRESH", 0],
1408
+ ["EV_LANGUAGE_CHANGED", 0],
1409
+ ["EV_PERIOD_CHANGED", event_flag_t.EVF_OUTPUT_EVENT]
1410
+ ];
1411
+
1412
+ __gclass__ = gclass_create(
1413
+ gclass_name,
1414
+ event_types,
1415
+ states,
1416
+ gmt,
1417
+ 0, // lmt,
1418
+ attrs_table,
1419
+ PRIVATE_DATA,
1420
+ 0, // authz_table,
1421
+ 0, // command_table,
1422
+ 0, // s_user_trace_level
1423
+ 0 // gclass_flag
1424
+ );
1425
+
1426
+ if(!__gclass__) {
1427
+ return -1;
1428
+ }
1429
+
1430
+ return 0;
1431
+ }
1432
+
1433
+ /***************************************************************
1434
+ * Register GClass
1435
+ ***************************************************************/
1436
+ function register_c_yui_period()
1437
+ {
1438
+ return create_gclass(GCLASS_NAME);
1439
+ }
1440
+
1441
+ export {register_c_yui_period};