@yuneta/gobj-ui 5.8.1 → 5.9.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 +37 -0
- package/dist/gobj-ui.cjs.js +10 -0
- package/dist/gobj-ui.es.js +10 -0
- package/package.json +1 -1
- package/src/c_yui_shell.js +22 -0
- package/src/c_yui_treedb_topic_with_form.js +5 -0
- package/src/yui_clipboard.js +143 -0
- package/src/yui_clipboard.test.js +112 -0
package/README.md
CHANGED
|
@@ -520,6 +520,43 @@ keeps working afterwards. With no guard a modal closes exactly as it always
|
|
|
520
520
|
did, and the returned `close()` always closes **unconditionally** — the veto is
|
|
521
521
|
for the user's dismiss, not for the code's.
|
|
522
522
|
|
|
523
|
+
### Confirmations — and the red one, `yui_shell_confirm_danger`
|
|
524
|
+
|
|
525
|
+
`yui_shell_confirm_yesno(shell, message, opts)` asks a question and resolves to
|
|
526
|
+
a boolean. Its yes is `is-link`, the right colour for *"do you want to
|
|
527
|
+
continue"*.
|
|
528
|
+
|
|
529
|
+
**`yui_shell_confirm_danger(shell, message, opts)`** is the same call with a
|
|
530
|
+
**red** confirm button and the error icon (`type: "danger"` by default). Use it
|
|
531
|
+
whenever the yes destroys something — deleting an account, dropping a record.
|
|
532
|
+
The two must not look alike: the destructive one is precisely the one that must
|
|
533
|
+
not be clicked by reflex.
|
|
534
|
+
|
|
535
|
+
In both, the **safe answer is the last button**, so Escape, the backdrop and
|
|
536
|
+
the X all resolve to it.
|
|
537
|
+
|
|
538
|
+
```js
|
|
539
|
+
if(await yui_shell_confirm_danger(shell, t("delete account detail"))) {
|
|
540
|
+
/* only here has the red button been pressed */
|
|
541
|
+
}
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
### `C_YUI_FORM` — choosing the bottom toolbar
|
|
545
|
+
|
|
546
|
+
By default the form shows **save + undo + clear + copy + paste**. The
|
|
547
|
+
`toolbar` attr takes the button names you want, in the order you want them:
|
|
548
|
+
|
|
549
|
+
```js
|
|
550
|
+
gobj_create("form", "C_YUI_FORM", {toolbar: ["save"]}, parent); // one action
|
|
551
|
+
gobj_create("form", "C_YUI_FORM", {toolbar: []}, parent); // no toolbar
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
Save/undo/clear stay on the left of the bar and copy/paste on the right — the
|
|
555
|
+
split the layout has always drawn — so dropping a whole group leaves no hole in
|
|
556
|
+
the middle, and **a toolbar left with a single group is centred**. An unknown
|
|
557
|
+
name is reported, not silently dropped: a typo would otherwise remove the save
|
|
558
|
+
button with no trace of why.
|
|
559
|
+
|
|
523
560
|
## Conventions
|
|
524
561
|
|
|
525
562
|
### i18n: a string must be able to CHANGE language, not just be translated once
|
package/dist/gobj-ui.cjs.js
CHANGED
|
@@ -2003,6 +2003,16 @@ function open_toolbar_dropdown(gobj, item, action, $trigger) {
|
|
|
2003
2003
|
} else style_parts.push(`left:${Math.max(0, Math.round(rect.left))}px`);
|
|
2004
2004
|
$panel.setAttribute("style", style_parts.join(";"));
|
|
2005
2005
|
priv.layers.popup.appendChild($panel);
|
|
2006
|
+
let margin = 8;
|
|
2007
|
+
let pw = $panel.offsetWidth;
|
|
2008
|
+
let pos = $panel.getBoundingClientRect();
|
|
2009
|
+
let left = pos.left;
|
|
2010
|
+
if (left + pw > window.innerWidth - margin) left = window.innerWidth - pw - margin;
|
|
2011
|
+
if (left < margin) left = margin;
|
|
2012
|
+
if (Math.round(left) !== Math.round(pos.left)) {
|
|
2013
|
+
$panel.style.right = "auto";
|
|
2014
|
+
$panel.style.left = `${Math.round(left)}px`;
|
|
2015
|
+
}
|
|
2006
2016
|
yui_shell_translate(gobj, $panel);
|
|
2007
2017
|
let backdrop = (ev) => {
|
|
2008
2018
|
if ($panel.contains(ev.target)) return;
|
package/dist/gobj-ui.es.js
CHANGED
|
@@ -1998,6 +1998,16 @@ function open_toolbar_dropdown(gobj, item, action, $trigger) {
|
|
|
1998
1998
|
} else style_parts.push(`left:${Math.max(0, Math.round(rect.left))}px`);
|
|
1999
1999
|
$panel.setAttribute("style", style_parts.join(";"));
|
|
2000
2000
|
priv.layers.popup.appendChild($panel);
|
|
2001
|
+
let margin = 8;
|
|
2002
|
+
let pw = $panel.offsetWidth;
|
|
2003
|
+
let pos = $panel.getBoundingClientRect();
|
|
2004
|
+
let left = pos.left;
|
|
2005
|
+
if (left + pw > window.innerWidth - margin) left = window.innerWidth - pw - margin;
|
|
2006
|
+
if (left < margin) left = margin;
|
|
2007
|
+
if (Math.round(left) !== Math.round(pos.left)) {
|
|
2008
|
+
$panel.style.right = "auto";
|
|
2009
|
+
$panel.style.left = `${Math.round(left)}px`;
|
|
2010
|
+
}
|
|
2001
2011
|
yui_shell_translate(gobj, $panel);
|
|
2002
2012
|
let backdrop = (ev) => {
|
|
2003
2013
|
if ($panel.contains(ev.target)) return;
|
package/package.json
CHANGED
package/src/c_yui_shell.js
CHANGED
|
@@ -2099,6 +2099,28 @@ function open_toolbar_dropdown(gobj, item, action, $trigger)
|
|
|
2099
2099
|
|
|
2100
2100
|
priv.layers.popup.appendChild($panel);
|
|
2101
2101
|
|
|
2102
|
+
/* Now that it HAS a width, keep it inside the viewport on both
|
|
2103
|
+
* edges. The anchoring above guards only the edge it aligns to,
|
|
2104
|
+
* so a right-aligned panel whose trigger sits near the LEFT of the
|
|
2105
|
+
* bar hangs off the left side — which is what every navbar-end
|
|
2106
|
+
* trigger does under dir="rtl": in Arabic the language menu opened
|
|
2107
|
+
* at left:-60px with its first characters unreachable. Measure,
|
|
2108
|
+
* clamp, and pin with `left` so there is one source of truth. */
|
|
2109
|
+
let margin = 8;
|
|
2110
|
+
let pw = $panel.offsetWidth;
|
|
2111
|
+
let pos = $panel.getBoundingClientRect();
|
|
2112
|
+
let left = pos.left;
|
|
2113
|
+
if(left + pw > window.innerWidth - margin) {
|
|
2114
|
+
left = window.innerWidth - pw - margin;
|
|
2115
|
+
}
|
|
2116
|
+
if(left < margin) {
|
|
2117
|
+
left = margin;
|
|
2118
|
+
}
|
|
2119
|
+
if(Math.round(left) !== Math.round(pos.left)) {
|
|
2120
|
+
$panel.style.right = "auto";
|
|
2121
|
+
$panel.style.left = `${Math.round(left)}px`;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2102
2124
|
/* Translate the lazily-built panel (see the note above). */
|
|
2103
2125
|
yui_shell_translate(gobj, $panel);
|
|
2104
2126
|
|
|
@@ -345,6 +345,11 @@ function build_ui(gobj)
|
|
|
345
345
|
$table_toolbar = createElement2(
|
|
346
346
|
['div', {id: `${toolbar_id}`, class: 'TREEDB_TABLE_ACTIONS buttons mb-0'}]
|
|
347
347
|
);
|
|
348
|
+
/* Edit is a MODE TOGGLE, not one more action: it arms/disarms
|
|
349
|
+
* the buttons that modify the table (new/delete/copy/paste),
|
|
350
|
+
* which is why it comes first and why delete sits among them
|
|
351
|
+
* instead of being pushed away from the harmless ones. Do not
|
|
352
|
+
* reorder this group as if they were peer actions. */
|
|
348
353
|
let $edit_button = createElement2(
|
|
349
354
|
['button', {id: ``, class: 'button button-edit-record mr-1'}, [
|
|
350
355
|
['i', {class: 'yi-pen'}],
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* yui_clipboard.js
|
|
3
|
+
*
|
|
4
|
+
* Put things on the clipboard, from any view.
|
|
5
|
+
*
|
|
6
|
+
* Four views had grown their own copy code, and they had
|
|
7
|
+
* drifted: two of them wrote unindented JSON and reported
|
|
8
|
+
* nothing when the write failed. This is that code, once.
|
|
9
|
+
*
|
|
10
|
+
* The Clipboard API is absent in an insecure context (plain
|
|
11
|
+
* http, some embedded webviews) and REJECTS when the document
|
|
12
|
+
* does not have focus — which happens whenever the click that
|
|
13
|
+
* asked for the copy also moved focus. Neither is something
|
|
14
|
+
* the caller can act on, so a hidden-textarea copy covers both
|
|
15
|
+
* rather than failing.
|
|
16
|
+
*
|
|
17
|
+
* Copyright (c) 2026, ArtGins.
|
|
18
|
+
* All Rights Reserved.
|
|
19
|
+
***********************************************************************/
|
|
20
|
+
import {log_error} from "@yuneta/gobj-js";
|
|
21
|
+
|
|
22
|
+
/***************************************************************
|
|
23
|
+
* Last-resort copy: a hidden textarea + execCommand.
|
|
24
|
+
* Deprecated in the spec, still the only route in an
|
|
25
|
+
* insecure context. Returns TRUE when it worked.
|
|
26
|
+
***************************************************************/
|
|
27
|
+
function fallback_copy(text)
|
|
28
|
+
{
|
|
29
|
+
let ok = false;
|
|
30
|
+
let $ta = document.createElement("textarea");
|
|
31
|
+
|
|
32
|
+
$ta.value = text;
|
|
33
|
+
$ta.style.position = "fixed";
|
|
34
|
+
$ta.style.left = "-9999px";
|
|
35
|
+
document.body.appendChild($ta);
|
|
36
|
+
$ta.select();
|
|
37
|
+
try {
|
|
38
|
+
ok = document.execCommand("copy");
|
|
39
|
+
} catch(e) {
|
|
40
|
+
log_error(`yui_clipboard: execCommand copy failed: ${e}`);
|
|
41
|
+
}
|
|
42
|
+
document.body.removeChild($ta);
|
|
43
|
+
|
|
44
|
+
if(!ok) {
|
|
45
|
+
log_error("yui_clipboard: could not reach the clipboard");
|
|
46
|
+
}
|
|
47
|
+
return ok;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/***************************************************************
|
|
51
|
+
* Copy text.
|
|
52
|
+
* Resolves TRUE when the text reached the clipboard.
|
|
53
|
+
***************************************************************/
|
|
54
|
+
function yui_copy_text(text)
|
|
55
|
+
{
|
|
56
|
+
if(typeof text !== "string") {
|
|
57
|
+
log_error("yui_copy_text(): text must be a string");
|
|
58
|
+
return Promise.resolve(false);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if(navigator.clipboard && navigator.clipboard.writeText) {
|
|
62
|
+
return navigator.clipboard.writeText(text).then(
|
|
63
|
+
function() {
|
|
64
|
+
return true;
|
|
65
|
+
},
|
|
66
|
+
function() {
|
|
67
|
+
return fallback_copy(text);
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return Promise.resolve(fallback_copy(text));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/***************************************************************
|
|
76
|
+
* Copy a value as JSON, indented FOUR spaces — the same
|
|
77
|
+
* width the rest of the family uses to show structure.
|
|
78
|
+
* Resolves TRUE when it reached the clipboard.
|
|
79
|
+
***************************************************************/
|
|
80
|
+
function yui_copy_json(value)
|
|
81
|
+
{
|
|
82
|
+
let text;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
text = JSON.stringify(value, null, 4);
|
|
86
|
+
} catch(e) {
|
|
87
|
+
log_error(`yui_copy_json(): value is not serializable: ${e}`);
|
|
88
|
+
return Promise.resolve(false);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if(text === undefined) {
|
|
92
|
+
log_error("yui_copy_json(): value is not serializable");
|
|
93
|
+
return Promise.resolve(false);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return yui_copy_text(text);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/***************************************************************
|
|
100
|
+
* What the user is LOOKING AT, as an array of records:
|
|
101
|
+
* the selected rows when there is a selection, otherwise
|
|
102
|
+
* every row that passes the current filters, in the order
|
|
103
|
+
* shown.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately not the whole dataset: the point is to hand
|
|
106
|
+
* over what is on screen. A table with no selection and no
|
|
107
|
+
* filter therefore copies everything, which is what the
|
|
108
|
+
* screen shows too.
|
|
109
|
+
***************************************************************/
|
|
110
|
+
function yui_table_rows(tabulator)
|
|
111
|
+
{
|
|
112
|
+
if(!tabulator) {
|
|
113
|
+
log_error("yui_table_rows(): no tabulator");
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let selected = tabulator.getSelectedData();
|
|
118
|
+
if(selected && selected.length) {
|
|
119
|
+
return selected;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return tabulator.getData("active");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/***************************************************************
|
|
126
|
+
* Copy what the table shows, as JSON.
|
|
127
|
+
* Resolves to the NUMBER of records copied, 0 if none —
|
|
128
|
+
* so the caller can tell the user, or stay quiet.
|
|
129
|
+
***************************************************************/
|
|
130
|
+
function yui_copy_table_json(tabulator)
|
|
131
|
+
{
|
|
132
|
+
let rows = yui_table_rows(tabulator);
|
|
133
|
+
|
|
134
|
+
if(!rows.length) {
|
|
135
|
+
return Promise.resolve(0);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return yui_copy_json(rows).then(function(ok) {
|
|
139
|
+
return ok? rows.length : 0;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export {yui_copy_text, yui_copy_json, yui_table_rows, yui_copy_table_json};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* yui_clipboard.test.js
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for the shared clipboard helpers.
|
|
5
|
+
* Run with: npm test
|
|
6
|
+
***********************************************************************/
|
|
7
|
+
import { test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
yui_copy_text,
|
|
10
|
+
yui_copy_json,
|
|
11
|
+
yui_table_rows,
|
|
12
|
+
yui_copy_table_json,
|
|
13
|
+
} from "./yui_clipboard.js";
|
|
14
|
+
|
|
15
|
+
let written;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
written = [];
|
|
19
|
+
/* A clipboard that accepts everything. */
|
|
20
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
21
|
+
value: {
|
|
22
|
+
writeText: (text) => {
|
|
23
|
+
written.push(text);
|
|
24
|
+
return Promise.resolve();
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
configurable: true,
|
|
28
|
+
writable: true
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/* A Tabulator stand-in: only the two methods the helper calls. */
|
|
37
|
+
function fake_table(active, selected)
|
|
38
|
+
{
|
|
39
|
+
return {
|
|
40
|
+
getData: () => active,
|
|
41
|
+
getSelectedData: () => selected || []
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/*============================================================
|
|
46
|
+
* yui_copy_text
|
|
47
|
+
*============================================================*/
|
|
48
|
+
test("copy_text: writes the text and resolves true", async () => {
|
|
49
|
+
await expect(yui_copy_text("hello")).resolves.toBe(true);
|
|
50
|
+
expect(written).toEqual(["hello"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("copy_text: a non-string is refused, nothing is written", async () => {
|
|
54
|
+
await expect(yui_copy_text({a: 1})).resolves.toBe(false);
|
|
55
|
+
expect(written).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/*============================================================
|
|
59
|
+
* yui_copy_json
|
|
60
|
+
*============================================================*/
|
|
61
|
+
test("copy_json: indents FOUR spaces", async () => {
|
|
62
|
+
await yui_copy_json({a: 1});
|
|
63
|
+
expect(written[0]).toBe("{\n \"a\": 1\n}");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("copy_json: a cycle is reported, not thrown", async () => {
|
|
67
|
+
let cyclic = {};
|
|
68
|
+
cyclic.self = cyclic;
|
|
69
|
+
await expect(yui_copy_json(cyclic)).resolves.toBe(false);
|
|
70
|
+
expect(written).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("copy_json: an undefined value is refused", async () => {
|
|
74
|
+
await expect(yui_copy_json(undefined)).resolves.toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/*============================================================
|
|
78
|
+
* yui_table_rows
|
|
79
|
+
*============================================================*/
|
|
80
|
+
test("table_rows: with no selection, the filtered rows", () => {
|
|
81
|
+
let rows = [{id: 1}, {id: 2}];
|
|
82
|
+
expect(yui_table_rows(fake_table(rows))).toEqual(rows);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("table_rows: a selection WINS over the filtered rows", () => {
|
|
86
|
+
let active = [{id: 1}, {id: 2}, {id: 3}];
|
|
87
|
+
let selected = [{id: 2}];
|
|
88
|
+
expect(yui_table_rows(fake_table(active, selected))).toEqual(selected);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("table_rows: no table is an empty array, not a throw", () => {
|
|
92
|
+
expect(yui_table_rows(null)).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/*============================================================
|
|
96
|
+
* yui_copy_table_json
|
|
97
|
+
*============================================================*/
|
|
98
|
+
test("copy_table_json: resolves to the number of records copied", async () => {
|
|
99
|
+
let rows = [{id: 1}, {id: 2}, {id: 3}];
|
|
100
|
+
await expect(yui_copy_table_json(fake_table(rows))).resolves.toBe(3);
|
|
101
|
+
expect(JSON.parse(written[0])).toEqual(rows);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("copy_table_json: an empty table copies nothing and reports 0", async () => {
|
|
105
|
+
await expect(yui_copy_table_json(fake_table([]))).resolves.toBe(0);
|
|
106
|
+
expect(written).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("copy_table_json: counts the SELECTION when there is one", async () => {
|
|
110
|
+
let active = [{id: 1}, {id: 2}, {id: 3}];
|
|
111
|
+
await expect(yui_copy_table_json(fake_table(active, [{id: 2}]))).resolves.toBe(1);
|
|
112
|
+
});
|