@yuneta/gobj-ui 7.10.5 → 7.11.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 +61 -0
- package/package.json +1 -1
- package/src/yui_table_select.js +231 -0
- package/src/yui_table_select.test.js +54 -0
package/README.md
CHANGED
|
@@ -970,6 +970,67 @@ the middle, and **a toolbar left with a single group is centred**. An unknown
|
|
|
970
970
|
name is reported, not silently dropped: a typo would otherwise remove the save
|
|
971
971
|
button with no trace of why.
|
|
972
972
|
|
|
973
|
+
### Selecting rows in any table — `yui_table_select.js`
|
|
974
|
+
|
|
975
|
+
Deleting twenty rows one confirmation at a time is not a workflow. Any view
|
|
976
|
+
whose table can remove (or export, or act on) a row eventually needs to do it
|
|
977
|
+
to several at once, so the checkbox column, the settings behind it and the bar
|
|
978
|
+
that appears while something is ticked live here once:
|
|
979
|
+
|
|
980
|
+
```js
|
|
981
|
+
import {
|
|
982
|
+
yui_selection_column,
|
|
983
|
+
yui_selection_settings,
|
|
984
|
+
yui_selection_bar,
|
|
985
|
+
yui_wire_selection,
|
|
986
|
+
yui_selected_rows,
|
|
987
|
+
yui_clear_selection,
|
|
988
|
+
} from "@yuneta/gobj-ui/src/yui_table_select.js";
|
|
989
|
+
|
|
990
|
+
/* 1. the column, FIRST in the list */
|
|
991
|
+
let columns = [yui_selection_column(), ...my_columns];
|
|
992
|
+
|
|
993
|
+
/* 2. the settings it needs */
|
|
994
|
+
let table = new Tabulator($div, {...yui_selection_settings(), columns: columns, ...});
|
|
995
|
+
|
|
996
|
+
/* 3. the bar. Every button's job is to SEND AN EVENT */
|
|
997
|
+
priv.bar = yui_selection_bar(t, {
|
|
998
|
+
name: "CONNECTIONS",
|
|
999
|
+
actions: [{
|
|
1000
|
+
label: "remove selected", /* an i18n KEY */
|
|
1001
|
+
icon: "yi-trash",
|
|
1002
|
+
class: "is-danger",
|
|
1003
|
+
on_click: () => gobj_send_event(gobj, "EV_REMOVE_SELECTED", {}, gobj)
|
|
1004
|
+
}],
|
|
1005
|
+
on_clear: () => gobj_send_event(gobj, "EV_CLEAR_SELECTION", {}, gobj)
|
|
1006
|
+
});
|
|
1007
|
+
$container.appendChild(priv.bar.$el);
|
|
1008
|
+
|
|
1009
|
+
/* 4. the table tells the bar how many are ticked */
|
|
1010
|
+
yui_wire_selection(table, (n) => gobj_send_event(gobj, "EV_SELECTION_CHANGED",
|
|
1011
|
+
{count: n}, gobj));
|
|
1012
|
+
```
|
|
1013
|
+
|
|
1014
|
+
Two decisions are baked in, both learned in the treedb topic table:
|
|
1015
|
+
|
|
1016
|
+
- **Selection is driven only by the checkbox** (`selectableRows: "highlight"`),
|
|
1017
|
+
never by clicking the row. A row is full of things to click — an editor, an
|
|
1018
|
+
icon, a nested table — and click-to-select ticks a row every time you reach
|
|
1019
|
+
for one of them.
|
|
1020
|
+
- **The header checkbox covers the ACTIVE rows**, the ones the filters leave on
|
|
1021
|
+
screen (`titleFormatterParams: {rowRange: "active"}`). "Select all" over rows
|
|
1022
|
+
nobody can see is how a filtered delete takes the whole topic with it.
|
|
1023
|
+
|
|
1024
|
+
The bar takes its words from the HOST's `t` (this library translates through
|
|
1025
|
+
the app's i18next): the app must define **`"{{n}} selected"`** and
|
|
1026
|
+
**`"clear selection"`**, and each action's own key. The count is composed at
|
|
1027
|
+
render time, so `refresh_language()` cannot reach it — call `bar.refresh()`
|
|
1028
|
+
from the view's `EV_LANGUAGE_CHANGED` action.
|
|
1029
|
+
|
|
1030
|
+
`yui_selected_rows(table)` and `yui_clear_selection(table)` answer safely on a
|
|
1031
|
+
table that is not built yet or is already gone. Clear the selection after
|
|
1032
|
+
acting on it: the rows it names are no longer there.
|
|
1033
|
+
|
|
973
1034
|
## Conventions
|
|
974
1035
|
|
|
975
1036
|
### i18n: a string must be able to CHANGE language, not just be translated once
|
package/package.json
CHANGED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* yui_table_select.js
|
|
3
|
+
*
|
|
4
|
+
* SELECT ROWS IN A TABLE, and act on the lot.
|
|
5
|
+
*
|
|
6
|
+
* Deleting twenty rows one confirmation at a time is not a
|
|
7
|
+
* workflow, it is a punishment, and every table that lets you
|
|
8
|
+
* remove a row will eventually be asked to remove twenty. This
|
|
9
|
+
* is that facility, once: the checkbox column, the settings
|
|
10
|
+
* that make selection behave, and the bar that appears while
|
|
11
|
+
* something is selected and carries what can be done to it.
|
|
12
|
+
*
|
|
13
|
+
* Two decisions are baked in, both learned in the treedb
|
|
14
|
+
* topic table:
|
|
15
|
+
*
|
|
16
|
+
* - Selection is driven ONLY by the checkbox, never by
|
|
17
|
+
* clicking the row (`selectableRows: "highlight"`). A table
|
|
18
|
+
* row is full of things to click -- an editor, an icon, a
|
|
19
|
+
* nested table -- and click-to-select ticks a row every time
|
|
20
|
+
* you reach for one of them.
|
|
21
|
+
*
|
|
22
|
+
* - The header checkbox covers the ACTIVE rows, the ones the
|
|
23
|
+
* filters leave on screen. "Select all" over rows nobody can
|
|
24
|
+
* see is how a filtered delete takes the whole topic with
|
|
25
|
+
* it.
|
|
26
|
+
*
|
|
27
|
+
* The bar takes its words from the HOST's `t`: this library
|
|
28
|
+
* translates through the app's i18next, and the app owns the
|
|
29
|
+
* keys. It needs "{{n}} selected" and "clear selection"; each
|
|
30
|
+
* action brings its own key.
|
|
31
|
+
*
|
|
32
|
+
* Copyright (c) 2026, ArtGins.
|
|
33
|
+
* All Rights Reserved.
|
|
34
|
+
***********************************************************************/
|
|
35
|
+
import {createElement2, log_error} from "@yuneta/gobj-js";
|
|
36
|
+
|
|
37
|
+
/***************************************************************
|
|
38
|
+
* The checkbox column. Put it FIRST in the column list.
|
|
39
|
+
*
|
|
40
|
+
* opts:
|
|
41
|
+
* field the (virtual) field name, default "_select"
|
|
42
|
+
* width default 44
|
|
43
|
+
***************************************************************/
|
|
44
|
+
function yui_selection_column(opts)
|
|
45
|
+
{
|
|
46
|
+
let o = opts || {};
|
|
47
|
+
return {
|
|
48
|
+
title: "",
|
|
49
|
+
field: o.field || "_select",
|
|
50
|
+
width: o.width || 44,
|
|
51
|
+
minWidth: o.width || 44,
|
|
52
|
+
hozAlign: "center",
|
|
53
|
+
headerHozAlign: "center",
|
|
54
|
+
headerSort: false,
|
|
55
|
+
resizable: false,
|
|
56
|
+
formatter: "rowSelection",
|
|
57
|
+
titleFormatter: "rowSelection",
|
|
58
|
+
/* The header ticks what is ON SCREEN, not what the filters hide. */
|
|
59
|
+
titleFormatterParams: {rowRange: "active"},
|
|
60
|
+
cssClass: "TABLE_SELECT_CELL"
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/***************************************************************
|
|
65
|
+
* What the Tabulator settings need for the column above.
|
|
66
|
+
* Spread it into the settings object.
|
|
67
|
+
***************************************************************/
|
|
68
|
+
function yui_selection_settings()
|
|
69
|
+
{
|
|
70
|
+
return {selectableRows: "highlight"};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/***************************************************************
|
|
74
|
+
* The rows selected right now (never null).
|
|
75
|
+
***************************************************************/
|
|
76
|
+
function yui_selected_rows(tabulator)
|
|
77
|
+
{
|
|
78
|
+
if(!tabulator || typeof tabulator.getSelectedData !== "function") {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return tabulator.getSelectedData() || [];
|
|
83
|
+
} catch(e) {
|
|
84
|
+
return []; /* asked before the table was built */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/***************************************************************
|
|
89
|
+
* Drop the selection (after acting on it, or after a
|
|
90
|
+
* reload that leaves the ticked rows behind).
|
|
91
|
+
***************************************************************/
|
|
92
|
+
function yui_clear_selection(tabulator)
|
|
93
|
+
{
|
|
94
|
+
if(!tabulator || typeof tabulator.deselectRow !== "function") {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
tabulator.deselectRow();
|
|
99
|
+
} catch(e) {
|
|
100
|
+
/* the table is gone: there is no selection to drop */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/***************************************************************
|
|
105
|
+
* Tell me when the selection changes.
|
|
106
|
+
*
|
|
107
|
+
* `on_change(count)` is a DOM/widget notification and nothing
|
|
108
|
+
* else: in a gclass its whole job is to SEND AN EVENT.
|
|
109
|
+
***************************************************************/
|
|
110
|
+
function yui_wire_selection(tabulator, on_change)
|
|
111
|
+
{
|
|
112
|
+
if(!tabulator || typeof tabulator.on !== "function") {
|
|
113
|
+
log_error("yui_wire_selection(): no tabulator");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if(typeof on_change !== "function") {
|
|
117
|
+
log_error("yui_wire_selection(): on_change is not a function");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
tabulator.on("rowSelectionChanged", function(data) {
|
|
121
|
+
on_change((data && data.length) || 0);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/***************************************************************
|
|
126
|
+
* The bar that appears while rows are selected.
|
|
127
|
+
*
|
|
128
|
+
* t the HOST's translator
|
|
129
|
+
* opts:
|
|
130
|
+
* actions [{label, icon, class, on_click}] -- `label` is an
|
|
131
|
+
* i18n KEY, `class` extra Bulma classes for the
|
|
132
|
+
* button (e.g. "is-danger"), `on_click` the DOM
|
|
133
|
+
* handler, whose job is to send an event
|
|
134
|
+
* on_clear called by the "clear selection" button
|
|
135
|
+
* name logical-class prefix, default "TABLE"
|
|
136
|
+
*
|
|
137
|
+
* -> {$el, set_count(n), refresh()}
|
|
138
|
+
*
|
|
139
|
+
* `refresh()` redraws it in the current language: the count is
|
|
140
|
+
* composed at render time, so it cannot re-translate itself
|
|
141
|
+
* through `refresh_language()`. Call it on EV_LANGUAGE_CHANGED.
|
|
142
|
+
***************************************************************/
|
|
143
|
+
function yui_selection_bar(t, opts)
|
|
144
|
+
{
|
|
145
|
+
let o = opts || {};
|
|
146
|
+
let actions = Array.isArray(o.actions) ? o.actions : [];
|
|
147
|
+
let name = o.name || "TABLE";
|
|
148
|
+
let count = 0;
|
|
149
|
+
|
|
150
|
+
let $count = createElement2(
|
|
151
|
+
["span", {class: `${name}_SELECTION_COUNT has-text-weight-semibold`}, ""]);
|
|
152
|
+
|
|
153
|
+
let $buttons = [];
|
|
154
|
+
for(let action of actions) {
|
|
155
|
+
let $b = createElement2(
|
|
156
|
+
["button", {class: `${name}_SELECTION_ACTION button ${action.class || ""}`,
|
|
157
|
+
type: "button"}, [
|
|
158
|
+
["span", {class: "icon"}, [["i", {class: action.icon || "yi-check"}]]],
|
|
159
|
+
["span", {}, ""]
|
|
160
|
+
], {
|
|
161
|
+
click: action.on_click
|
|
162
|
+
}]
|
|
163
|
+
);
|
|
164
|
+
$b._yui_label = action.label || "";
|
|
165
|
+
$buttons.push($b);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let $clear = createElement2(
|
|
169
|
+
["button", {class: `${name}_SELECTION_CLEAR button is-ghost`, type: "button"}, [
|
|
170
|
+
["span", {class: "icon"}, [["i", {class: "yi-xmark"}]]],
|
|
171
|
+
["span", {}, ""]
|
|
172
|
+
], {
|
|
173
|
+
click: function(e) {
|
|
174
|
+
if(typeof o.on_clear === "function") {
|
|
175
|
+
o.on_clear(e);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}]
|
|
179
|
+
);
|
|
180
|
+
$clear._yui_label = "clear selection";
|
|
181
|
+
|
|
182
|
+
let $el = createElement2(
|
|
183
|
+
["div", {class: `${name}_SELECTION_BAR is-flex is-align-items-center `
|
|
184
|
+
+ "is-flex-wrap-wrap mb-2 is-hidden",
|
|
185
|
+
style: "gap:0.5rem;"},
|
|
186
|
+
[$count].concat($buttons, [$clear])]
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
function label_of($b)
|
|
190
|
+
{
|
|
191
|
+
/* The second span: the icon carries the first one. */
|
|
192
|
+
return $b.querySelector("span.icon ~ span");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function refresh()
|
|
196
|
+
{
|
|
197
|
+
$count.textContent = t("{{n}} selected", {n: count});
|
|
198
|
+
for(let $b of $buttons.concat([$clear])) {
|
|
199
|
+
let $label = label_of($b);
|
|
200
|
+
let key = $b._yui_label;
|
|
201
|
+
if($label && key) {
|
|
202
|
+
$label.textContent = t(key);
|
|
203
|
+
}
|
|
204
|
+
$b.title = key ? t(key) : "";
|
|
205
|
+
$b.setAttribute("aria-label", $b.title);
|
|
206
|
+
}
|
|
207
|
+
/* Bulma's `is-hidden` carries `!important`: the class is what
|
|
208
|
+
* toggles, an inline display would lose to it. */
|
|
209
|
+
$el.classList.toggle("is-hidden", count <= 0);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function set_count(n)
|
|
213
|
+
{
|
|
214
|
+
count = n > 0 ? n : 0;
|
|
215
|
+
refresh();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
refresh();
|
|
219
|
+
|
|
220
|
+
return {$el: $el, set_count: set_count, refresh: refresh};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
export {
|
|
225
|
+
yui_selection_column,
|
|
226
|
+
yui_selection_settings,
|
|
227
|
+
yui_selected_rows,
|
|
228
|
+
yui_clear_selection,
|
|
229
|
+
yui_wire_selection,
|
|
230
|
+
yui_selection_bar,
|
|
231
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* yui_table_select.test.js
|
|
3
|
+
*
|
|
4
|
+
* The part of the facility that is not DOM: what the checkbox
|
|
5
|
+
* column is, and reading a selection out of a table that may not
|
|
6
|
+
* be there any more. The bar itself is DOM, like every other view
|
|
7
|
+
* in this library, and this suite has no DOM.
|
|
8
|
+
***********************************************************************/
|
|
9
|
+
import { test, expect, describe } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
yui_selection_column,
|
|
12
|
+
yui_selection_settings,
|
|
13
|
+
yui_selected_rows,
|
|
14
|
+
yui_clear_selection,
|
|
15
|
+
} from "./yui_table_select.js";
|
|
16
|
+
|
|
17
|
+
describe("the column", () => {
|
|
18
|
+
test("is a rowSelection checkbox that does not sort", () => {
|
|
19
|
+
let col = yui_selection_column();
|
|
20
|
+
expect(col.formatter).toBe("rowSelection");
|
|
21
|
+
expect(col.titleFormatter).toBe("rowSelection");
|
|
22
|
+
expect(col.headerSort).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("the header ticks the rows the filters leave on screen", () => {
|
|
26
|
+
expect(yui_selection_column().titleFormatterParams).toEqual({rowRange: "active"});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("the row itself is not clickable into selection", () => {
|
|
30
|
+
expect(yui_selection_settings()).toEqual({selectableRows: "highlight"});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("reading and dropping the selection", () => {
|
|
35
|
+
test("a table that is not built yet has nothing selected", () => {
|
|
36
|
+
expect(yui_selected_rows(null)).toEqual([]);
|
|
37
|
+
expect(yui_selected_rows({})).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a table that throws has nothing selected either", () => {
|
|
41
|
+
expect(yui_selected_rows({getSelectedData: () => { throw new Error("gone"); }}))
|
|
42
|
+
.toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("the rows come back as the table gives them", () => {
|
|
46
|
+
expect(yui_selected_rows({getSelectedData: () => [{id: "a"}]})).toEqual([{id: "a"}]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("clearing a table that is gone is not a crash", () => {
|
|
50
|
+
expect(() => yui_clear_selection(null)).not.toThrow();
|
|
51
|
+
expect(() => yui_clear_selection({deselectRow: () => { throw new Error("gone"); }}))
|
|
52
|
+
.not.toThrow();
|
|
53
|
+
});
|
|
54
|
+
});
|