@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,330 @@
1
+ /***********************************************************************
2
+ * yui_time.test.js
3
+ *
4
+ * The period algebra, without a DOM.
5
+ *
6
+ * Everything here is built from LOCAL calendar fields
7
+ * (`new Date(y, m, d)`), never from a UTC literal, so the suite says
8
+ * the same thing in every timezone the developers and CI happen to
9
+ * sit in.
10
+ *
11
+ * Copyright (c) 2026, ArtGins.
12
+ * All Rights Reserved.
13
+ ***********************************************************************/
14
+ import {describe, it, expect} from "vitest";
15
+
16
+ import {
17
+ epoch_to_ms,
18
+ ms_to_epoch,
19
+ epoch_to_local_input,
20
+ local_input_to_epoch,
21
+ fmt_epoch,
22
+ iso_week,
23
+ period_spec,
24
+ period_start,
25
+ period_shift,
26
+ period_bounds,
27
+ period_bounds_epoch,
28
+ rolling_bounds,
29
+ is_current_period,
30
+ infer_period,
31
+ period_label,
32
+ day_number,
33
+ YUI_PERIODS
34
+ } from "./yui_time.js";
35
+
36
+ /* A translator that answers with the key, interpolated — what i18next
37
+ * does for a MISSING key, so the labels are asserted at their worst. */
38
+ const t = (key, params) => {
39
+ let s = key;
40
+ for(let k in (params || {})) {
41
+ s = s.replaceAll(`{{${k}}}`, String(params[k]));
42
+ }
43
+ return s;
44
+ };
45
+
46
+ const local = (y, m, d, h, mi, s) => new Date(y, m, d, h || 0, mi || 0, s || 0).getTime();
47
+
48
+ describe("epoch <-> clock", () => {
49
+ it("crosses the ms flag both ways", () => {
50
+ expect(epoch_to_ms(1500, false)).toBe(1500000);
51
+ expect(epoch_to_ms(1500, true)).toBe(1500);
52
+ expect(ms_to_epoch(1500999, false)).toBe(1500);
53
+ expect(ms_to_epoch(1500999, true)).toBe(1500999);
54
+ });
55
+
56
+ it("reads back the string a datetime-local gives", () => {
57
+ let ts = local(2026, 6, 14, 18, 55, 7); /* 14 jul 2026 */
58
+ let s = epoch_to_local_input(ts / 1000, false);
59
+ expect(s).toBe("2026-07-14T18:55:07");
60
+ expect(local_input_to_epoch(s, false)).toBe(ts / 1000);
61
+ });
62
+
63
+ it("keeps the milliseconds of a ms topic in a cell, and not in the picker", () => {
64
+ let ts = local(2026, 6, 14, 18, 55, 7) + 42;
65
+ expect(fmt_epoch(ts, true)).toBe("2026-07-14 18:55:07.042");
66
+ expect(epoch_to_local_input(ts, true)).toBe("2026-07-14T18:55:07");
67
+ });
68
+
69
+ it("reads an empty / unparseable bound as unset", () => {
70
+ expect(local_input_to_epoch("", false)).toBe(0);
71
+ expect(local_input_to_epoch("not a date", false)).toBe(0);
72
+ expect(epoch_to_local_input(0, false)).toBe("");
73
+ expect(fmt_epoch(0, false)).toBe("");
74
+ });
75
+ });
76
+
77
+ describe("ISO weeks", () => {
78
+ it("puts 1 jan 2026 (a Thursday) in week 1", () => {
79
+ expect(iso_week(new Date(2026, 0, 1))).toEqual({week: 1, year: 2026});
80
+ });
81
+
82
+ it("gives the WEEK-year, not the calendar year, on 1 jan 2027 (a Friday)", () => {
83
+ /* It closes week 53 of 2026: a label saying "week 1 2027" would
84
+ * name a week that starts three days LATER. */
85
+ expect(iso_week(new Date(2027, 0, 1))).toEqual({week: 53, year: 2026});
86
+ });
87
+
88
+ it("starts the week on Monday", () => {
89
+ /* 13 jul 2026 is a Monday, 19 jul the Sunday that closes it. */
90
+ expect(iso_week(new Date(2026, 6, 13)).week).toBe(29);
91
+ expect(iso_week(new Date(2026, 6, 19)).week).toBe(29);
92
+ expect(iso_week(new Date(2026, 6, 20)).week).toBe(30);
93
+ });
94
+ });
95
+
96
+ describe("bucket alignment", () => {
97
+ const at = local(2026, 6, 14, 18, 55, 7); /* Tue 14 jul 2026 */
98
+
99
+ it("floors a day to local midnight", () => {
100
+ expect(period_start("day", at).getTime()).toBe(local(2026, 6, 14));
101
+ });
102
+
103
+ it("floors a week to its Monday", () => {
104
+ expect(period_start("week", at).getTime()).toBe(local(2026, 6, 13));
105
+ });
106
+
107
+ it("floors a month to the 1st", () => {
108
+ expect(period_start("month", at).getTime()).toBe(local(2026, 6, 1));
109
+ });
110
+
111
+ it("aligns a quarter to the calendar year: jul -> Q3 starts 1 jul", () => {
112
+ expect(period_start("quarter", at).getTime()).toBe(local(2026, 6, 1));
113
+ expect(period_start("quarter", local(2026, 4, 30)).getTime()).toBe(local(2026, 3, 1));
114
+ });
115
+
116
+ it("aligns a semester: jul -> H2 starts 1 jul", () => {
117
+ expect(period_start("semester", at).getTime()).toBe(local(2026, 6, 1));
118
+ expect(period_start("semester", local(2026, 5, 30)).getTime()).toBe(local(2026, 0, 1));
119
+ });
120
+
121
+ it("aligns a bimester to the year: jul -> jul+aug", () => {
122
+ let b = period_bounds("bimester", at);
123
+ expect(b.from).toBe(local(2026, 6, 1));
124
+ expect(b.to).toBe(local(2026, 8, 1) - 1);
125
+ });
126
+
127
+ it("aligns a decade to 2020, not to 2021", () => {
128
+ expect(period_start("decade", at).getTime()).toBe(local(2020, 0, 1));
129
+ });
130
+
131
+ it("aligns a 15min bucket to the quarter of the hour", () => {
132
+ expect(period_start("15min", at).getTime()).toBe(local(2026, 6, 14, 18, 45));
133
+ });
134
+
135
+ it("aligns a 6h bucket to local midnight: 18:55 sits in the 18:00 one", () => {
136
+ let six = {id: "6h", unit: "hour", count: 6};
137
+ expect(period_start(six, at).getTime()).toBe(local(2026, 6, 14, 18));
138
+ expect(period_start(six, local(2026, 6, 14, 5, 59)).getTime())
139
+ .toBe(local(2026, 6, 14, 0));
140
+ });
141
+ });
142
+
143
+ describe("bucket bounds", () => {
144
+ it("closes the bucket on its LAST millisecond, not on the next one's first", () => {
145
+ /* Both ends of a match condition are inclusive: an exclusive end
146
+ * swallows the record that landed exactly on the boundary. */
147
+ let b = period_bounds("day", local(2026, 6, 14, 10));
148
+ expect(b.from).toBe(local(2026, 6, 14));
149
+ expect(b.to).toBe(local(2026, 6, 15) - 1);
150
+ });
151
+
152
+ it("hands the bounds over in the consumer's unit", () => {
153
+ let s = period_bounds_epoch("day", local(2026, 6, 14, 10), false);
154
+ expect(s.from).toBe(local(2026, 6, 14) / 1000);
155
+ expect(s.to).toBe(local(2026, 6, 15) / 1000 - 1);
156
+
157
+ let m = period_bounds_epoch("day", local(2026, 6, 14, 10), true);
158
+ expect(m.to).toBe(local(2026, 6, 15) - 1);
159
+ });
160
+
161
+ it("gives a month its own length, february included", () => {
162
+ expect(period_bounds("month", local(2024, 1, 10)).to)
163
+ .toBe(local(2024, 2, 1) - 1); /* 2024 is a leap year */
164
+ expect(period_bounds("month", local(2026, 1, 10)).to)
165
+ .toBe(local(2026, 2, 1) - 1);
166
+ });
167
+ });
168
+
169
+ describe("stepping", () => {
170
+ it("steps a month by the CALENDAR, not by 30 days", () => {
171
+ /* 31 jan + 1 month is february, not "3 march". */
172
+ let feb = period_shift("month", local(2026, 0, 31, 23), 1);
173
+ expect(feb).toBe(local(2026, 1, 1));
174
+ });
175
+
176
+ it("crosses the year in both directions", () => {
177
+ expect(period_shift("month", local(2026, 11, 15), 1)).toBe(local(2027, 0, 1));
178
+ expect(period_shift("month", local(2026, 0, 15), -1)).toBe(local(2025, 11, 1));
179
+ expect(period_shift("quarter", local(2026, 11, 15), 1)).toBe(local(2027, 0, 1));
180
+ });
181
+
182
+ it("steps a day to the start of the next LOCAL day — every day of the year", () => {
183
+ /* The DST trap: a day that adds 86400000 ms lands at 23:00 or at
184
+ * 01:00 the two days a year the clock moves. Walking a whole year
185
+ * catches it in any timezone that has DST at all.
186
+ *
187
+ * NOT asserted: hour === 0. In timezones whose transition happens
188
+ * AT midnight (America/Santiago springs 23:59 -> 01:00) the first
189
+ * existing instant of the day IS 01:00, and that is the right
190
+ * answer. What must hold in every timezone: the step lands exactly
191
+ * on the first instant of the next calendar day (what the Date
192
+ * constructor resolves for it), and the buckets stay contiguous. */
193
+ let d = local(2026, 0, 1);
194
+ for(let i = 0; i < 365; i++) {
195
+ let next = period_shift("day", d, 1);
196
+ let as_date = new Date(next);
197
+ expect(next).toBe(local(
198
+ as_date.getFullYear(), as_date.getMonth(), as_date.getDate()
199
+ ));
200
+ expect(day_number(as_date)).toBe(day_number(new Date(d)) + 1);
201
+ expect(period_bounds("day", d).to).toBe(next - 1);
202
+ d = next;
203
+ }
204
+ expect(new Date(d).getFullYear()).toBe(2027);
205
+ });
206
+
207
+ it("steps a week to a Monday, always", () => {
208
+ let d = local(2026, 0, 5); /* a Monday */
209
+ for(let i = 0; i < 60; i++) {
210
+ d = period_shift("week", d, 1);
211
+ expect(new Date(d).getDay()).toBe(1);
212
+ expect(new Date(d).getHours()).toBe(0);
213
+ }
214
+ });
215
+ });
216
+
217
+ describe("navigation state", () => {
218
+ it("knows the bucket `now` falls in", () => {
219
+ let now = local(2026, 6, 14, 18, 0);
220
+ expect(is_current_period("day", local(2026, 6, 14, 3), now)).toBe(true);
221
+ expect(is_current_period("day", local(2026, 6, 13, 3), now)).toBe(false);
222
+ expect(is_current_period("month", local(2026, 6, 1), now)).toBe(true);
223
+ expect(is_current_period("year", local(2026, 0, 1), now)).toBe(true);
224
+ expect(is_current_period("year", local(2025, 0, 1), now)).toBe(false);
225
+ });
226
+
227
+ it("leaves a rolling window's end OPEN", () => {
228
+ let now = local(2026, 6, 14, 18, 0);
229
+ let r = rolling_bounds("24h", false, now);
230
+ expect(r.from).toBe((now - 24 * 3600 * 1000) / 1000);
231
+ expect(r.to).toBe(0);
232
+ });
233
+ });
234
+
235
+ describe("recognizing a range", () => {
236
+ it("brings a round-tripped range back as the period it was", () => {
237
+ let b = period_bounds("quarter", local(2026, 6, 14));
238
+ let got = infer_period(b.from, b.to, ["day", "week", "month", "quarter", "year"], true);
239
+ expect(got.period.id).toBe("quarter");
240
+ expect(period_bounds(got.period, got.anchor)).toEqual(b);
241
+ });
242
+
243
+ it("recognizes a bucket a SECONDS consumer stored, .999 and all", () => {
244
+ /* The bucket ends at …23:59:59.999; a seconds topic wrote
245
+ * …23:59:59. Compared in milliseconds, the week it saved came back
246
+ * as a hand-typed range and the granularity fell to "All". */
247
+ let b = period_bounds_epoch("week", local(2026, 6, 14), false);
248
+ let got = infer_period(b.from, b.to, ["day", "week", "month", "year"], false);
249
+ expect(got).not.toBe(null);
250
+ expect(got.period.id).toBe("week");
251
+ expect(period_bounds_epoch(got.period, got.anchor, false)).toEqual(b);
252
+ });
253
+
254
+ it("refuses a range that is not a bucket", () => {
255
+ let from = local(2026, 6, 14, 3, 12);
256
+ let to = local(2026, 6, 18, 9, 40);
257
+ expect(infer_period(from, to, ["day", "week", "month"], true)).toBe(null);
258
+ expect(infer_period(0, 0, ["day"], true)).toBe(null);
259
+ });
260
+
261
+ it("does not confuse a month with a bucket that merely starts with it", () => {
262
+ let month = period_bounds("month", local(2026, 6, 14));
263
+ expect(infer_period(month.from, month.to, ["quarter"], true)).toBe(null);
264
+ });
265
+ });
266
+
267
+ describe("labels", () => {
268
+ it("names the day the way a human does", () => {
269
+ let now = Date.now();
270
+ expect(period_label("day", now, t)).toBe("today");
271
+ expect(period_label("day", period_shift("day", now, -1), t)).toBe("yesterday");
272
+ });
273
+
274
+ it("names the two weeks a human never calls by their number", () => {
275
+ let now = Date.now();
276
+ expect(period_label("week", now, t)).toBe("this week");
277
+ expect(period_label("week", period_shift("week", now, -1), t)).toBe("last week");
278
+ });
279
+
280
+ it("numbers any other week, and adds the year only when it is not this one", () => {
281
+ let now = Date.now();
282
+ let back = period_shift("week", now, -3);
283
+ /* Runtime-relative on purpose (the suffix compares against TODAY's
284
+ * year), so the expectation must be too: a run in early January has
285
+ * its week-3-weeks-ago in the previous ISO year, suffix included. */
286
+ let w = iso_week(new Date(back));
287
+ let expected = (w.year === new Date().getFullYear())
288
+ ? `week ${w.week}`
289
+ : `week ${w.week} ${w.year}`;
290
+ expect(period_label("week", back, t)).toBe(expected);
291
+ /* 14 jul 2019 was a SUNDAY: it closes week 28, it does not open 29. */
292
+ expect(period_label("week", local(2019, 6, 14), t)).toBe("week 28 2019");
293
+ });
294
+
295
+ it("names a quarter and a semester by their number", () => {
296
+ expect(period_label("quarter", local(2026, 6, 14), t)).toBe("quarter 3 2026");
297
+ expect(period_label("semester", local(2026, 6, 14), t)).toBe("semester 2 2026");
298
+ expect(period_label("quarter", local(2026, 0, 5), t)).toBe("quarter 1 2026");
299
+ });
300
+
301
+ it("names a year, and spans a decade", () => {
302
+ expect(period_label("year", local(2025, 3, 1), t)).toBe("2025");
303
+ expect(period_label("decade", local(2026, 3, 1), t)).toBe("2020 – 2029");
304
+ });
305
+
306
+ it("falls back to the bucket's own edges for anything an app invents", () => {
307
+ let ten_days = {id: "10d", unit: "day", count: 10};
308
+ let label = period_label(ten_days, local(2019, 6, 14), t);
309
+ /* No name to give it: it says what it spans, and it says the year
310
+ * because the year is not this one. */
311
+ expect(label).toContain("–");
312
+ expect(label).toContain("2019");
313
+ });
314
+
315
+ it("says nothing about a mode that is not a bucket", () => {
316
+ expect(period_label(null, Date.now(), t)).toBe("");
317
+ expect(period_spec("span")).toBe(null);
318
+ expect(period_spec({id: "custom"})).toBe(null);
319
+ });
320
+ });
321
+
322
+ describe("the catalog", () => {
323
+ it("declares every named bucket as (unit, count)", () => {
324
+ for(let id in YUI_PERIODS) {
325
+ let spec = period_spec(id);
326
+ expect(spec).not.toBe(null);
327
+ expect(spec.count).toBeGreaterThan(0);
328
+ }
329
+ });
330
+ });
@@ -48,3 +48,31 @@
48
48
  .yui-horizontal-toolbar-scroll-btn span {
49
49
  width: 1.2em;
50
50
  }
51
+
52
+ /* Moved from c_yui_main.css (2.6.1): horizontal toolbar section.
53
+ * Self-contained here so every consumer gets it without the
54
+ * legacy stack's stylesheet. */
55
+
56
+ /*********************************************************
57
+ * Toolbars
58
+ *********************************************************/
59
+ .yui-horizontal-toolbar-section {
60
+ display: flex;
61
+ height: auto;
62
+ flex-shrink: 0; /* Prevents resizing */
63
+ white-space: nowrap;
64
+ margin: 0 2px; /* Space between sections */
65
+ justify-content: space-between;
66
+ align-items: center;
67
+ }
68
+
69
+ a.flex-horizontal-section { /* To make flex any <a> */
70
+ display: flex;
71
+ flex-shrink: 0; /* Prevents resizing */
72
+ white-space: nowrap;
73
+ margin: 0 2px; /* Space between sections */
74
+ justify-content: space-between;
75
+ align-items: center;
76
+ padding-inline-end: 2rem;
77
+ }
78
+
@@ -1,12 +1,14 @@
1
1
  /*
2
- * A toolbar with scroll (hidden) horizontal
2
+ * A horizontal toolbar with a hidden scrollbar and auto-appearing
3
+ * left/right scroll arrows.
3
4
  *
4
5
  * Using Bulma Framework (https://bulma.io)
5
6
  *
6
- * attrs: attributes to <div> of yui-horizontal-toolbar
7
- * items: [[createElement2() or parameters of createElement2]]
7
+ * attrs: attributes for the outer <div> of yui-horizontal-toolbar
8
+ * (the caller's object is not mutated).
9
+ * items: [[createElement2() output or parameters of createElement2]]
8
10
  */
9
- /* global ResizeObserver, window, document */
11
+ /* global ResizeObserver */
10
12
 
11
13
  import {
12
14
  createElement2, debounce
@@ -16,69 +18,110 @@ import "./yui_toolbar.css"; // Must be in index.js ?
16
18
 
17
19
  function yui_toolbar(attrs={}, items = [])
18
20
  {
19
- const left_button = `
20
- <span class="has-text-primary is-flex"><svg viewBox="0 0 320 512" style="fill:var(--bulma-link)"><path class="fa-secondary" opacity="1" d="M41.4 278.6c-12.5-12.5-12.5-32.8 0-45.3l160-160c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3L109.3 256 246.6 393.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0l-160-160z"/><path class="fa-primary" d=""/></svg></span>
21
- `;
22
- const right_button = `
23
- <span class="has-text-primary is-flex"><svg viewBox="0 0 320 512" style="fill:var(--bulma-link)"><path class="fa-secondary" opacity="1" d="M278.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-160 160c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L210.7 256 73.4 118.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160z"/><path class="fa-primary" d=""/></svg></span>
24
- `;
25
-
26
- // Create the toolbar container
27
- attrs.class = attrs.class?`yui-horizontal-toolbar ${attrs.class}`:'yui-horizontal-toolbar';
28
- let $toolbar = createElement2(['div', attrs, [
29
- ['button', { class: 'yui-horizontal-toolbar-scroll-btn left' },
30
- left_button, //'<',
21
+ /*
22
+ * Scroll arrow buttons: use the repo icon set (yi-chevron-*), colored
23
+ * via currentColor so both themes work; never a hardcoded fill.
24
+ */
25
+ function arrow_button(side, icon, label)
26
+ {
27
+ return ['button',
31
28
  {
32
- click: cb_left_arrow
33
- }
34
- ],
35
- ['div', { class: 'yui-horizontal-toolbar-container' }, items],
36
- ['button', { class: 'yui-horizontal-toolbar-scroll-btn right' },
37
- right_button, //'>',
29
+ class: `yui-horizontal-toolbar-scroll-btn ${side} has-text-link`,
30
+ /* Start hidden: only shown once we know the content overflows,
31
+ * never a flash of both arrows on a toolbar that doesn't scroll. */
32
+ style: 'display:none',
33
+ type: 'button',
34
+ title: label,
35
+ 'aria-label': label,
36
+ 'data-i18n-title': label,
37
+ 'data-i18n-aria-label': label
38
+ },
39
+ [['span', {class: 'icon'}, [['i', {class: icon}]]]],
38
40
  {
39
- click: cb_right_arrow
41
+ click: side === 'left' ? cb_left_arrow : cb_right_arrow
40
42
  }
41
- ]
43
+ ];
44
+ }
45
+
46
+ /*
47
+ * Don't mutate the caller's attrs object.
48
+ */
49
+ const div_attrs = {...attrs};
50
+ div_attrs.class = div_attrs.class ?
51
+ `yui-horizontal-toolbar ${div_attrs.class}` : 'yui-horizontal-toolbar';
52
+
53
+ // Create the toolbar container
54
+ let $toolbar = createElement2(['div', div_attrs, [
55
+ arrow_button('left', 'yi-chevron-left', 'scroll left'),
56
+ ['div', {class: 'yui-horizontal-toolbar-container'}, items],
57
+ arrow_button('right', 'yi-chevron-right', 'scroll right')
42
58
  ]]);
43
59
 
44
- let $container =$toolbar.querySelector('.yui-horizontal-toolbar-container');
45
- let $leftButton =$toolbar.querySelector('.yui-horizontal-toolbar-scroll-btn.left');
46
- let $rightButton =$toolbar.querySelector('.yui-horizontal-toolbar-scroll-btn.right');
60
+ let $container = $toolbar.querySelector('.yui-horizontal-toolbar-container');
61
+ let $leftButton = $toolbar.querySelector('.yui-horizontal-toolbar-scroll-btn.left');
62
+ let $rightButton = $toolbar.querySelector('.yui-horizontal-toolbar-scroll-btn.right');
47
63
 
64
+ /*
65
+ * Scroll by most of the visible width so a click makes a real jump,
66
+ * with a sane floor when the toolbar is very narrow.
67
+ */
68
+ function scroll_step() {
69
+ return Math.max(80, Math.round($container.clientWidth * 0.8));
70
+ }
48
71
  function cb_left_arrow(evt) {
49
72
  evt.stopPropagation();
50
- $container.scrollBy({ left: -20, behavior: 'smooth' });
73
+ $container.scrollBy({left: -scroll_step(), behavior: 'smooth'});
51
74
  }
52
75
  function cb_right_arrow(evt) {
53
76
  evt.stopPropagation();
54
- $container.scrollBy({ left: 20, behavior: 'smooth' });
77
+ $container.scrollBy({left: scroll_step(), behavior: 'smooth'});
55
78
  }
56
79
 
57
- function updateScrollButtons(evt) {
58
- // window.console.log(evt);
59
- const isScrollable = $container.scrollWidth > $container.clientWidth;
60
- const atStart = $container.scrollLeft <= 0;
61
- const atEnd = $container.scrollLeft >= $container.scrollWidth - $container.clientWidth;
62
- $leftButton.style.display = isScrollable && !atStart ? 'block' : 'none';
63
- $rightButton.style.display = isScrollable && !atEnd ? 'block' : 'none';
80
+ let wasConnected = false;
81
+ function updateScrollButtons() {
82
+ if($container.isConnected) {
83
+ wasConnected = true;
84
+ } else if(wasConnected) {
85
+ /*
86
+ * Detached AFTER having been live: stop observing so the toolbar
87
+ * subtree can be garbage collected (the observer's only tie is
88
+ * $container). Before the first insertion the node is legitimately
89
+ * disconnected — fall through and compute (widths are 0, so both
90
+ * arrows resolve to hidden), never disconnect pre-emptively.
91
+ */
92
+ observer.disconnect();
93
+ debouncedResize.cancel();
94
+ return;
95
+ }
96
+ const tolerance = 1; /* sub-pixel rounding of scrollLeft */
97
+ const isScrollable = $container.scrollWidth > $container.clientWidth + tolerance;
98
+ const atStart = $container.scrollLeft <= tolerance;
99
+ const atEnd = $container.scrollLeft >=
100
+ $container.scrollWidth - $container.clientWidth - tolerance;
101
+ /*
102
+ * Empty string (not 'none'/'block') lets the CSS `display:flex`
103
+ * reassert and keep the arrow centered.
104
+ */
105
+ $leftButton.style.display = isScrollable && !atStart ? '' : 'none';
106
+ $rightButton.style.display = isScrollable && !atEnd ? '' : 'none';
64
107
  }
65
108
 
66
109
  $container.addEventListener('scroll', updateScrollButtons);
67
110
 
111
+ /*
112
+ * Observe the container itself (NOT document.body): it delivers an
113
+ * initial callback once the toolbar is laid out and fires whenever the
114
+ * available width changes, and it does not pin the subtree to the
115
+ * page-lifetime <body>.
116
+ */
68
117
  const debouncedResize = debounce(updateScrollButtons, 300);
69
118
  const observer = new ResizeObserver(() => {
70
119
  debouncedResize();
71
120
  });
72
- observer.observe(document.body);
121
+ observer.observe($container);
73
122
 
74
123
  updateScrollButtons();
75
124
 
76
-
77
- // window.addEventListener('resize', updateScrollButtons);
78
- // window.addEventListener('pageshow', updateScrollButtons);
79
- //
80
- // updateScrollButtons();
81
-
82
125
  return $toolbar;
83
126
  }
84
127