@yuneta/gobj-ui 5.8.2 → 5.10.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/package.json +1 -1
- package/src/yui_clipboard.js +209 -0
- package/src/yui_clipboard.test.js +184 -0
package/package.json
CHANGED
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
/***************************************************************
|
|
144
|
+
* Show a button as "done": swap its icon for a check and
|
|
145
|
+
* its label for the given text. Idempotent — calling it
|
|
146
|
+
* twice does not lose the original.
|
|
147
|
+
*
|
|
148
|
+
* The TIMING is not here on purpose. A view arms its own
|
|
149
|
+
* C_TIMER and calls yui_button_unmark() from the timeout
|
|
150
|
+
* action, so going back is an FSM transition and shows up
|
|
151
|
+
* in the machine trace, instead of a setTimeout nobody can
|
|
152
|
+
* see. gobj-ui owns the look; the view owns the when.
|
|
153
|
+
*
|
|
154
|
+
* Expects the Bulma button shape both views use:
|
|
155
|
+
* <button><span class="icon"><span class="yi-…">
|
|
156
|
+
* <span>label</span></button>
|
|
157
|
+
* The label is optional (icon-only buttons on a phone).
|
|
158
|
+
***************************************************************/
|
|
159
|
+
function yui_button_mark_done($button, label)
|
|
160
|
+
{
|
|
161
|
+
if(!$button) {
|
|
162
|
+
log_error("yui_button_mark_done(): no button");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let $icon = $button.querySelector("span.icon > span");
|
|
167
|
+
let $label = $button.querySelector("span.icon ~ span");
|
|
168
|
+
|
|
169
|
+
if($icon && $button.dataset.yuiIconWas === undefined) {
|
|
170
|
+
$button.dataset.yuiIconWas = $icon.className;
|
|
171
|
+
$icon.className = "yi-check";
|
|
172
|
+
}
|
|
173
|
+
if($label && $button.dataset.yuiLabelWas === undefined) {
|
|
174
|
+
$button.dataset.yuiLabelWas = $label.textContent;
|
|
175
|
+
$label.textContent = label;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/***************************************************************
|
|
180
|
+
* Put the button back the way it was. Safe to call when
|
|
181
|
+
* it was never marked.
|
|
182
|
+
***************************************************************/
|
|
183
|
+
function yui_button_unmark($button)
|
|
184
|
+
{
|
|
185
|
+
if(!$button) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let $icon = $button.querySelector("span.icon > span");
|
|
190
|
+
let $label = $button.querySelector("span.icon ~ span");
|
|
191
|
+
|
|
192
|
+
if($icon && $button.dataset.yuiIconWas !== undefined) {
|
|
193
|
+
$icon.className = $button.dataset.yuiIconWas;
|
|
194
|
+
delete $button.dataset.yuiIconWas;
|
|
195
|
+
}
|
|
196
|
+
if($label && $button.dataset.yuiLabelWas !== undefined) {
|
|
197
|
+
$label.textContent = $button.dataset.yuiLabelWas;
|
|
198
|
+
delete $button.dataset.yuiLabelWas;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export {
|
|
203
|
+
yui_copy_text,
|
|
204
|
+
yui_copy_json,
|
|
205
|
+
yui_table_rows,
|
|
206
|
+
yui_copy_table_json,
|
|
207
|
+
yui_button_mark_done,
|
|
208
|
+
yui_button_unmark,
|
|
209
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
yui_button_mark_done,
|
|
14
|
+
yui_button_unmark,
|
|
15
|
+
} from "./yui_clipboard.js";
|
|
16
|
+
|
|
17
|
+
let written;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
written = [];
|
|
21
|
+
/* A clipboard that accepts everything. */
|
|
22
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
23
|
+
value: {
|
|
24
|
+
writeText: (text) => {
|
|
25
|
+
written.push(text);
|
|
26
|
+
return Promise.resolve();
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
configurable: true,
|
|
30
|
+
writable: true
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.restoreAllMocks();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/* A Tabulator stand-in: only the two methods the helper calls. */
|
|
39
|
+
function fake_table(active, selected)
|
|
40
|
+
{
|
|
41
|
+
return {
|
|
42
|
+
getData: () => active,
|
|
43
|
+
getSelectedData: () => selected || []
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/*============================================================
|
|
48
|
+
* yui_copy_text
|
|
49
|
+
*============================================================*/
|
|
50
|
+
test("copy_text: writes the text and resolves true", async () => {
|
|
51
|
+
await expect(yui_copy_text("hello")).resolves.toBe(true);
|
|
52
|
+
expect(written).toEqual(["hello"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("copy_text: a non-string is refused, nothing is written", async () => {
|
|
56
|
+
await expect(yui_copy_text({a: 1})).resolves.toBe(false);
|
|
57
|
+
expect(written).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/*============================================================
|
|
61
|
+
* yui_copy_json
|
|
62
|
+
*============================================================*/
|
|
63
|
+
test("copy_json: indents FOUR spaces", async () => {
|
|
64
|
+
await yui_copy_json({a: 1});
|
|
65
|
+
expect(written[0]).toBe("{\n \"a\": 1\n}");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("copy_json: a cycle is reported, not thrown", async () => {
|
|
69
|
+
let cyclic = {};
|
|
70
|
+
cyclic.self = cyclic;
|
|
71
|
+
await expect(yui_copy_json(cyclic)).resolves.toBe(false);
|
|
72
|
+
expect(written).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("copy_json: an undefined value is refused", async () => {
|
|
76
|
+
await expect(yui_copy_json(undefined)).resolves.toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/*============================================================
|
|
80
|
+
* yui_table_rows
|
|
81
|
+
*============================================================*/
|
|
82
|
+
test("table_rows: with no selection, the filtered rows", () => {
|
|
83
|
+
let rows = [{id: 1}, {id: 2}];
|
|
84
|
+
expect(yui_table_rows(fake_table(rows))).toEqual(rows);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("table_rows: a selection WINS over the filtered rows", () => {
|
|
88
|
+
let active = [{id: 1}, {id: 2}, {id: 3}];
|
|
89
|
+
let selected = [{id: 2}];
|
|
90
|
+
expect(yui_table_rows(fake_table(active, selected))).toEqual(selected);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("table_rows: no table is an empty array, not a throw", () => {
|
|
94
|
+
expect(yui_table_rows(null)).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/*============================================================
|
|
98
|
+
* yui_copy_table_json
|
|
99
|
+
*============================================================*/
|
|
100
|
+
test("copy_table_json: resolves to the number of records copied", async () => {
|
|
101
|
+
let rows = [{id: 1}, {id: 2}, {id: 3}];
|
|
102
|
+
await expect(yui_copy_table_json(fake_table(rows))).resolves.toBe(3);
|
|
103
|
+
expect(JSON.parse(written[0])).toEqual(rows);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("copy_table_json: an empty table copies nothing and reports 0", async () => {
|
|
107
|
+
await expect(yui_copy_table_json(fake_table([]))).resolves.toBe(0);
|
|
108
|
+
expect(written).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("copy_table_json: counts the SELECTION when there is one", async () => {
|
|
112
|
+
let active = [{id: 1}, {id: 2}, {id: 3}];
|
|
113
|
+
await expect(yui_copy_table_json(fake_table(active, [{id: 2}]))).resolves.toBe(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/*============================================================
|
|
117
|
+
* yui_button_mark_done / yui_button_unmark
|
|
118
|
+
*
|
|
119
|
+
* Doubles instead of jsdom: the library has no DOM test
|
|
120
|
+
* environment and does not need one for this. What is worth
|
|
121
|
+
* testing here is the save/restore bookkeeping; that the
|
|
122
|
+
* selectors match the real markup is the app's job to prove.
|
|
123
|
+
*============================================================*/
|
|
124
|
+
function fake_button(with_label)
|
|
125
|
+
{
|
|
126
|
+
let $icon = {className: "yi-copy"};
|
|
127
|
+
let $label = with_label? {textContent: "Copy JSON"} : null;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
$icon: $icon,
|
|
131
|
+
$label: $label,
|
|
132
|
+
dataset: {},
|
|
133
|
+
querySelector: function(sel) {
|
|
134
|
+
if(sel === "span.icon > span") {
|
|
135
|
+
return $icon;
|
|
136
|
+
}
|
|
137
|
+
return $label;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
test("mark_done: swaps the glyph for a check and the label", () => {
|
|
143
|
+
let $b = fake_button(true);
|
|
144
|
+
yui_button_mark_done($b, "Copied");
|
|
145
|
+
expect($b.$icon.className).toBe("yi-check");
|
|
146
|
+
expect($b.$label.textContent).toBe("Copied");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("unmark: puts the original glyph and label back", () => {
|
|
150
|
+
let $b = fake_button(true);
|
|
151
|
+
yui_button_mark_done($b, "Copied");
|
|
152
|
+
yui_button_unmark($b);
|
|
153
|
+
expect($b.$icon.className).toBe("yi-copy");
|
|
154
|
+
expect($b.$label.textContent).toBe("Copy JSON");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("mark_done twice does NOT lose the original", () => {
|
|
158
|
+
let $b = fake_button(true);
|
|
159
|
+
yui_button_mark_done($b, "Copied");
|
|
160
|
+
yui_button_mark_done($b, "Copied again");
|
|
161
|
+
yui_button_unmark($b);
|
|
162
|
+
expect($b.$icon.className).toBe("yi-copy");
|
|
163
|
+
expect($b.$label.textContent).toBe("Copy JSON");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("an icon-only button (no label) round-trips too", () => {
|
|
167
|
+
let $b = fake_button(false);
|
|
168
|
+
yui_button_mark_done($b, "Copied");
|
|
169
|
+
expect($b.$icon.className).toBe("yi-check");
|
|
170
|
+
yui_button_unmark($b);
|
|
171
|
+
expect($b.$icon.className).toBe("yi-copy");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("unmark on a button that was never marked is a no-op", () => {
|
|
175
|
+
let $b = fake_button(true);
|
|
176
|
+
yui_button_unmark($b);
|
|
177
|
+
expect($b.$icon.className).toBe("yi-copy");
|
|
178
|
+
expect($b.$label.textContent).toBe("Copy JSON");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("mark_done without a button is reported, not thrown", () => {
|
|
182
|
+
expect(() => yui_button_mark_done(null, "Copied")).not.toThrow();
|
|
183
|
+
expect(() => yui_button_unmark(null)).not.toThrow();
|
|
184
|
+
});
|