@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.10.5",
3
+ "version": "7.11.0",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -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
+ });