@yuneta/gobj-ui 1.0.1 → 2.2.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 (46) hide show
  1. package/README.md +40 -363
  2. package/dist/gobj-ui.cjs.js +11154 -5506
  3. package/dist/gobj-ui.es.js +11131 -5508
  4. package/index.js +41 -14
  5. package/package.json +11 -9
  6. package/src/c_g6_nodes_tree.js +6 -1
  7. package/src/c_yui_form.js +1 -1
  8. package/src/c_yui_gobj_tree_js.js +1 -1
  9. package/src/c_yui_json_graph.js +1 -1
  10. package/src/c_yui_main.js +3 -3
  11. package/src/c_yui_map.js +6 -1
  12. package/src/c_yui_nav.js +881 -0
  13. package/src/c_yui_pager.js +545 -0
  14. package/src/c_yui_routing.css +1 -1
  15. package/src/c_yui_routing.js +1 -1
  16. package/src/c_yui_shell.css +571 -0
  17. package/src/c_yui_shell.js +2474 -0
  18. package/src/c_yui_tabs.js +1 -1
  19. package/src/c_yui_treedb_graph.js +35 -9
  20. package/src/c_yui_treedb_topic_with_form.js +1 -1
  21. package/src/c_yui_treedb_topics.js +36 -8
  22. package/src/c_yui_uplot.js +1 -1
  23. package/src/c_yui_window.js +242 -21
  24. package/src/c_yui_window_manager.js +602 -0
  25. package/src/c_yui_wizard.js +612 -0
  26. package/src/pager_helpers.js +138 -0
  27. package/src/pager_helpers.test.js +140 -0
  28. package/src/route_resolver.js +53 -0
  29. package/src/route_resolver.test.js +82 -0
  30. package/src/shell_focus_trap.js +123 -0
  31. package/src/shell_focus_trap.test.js +299 -0
  32. package/src/shell_modals.js +445 -0
  33. package/src/shell_show_on.js +91 -0
  34. package/src/shell_show_on.test.js +86 -0
  35. package/src/shell_toolbar_helpers.js +221 -0
  36. package/src/shell_toolbar_helpers.test.js +207 -0
  37. package/src/tabulator.css +53 -0
  38. package/src/wizard_helpers.js +117 -0
  39. package/src/wizard_helpers.test.js +122 -0
  40. package/src/yui_dev.js +1257 -362
  41. package/src/yui_icons.css +5 -0
  42. package/src/yui_inputs.css +32 -0
  43. package/src/yui_inputs.js +71 -0
  44. package/vite-plugin-yuneta-html.js +2 -2
  45. package/skeleton/config.json +0 -20
  46. package/skeleton/index.html +0 -37
@@ -0,0 +1,445 @@
1
+ /***********************************************************************
2
+ * shell_modals.js
3
+ *
4
+ * Modal / notification API on top of the layers C_YUI_SHELL
5
+ * already creates (`priv.layers.notification`,
6
+ * `priv.layers.modal`). Naming convention:
7
+ *
8
+ * yui_shell_show_* — non-blocking notifications/modal
9
+ * yui_shell_confirm_* — blocking dialog, returns Promise
10
+ *
11
+ * Bulma `.notification` / `.modal-card` markup is reused
12
+ * verbatim so apps importing Bulma get the visual styling for
13
+ * free.
14
+ *
15
+ * Every blocking dialog and every modal pushes a close handler
16
+ * onto the shell's Escape priority chain (see
17
+ * yui_shell_push_escape / yui_shell_pop_escape) so Escape
18
+ * closes the topmost overlay only.
19
+ *
20
+ * The legacy display_* / get_yes* helpers in c_yui_main.js are
21
+ * NOT changed — apps that ride on the legacy shell keep using
22
+ * them (see SHELL.md §10 drift policy).
23
+ *
24
+ * Copyright (c) 2026, ArtGins.
25
+ * All Rights Reserved.
26
+ ***********************************************************************/
27
+ /* global document */
28
+
29
+ import {
30
+ gobj_read_attr,
31
+ createElement2,
32
+ refresh_language,
33
+ empty_string,
34
+ log_warning,
35
+ } from "@yuneta/gobj-js";
36
+
37
+ import {
38
+ activate_focus_trap_on,
39
+ } from "./shell_focus_trap.js";
40
+
41
+ import {
42
+ yui_shell_push_escape,
43
+ yui_shell_pop_escape,
44
+ } from "./c_yui_shell.js";
45
+
46
+
47
+ /***************************************************************
48
+ * Layer accessors
49
+ ***************************************************************/
50
+ function notification_layer(shell)
51
+ {
52
+ let priv = gobj_read_attr(shell, "priv");
53
+ return priv && priv.layers && priv.layers.notification;
54
+ }
55
+
56
+ function modal_layer(shell)
57
+ {
58
+ let priv = gobj_read_attr(shell, "priv");
59
+ return priv && priv.layers && priv.layers.modal;
60
+ }
61
+
62
+
63
+ /***************************************************************
64
+ * i18n bridge:
65
+ * - `text` is rendered as the canonical English key.
66
+ * - The hosting element is tagged with `data-i18n="<key>"`,
67
+ * so a later `refresh_language(shell.$container, t)` call
68
+ * retranslates the modal even if it was created BEFORE the
69
+ * language was switched.
70
+ * - If `opts.t` is a function, the helper translates the
71
+ * node right after creating it — so a modal opened AFTER
72
+ * the user has already toggled to ES renders in ES on the
73
+ * first frame, not the canonical English key.
74
+ ***************************************************************/
75
+ function maybe_apply_translator($node, opts)
76
+ {
77
+ if(opts && typeof opts.t === "function") {
78
+ refresh_language($node, opts.t);
79
+ }
80
+ }
81
+
82
+
83
+ /***************************************************************
84
+ * Notifications (Bulma .notification)
85
+ *
86
+ * Non-blocking, auto-dismiss after `opts.timeout` ms (default
87
+ * 5000). `opts.timeout = 0` disables auto-dismiss.
88
+ * Returns `{ close() }` so callers can dismiss programmatically.
89
+ ***************************************************************/
90
+ function show_notification(shell, kind, message, opts)
91
+ {
92
+ let $layer = notification_layer(shell);
93
+ if(!$layer) {
94
+ log_warning("yui_shell_show_*: shell has no notification layer");
95
+ return { close: () => {} };
96
+ }
97
+
98
+ let p_attrs = (typeof message === "string")
99
+ ? {i18n: message}
100
+ : {};
101
+ let $note = createElement2(
102
+ ["div", {class: `notification yui-notification is-${kind} is-light`,
103
+ role: kind === "danger" ? "alert" : "status"},
104
+ [
105
+ ["button", {class: "delete", "aria-label": "close"}],
106
+ ["p", p_attrs, message]
107
+ ]
108
+ ]
109
+ );
110
+ $layer.appendChild($note);
111
+ maybe_apply_translator($note, opts);
112
+
113
+ let timeout_id = null;
114
+ let closed = false;
115
+
116
+ let close = function() {
117
+ if(closed) {
118
+ return;
119
+ }
120
+ closed = true;
121
+ if(timeout_id) {
122
+ clearTimeout(timeout_id);
123
+ timeout_id = null;
124
+ }
125
+ if($note.parentNode) {
126
+ $note.parentNode.removeChild($note);
127
+ }
128
+ };
129
+
130
+ let $del = $note.querySelector(".delete");
131
+ if($del) {
132
+ $del.addEventListener("click", close);
133
+ }
134
+
135
+ let timeout = (opts && opts.timeout != null) ? opts.timeout : 5000;
136
+ if(timeout > 0) {
137
+ timeout_id = setTimeout(close, timeout);
138
+ }
139
+
140
+ return { close };
141
+ }
142
+
143
+
144
+ export function yui_shell_show_info(shell, message, opts)
145
+ {
146
+ return show_notification(shell, "info", message, opts);
147
+ }
148
+ export function yui_shell_show_warning(shell, message, opts)
149
+ {
150
+ return show_notification(shell, "warning", message, opts);
151
+ }
152
+ export function yui_shell_show_error(shell, message, opts)
153
+ {
154
+ return show_notification(shell, "danger", message, opts);
155
+ }
156
+
157
+
158
+ /***************************************************************
159
+ * Modal (Bulma .modal — non-blocking)
160
+ *
161
+ * Drops a Bulma `.modal-content` overlay into the modal layer.
162
+ * `content` may be a string (rendered inside a Bulma .box) or
163
+ * an HTMLElement (rendered as-is). Returns `{ close() }`.
164
+ * The caller decides when to dismiss; click on background,
165
+ * the `.modal-close` button, or `Escape` also close it.
166
+ ***************************************************************/
167
+ export function yui_shell_show_modal(shell, content, opts)
168
+ {
169
+ let $layer = modal_layer(shell);
170
+ if(!$layer) {
171
+ log_warning("yui_shell_show_modal: shell has no modal layer");
172
+ return { close: () => {} };
173
+ }
174
+
175
+ let inner = (typeof content === "string")
176
+ ? ["div", {class: "box"}, [["p", {i18n: content}, content]]]
177
+ : null;
178
+
179
+ /* Adaptive DIALOG mode (`opts.dialog: true`): the standardized
180
+ * "single window / popup" chrome — a centered card with the close X
181
+ * at the top-right on desktop, and a full-screen sheet with a back
182
+ * arrow at the top-left on mobile. A header bar carries `opts.title`
183
+ * and BOTH dismiss controls; CSS shows the right one per breakpoint.
184
+ * Both call close() — the app's on_close decides navigation (usually
185
+ * history.back()), so gobj-ui stays routing-agnostic. */
186
+ let dialog = !!(opts && opts.dialog);
187
+ let title = (opts && opts.title) || "";
188
+
189
+ /* The external Bulma `.modal-close is-large` sits at the
190
+ * top-right of the viewport, outside the content box. Callers
191
+ * whose content provides its own in-box close (e.g. a
192
+ * C_YUI_PAGER header) pass `with_close_button: false` to omit
193
+ * it; Escape and the backdrop still close the modal. */
194
+ let with_close = !(opts && opts.with_close_button === false);
195
+ let modal_children;
196
+ if(dialog) {
197
+ let header = ["div", {class: "yui-dialog-header"}, [
198
+ ["button", {class: "yui-dialog-back", type: "button", "aria-label": "back"},
199
+ [["i", {class: "yi-arrow-left"}]]],
200
+ ["span", {class: "yui-dialog-title", i18n: title}, title],
201
+ ["button", {class: "yui-dialog-x", type: "button", "aria-label": "close"},
202
+ [["i", {class: "yi-xmark"}]]],
203
+ ]];
204
+ let body = ["div", {class: "yui-dialog-body"}, inner ? [inner] : []];
205
+ modal_children = [
206
+ ["div", {class: "modal-background"}],
207
+ ["div", {class: "modal-content yui-dialog-content"}, [header, body]]
208
+ ];
209
+ /* The header X replaces the external Bulma close button. */
210
+ with_close = false;
211
+ } else {
212
+ modal_children = [
213
+ ["div", {class: "modal-background"}],
214
+ ["div", {class: "modal-content"},
215
+ inner ? [inner] : []
216
+ ]
217
+ ];
218
+ if(with_close) {
219
+ modal_children.push(
220
+ ["button", {class: "modal-close is-large",
221
+ "aria-label": "close"}]
222
+ );
223
+ }
224
+ }
225
+
226
+ let $modal = createElement2(
227
+ ["div", {class: "modal yui-modal" + (dialog ? " yui-dialog" : "") + " is-active",
228
+ role: "dialog", "aria-modal": "true"},
229
+ modal_children
230
+ ]
231
+ );
232
+ $layer.appendChild($modal);
233
+
234
+ if(!inner && content && typeof content.appendChild !== "undefined") {
235
+ let $content = dialog
236
+ ? $modal.querySelector(".yui-dialog-body")
237
+ : $modal.querySelector(".modal-content");
238
+ $content.appendChild(content);
239
+ }
240
+
241
+ maybe_apply_translator($modal, opts);
242
+
243
+ let closed = false;
244
+ let close_fn = null;
245
+ let release_focus = null;
246
+
247
+ let close = function() {
248
+ if(closed) {
249
+ return;
250
+ }
251
+ closed = true;
252
+ if(release_focus) {
253
+ release_focus();
254
+ release_focus = null;
255
+ }
256
+ if(close_fn) {
257
+ yui_shell_pop_escape(shell, close_fn);
258
+ close_fn = null;
259
+ }
260
+ if($modal.parentNode) {
261
+ $modal.parentNode.removeChild($modal);
262
+ }
263
+ if(opts && typeof opts.on_close === "function") {
264
+ try {
265
+ opts.on_close();
266
+ } catch(e) {
267
+ log_warning(`yui_shell_show_modal: on_close threw: ${e}`);
268
+ }
269
+ }
270
+ };
271
+ close_fn = close;
272
+
273
+ yui_shell_push_escape(shell, "modal", close);
274
+ /* Trap on $modal (not on .modal-content) so the .modal-close
275
+ * button — rendered as a SIBLING of .modal-content — is
276
+ * reachable via Tab. Without this, Tab/Shift+Tab can only
277
+ * cycle among focusables that the caller put inside
278
+ * .modal-content; the X button can only be clicked. */
279
+ release_focus = activate_focus_trap_on($modal);
280
+
281
+ if((opts == null || opts.dismiss_on_background !== false)) {
282
+ $modal.querySelector(".modal-background").addEventListener("click", close);
283
+ }
284
+ let $close_btn = $modal.querySelector(".modal-close");
285
+ if($close_btn) {
286
+ $close_btn.addEventListener("click", close);
287
+ }
288
+ if(dialog) {
289
+ let $back = $modal.querySelector(".yui-dialog-back");
290
+ if($back) {
291
+ $back.addEventListener("click", close);
292
+ }
293
+ let $x = $modal.querySelector(".yui-dialog-x");
294
+ if($x) {
295
+ $x.addEventListener("click", close);
296
+ }
297
+ }
298
+
299
+ return { close };
300
+ }
301
+
302
+
303
+ /***************************************************************
304
+ * Blocking dialogs (Bulma .modal-card)
305
+ *
306
+ * build_dialog returns a Promise that resolves with the
307
+ * clicked button's `value`. Escape, the close button and
308
+ * the dismiss action all resolve with the LAST button's value
309
+ * (cancel/no/ok by convention — the safe-default action).
310
+ ***************************************************************/
311
+ function build_dialog(shell, message, buttons, opts)
312
+ {
313
+ let $layer = modal_layer(shell);
314
+ if(!$layer) {
315
+ log_warning("yui_shell_confirm_*: shell has no modal layer");
316
+ return Promise.resolve(buttons[buttons.length - 1].value);
317
+ }
318
+
319
+ let title = (opts && opts.title) || "";
320
+ let dismiss_value = buttons[buttons.length - 1].value;
321
+
322
+ let $body_children = (typeof message === "string")
323
+ ? [["p", {i18n: message}, message]]
324
+ : [message];
325
+
326
+ let title_attrs = !empty_string(title)
327
+ ? {class: "modal-card-title", i18n: title}
328
+ : {class: "modal-card-title"};
329
+
330
+ let $footer_children = buttons.map(b => {
331
+ let cls = "button";
332
+ if(b.kind === "primary") {
333
+ cls += " is-link";
334
+ } else if(b.kind === "danger") {
335
+ cls += " is-danger";
336
+ }
337
+ let btn_attrs = {class: cls, type: "button",
338
+ "data-modal-button-value": b.value};
339
+ if(typeof b.label === "string") {
340
+ btn_attrs.i18n = b.label;
341
+ }
342
+ return ["button", btn_attrs, b.label];
343
+ });
344
+
345
+ let $modal = createElement2(
346
+ ["div", {class: "modal yui-modal yui-confirm is-active",
347
+ role: "dialog", "aria-modal": "true"},
348
+ [
349
+ ["div", {class: "modal-background"}],
350
+ ["div", {class: "modal-card"},
351
+ [
352
+ ["header", {class: "modal-card-head"},
353
+ [
354
+ ["p", title_attrs, title],
355
+ ["button", {class: "delete",
356
+ "aria-label": "close"}]
357
+ ]
358
+ ],
359
+ ["section", {class: "modal-card-body"}, $body_children],
360
+ ["footer", {class: "modal-card-foot"}, $footer_children]
361
+ ]
362
+ ]
363
+ ]
364
+ ]
365
+ );
366
+ $layer.appendChild($modal);
367
+ maybe_apply_translator($modal, opts);
368
+
369
+ return new Promise(resolve => {
370
+ let resolved = false;
371
+ let close_fn = null;
372
+ let release_focus = null;
373
+
374
+ let close = function(value) {
375
+ if(resolved) {
376
+ return;
377
+ }
378
+ resolved = true;
379
+ if(release_focus) {
380
+ release_focus();
381
+ release_focus = null;
382
+ }
383
+ if(close_fn) {
384
+ yui_shell_pop_escape(shell, close_fn);
385
+ close_fn = null;
386
+ }
387
+ if($modal.parentNode) {
388
+ $modal.parentNode.removeChild($modal);
389
+ }
390
+ resolve(value);
391
+ };
392
+ close_fn = () => close(dismiss_value);
393
+
394
+ yui_shell_push_escape(shell, "modal", close_fn);
395
+ release_focus = activate_focus_trap_on(
396
+ $modal.querySelector(".modal-card")
397
+ );
398
+
399
+ $modal.querySelector(".modal-background").addEventListener(
400
+ "click", () => close(dismiss_value)
401
+ );
402
+ $modal.querySelector(".modal-card-head .delete").addEventListener(
403
+ "click", () => close(dismiss_value)
404
+ );
405
+ let footer_buttons = $modal.querySelectorAll(
406
+ ".modal-card-foot button"
407
+ );
408
+ footer_buttons.forEach($btn => {
409
+ $btn.addEventListener("click", () => {
410
+ close($btn.getAttribute("data-modal-button-value"));
411
+ });
412
+ });
413
+ });
414
+ }
415
+
416
+
417
+ export function yui_shell_confirm_ok(shell, message, opts)
418
+ {
419
+ let label = (opts && opts.ok_label) || "OK";
420
+ return build_dialog(shell, message, [
421
+ {label: label, value: "ok", kind: "primary"}
422
+ ], opts).then(() => undefined);
423
+ }
424
+
425
+ export function yui_shell_confirm_yesno(shell, message, opts)
426
+ {
427
+ let yes_label = (opts && opts.yes_label) || "Yes";
428
+ let no_label = (opts && opts.no_label) || "No";
429
+ return build_dialog(shell, message, [
430
+ {label: yes_label, value: "yes", kind: "primary"},
431
+ {label: no_label, value: "no"}
432
+ ], opts).then(v => v === "yes");
433
+ }
434
+
435
+ export function yui_shell_confirm_yesnocancel(shell, message, opts)
436
+ {
437
+ let yes_label = (opts && opts.yes_label) || "Yes";
438
+ let no_label = (opts && opts.no_label) || "No";
439
+ let cancel_label = (opts && opts.cancel_label) || "Cancel";
440
+ return build_dialog(shell, message, [
441
+ {label: yes_label, value: "yes", kind: "primary"},
442
+ {label: no_label, value: "no"},
443
+ {label: cancel_label, value: "cancel"}
444
+ ], opts);
445
+ }
@@ -0,0 +1,91 @@
1
+ /***********************************************************************
2
+ * shell_show_on.js
3
+ *
4
+ * Pure breakpoint helpers used by C_YUI_SHELL to hide/show zones
5
+ * via Bulma visibility classes. Split out of c_yui_shell.js so
6
+ * it can be unit-tested without a DOM.
7
+ *
8
+ * Copyright (c) 2026, ArtGins.
9
+ * All Rights Reserved.
10
+ ***********************************************************************/
11
+
12
+ /* Bulma breakpoint order (low → high). Used by show_on parser. */
13
+ export const BULMA_BP_ORDER = ["mobile", "tablet", "desktop", "widescreen", "fullhd"];
14
+
15
+
16
+ /************************************************************
17
+ * Parse a `show_on` expression into the set of breakpoints
18
+ * at which the element should be visible.
19
+ *
20
+ * "" | "*" always visible
21
+ * "mobile" only at mobile
22
+ * "mobile|tablet" at mobile or tablet (OR)
23
+ * ">=desktop" from desktop up
24
+ * "<desktop" strictly below desktop
25
+ * "<=tablet" tablet and below
26
+ * ">mobile" strictly above mobile
27
+ *
28
+ * Returns { mobile:bool, tablet:bool, desktop:bool,
29
+ * widescreen:bool, fullhd:bool }.
30
+ ************************************************************/
31
+ export function breakpoints_from_expr(expr)
32
+ {
33
+ let out = { mobile:false, tablet:false, desktop:false, widescreen:false, fullhd:false };
34
+ expr = String(expr == null ? "" : expr).trim();
35
+ if(expr === "*" || expr === "") {
36
+ for(let bp of BULMA_BP_ORDER) {
37
+ out[bp] = true;
38
+ }
39
+ return out;
40
+ }
41
+ let parts = expr.split("|").map(s => s.trim()).filter(s => s.length);
42
+ for(let p of parts) {
43
+ let m;
44
+ if((m = /^>=(\w+)$/.exec(p))) {
45
+ let idx = BULMA_BP_ORDER.indexOf(m[1]);
46
+ if(idx >= 0) {
47
+ for(let i=idx;i<BULMA_BP_ORDER.length;i++) {
48
+ out[BULMA_BP_ORDER[i]] = true;
49
+ }
50
+ }
51
+ } else if((m = /^<(\w+)$/.exec(p))) {
52
+ let idx = BULMA_BP_ORDER.indexOf(m[1]);
53
+ if(idx > 0) {
54
+ for(let i=0;i<idx;i++) {
55
+ out[BULMA_BP_ORDER[i]] = true;
56
+ }
57
+ }
58
+ } else if((m = /^<=(\w+)$/.exec(p))) {
59
+ let idx = BULMA_BP_ORDER.indexOf(m[1]);
60
+ if(idx >= 0) {
61
+ for(let i=0;i<=idx;i++) {
62
+ out[BULMA_BP_ORDER[i]] = true;
63
+ }
64
+ }
65
+ } else if((m = /^>(\w+)$/.exec(p))) {
66
+ let idx = BULMA_BP_ORDER.indexOf(m[1]);
67
+ if(idx >= 0) {
68
+ for(let i=idx+1;i<BULMA_BP_ORDER.length;i++) {
69
+ out[BULMA_BP_ORDER[i]] = true;
70
+ }
71
+ }
72
+ } else if(Object.prototype.hasOwnProperty.call(out, p)) {
73
+ out[p] = true;
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+
80
+ /* Map each Bulma breakpoint to the helper class that hides it. */
81
+ export function bulma_hidden_class(bp)
82
+ {
83
+ switch(bp) {
84
+ case "mobile": return "yui-hidden-mobile"; /* <769 */
85
+ case "tablet": return "yui-hidden-tablet-only"; /* 769-1023 */
86
+ case "desktop": return "yui-hidden-desktop-only"; /* 1024-1215 */
87
+ case "widescreen": return "yui-hidden-widescreen-only"; /* 1216-1407 */
88
+ case "fullhd": return "yui-hidden-fullhd"; /* ≥1408 */
89
+ }
90
+ return "";
91
+ }
@@ -0,0 +1,86 @@
1
+ /***********************************************************************
2
+ * shell_show_on.test.mjs
3
+ *
4
+ * Unit tests for the pure `show_on` expression parser used by
5
+ * C_YUI_SHELL. Run with:
6
+ * node --test tests/
7
+ ***********************************************************************/
8
+ import { test, expect } from "vitest";
9
+ import {
10
+ BULMA_BP_ORDER,
11
+ breakpoints_from_expr,
12
+ bulma_hidden_class,
13
+ } from "./shell_show_on.js";
14
+
15
+
16
+ /* Helpers */
17
+ const VISIBLE = { mobile:true, tablet:true, desktop:true, widescreen:true, fullhd:true };
18
+ const INVISIBLE = { mobile:false, tablet:false, desktop:false, widescreen:false, fullhd:false };
19
+
20
+
21
+ test("empty expression ⇒ visible at every breakpoint", () => {
22
+ expect(breakpoints_from_expr("")).toEqual(VISIBLE);
23
+ expect(breakpoints_from_expr(" ")).toEqual(VISIBLE);
24
+ expect(breakpoints_from_expr("*")).toEqual(VISIBLE);
25
+ expect(breakpoints_from_expr(null)).toEqual(VISIBLE);
26
+ expect(breakpoints_from_expr(undefined)).toEqual(VISIBLE);
27
+ });
28
+
29
+ test("single breakpoint ⇒ only that one", () => {
30
+ expect(breakpoints_from_expr("mobile")).toEqual({ ...INVISIBLE, mobile: true });
31
+ expect(breakpoints_from_expr("desktop")).toEqual({ ...INVISIBLE, desktop: true });
32
+ expect(breakpoints_from_expr("fullhd")).toEqual({ ...INVISIBLE, fullhd: true });
33
+ });
34
+
35
+ test("OR combination with '|'", () => {
36
+ expect(breakpoints_from_expr("mobile|tablet")).toEqual({ ...INVISIBLE, mobile: true, tablet: true });
37
+ expect(breakpoints_from_expr("tablet | desktop | widescreen")).toEqual({ ...INVISIBLE, tablet: true, desktop: true, widescreen: true });
38
+ });
39
+
40
+ test(">=desktop means desktop and everything wider", () => {
41
+ expect(breakpoints_from_expr(">=desktop")).toEqual({ ...INVISIBLE, desktop: true, widescreen: true, fullhd: true });
42
+ });
43
+
44
+ test(">mobile is strictly wider than mobile", () => {
45
+ expect(breakpoints_from_expr(">mobile")).toEqual({ ...INVISIBLE, tablet: true, desktop: true, widescreen: true, fullhd: true });
46
+ });
47
+
48
+ test("<desktop is strictly narrower than desktop", () => {
49
+ expect(breakpoints_from_expr("<desktop")).toEqual({ ...INVISIBLE, mobile: true, tablet: true });
50
+ });
51
+
52
+ test("<=tablet includes tablet and everything narrower", () => {
53
+ expect(breakpoints_from_expr("<=tablet")).toEqual({ ...INVISIBLE, mobile: true, tablet: true });
54
+ });
55
+
56
+ test("< against the smallest breakpoint yields nothing", () => {
57
+ /* Historical guard: '<mobile' should be empty, not the whole set. */
58
+ expect(breakpoints_from_expr("<mobile")).toEqual(INVISIBLE);
59
+ });
60
+
61
+ test("> against the widest breakpoint yields nothing", () => {
62
+ expect(breakpoints_from_expr(">fullhd")).toEqual(INVISIBLE);
63
+ });
64
+
65
+ test("unknown tokens are ignored (no throw)", () => {
66
+ expect(breakpoints_from_expr("phablet")).toEqual(INVISIBLE);
67
+ /* Valid ones in the mix still apply. */
68
+ expect(breakpoints_from_expr("phablet|mobile")).toEqual({ ...INVISIBLE, mobile: true });
69
+ });
70
+
71
+ test("OR combining ranges is the union of ranges", () => {
72
+ expect(breakpoints_from_expr(">=desktop | mobile")).toEqual({ ...INVISIBLE, mobile: true, desktop: true, widescreen: true, fullhd: true });
73
+ });
74
+
75
+ test("BULMA_BP_ORDER is low→high and complete", () => {
76
+ expect(BULMA_BP_ORDER).toEqual(["mobile", "tablet", "desktop", "widescreen", "fullhd"]);
77
+ });
78
+
79
+ test("bulma_hidden_class maps each breakpoint correctly", () => {
80
+ expect(bulma_hidden_class("mobile")).toBe("yui-hidden-mobile");
81
+ expect(bulma_hidden_class("tablet")).toBe("yui-hidden-tablet-only");
82
+ expect(bulma_hidden_class("desktop")).toBe("yui-hidden-desktop-only");
83
+ expect(bulma_hidden_class("widescreen")).toBe("yui-hidden-widescreen-only");
84
+ expect(bulma_hidden_class("fullhd")).toBe("yui-hidden-fullhd");
85
+ expect(bulma_hidden_class("nonsense")).toBe("");
86
+ });