@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.
- package/README.md +40 -363
- package/dist/gobj-ui.cjs.js +11154 -5506
- package/dist/gobj-ui.es.js +11131 -5508
- package/index.js +41 -14
- package/package.json +11 -9
- package/src/c_g6_nodes_tree.js +6 -1
- package/src/c_yui_form.js +1 -1
- package/src/c_yui_gobj_tree_js.js +1 -1
- package/src/c_yui_json_graph.js +1 -1
- package/src/c_yui_main.js +3 -3
- package/src/c_yui_map.js +6 -1
- package/src/c_yui_nav.js +881 -0
- package/src/c_yui_pager.js +545 -0
- package/src/c_yui_routing.css +1 -1
- package/src/c_yui_routing.js +1 -1
- package/src/c_yui_shell.css +571 -0
- package/src/c_yui_shell.js +2474 -0
- package/src/c_yui_tabs.js +1 -1
- package/src/c_yui_treedb_graph.js +35 -9
- package/src/c_yui_treedb_topic_with_form.js +1 -1
- package/src/c_yui_treedb_topics.js +36 -8
- package/src/c_yui_uplot.js +1 -1
- package/src/c_yui_window.js +242 -21
- package/src/c_yui_window_manager.js +602 -0
- package/src/c_yui_wizard.js +612 -0
- package/src/pager_helpers.js +138 -0
- package/src/pager_helpers.test.js +140 -0
- package/src/route_resolver.js +53 -0
- package/src/route_resolver.test.js +82 -0
- package/src/shell_focus_trap.js +123 -0
- package/src/shell_focus_trap.test.js +299 -0
- package/src/shell_modals.js +445 -0
- package/src/shell_show_on.js +91 -0
- package/src/shell_show_on.test.js +86 -0
- package/src/shell_toolbar_helpers.js +221 -0
- package/src/shell_toolbar_helpers.test.js +207 -0
- package/src/tabulator.css +53 -0
- package/src/wizard_helpers.js +117 -0
- package/src/wizard_helpers.test.js +122 -0
- package/src/yui_dev.js +1257 -362
- package/src/yui_icons.css +5 -0
- package/src/yui_inputs.css +32 -0
- package/src/yui_inputs.js +71 -0
- package/vite-plugin-yuneta-html.js +2 -2
- package/skeleton/config.json +0 -20
- 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
|
+
});
|