@yuneta/gobj-ui 7.10.4 → 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/dist/gobj-ui.cjs.js +2 -2
- package/dist/gobj-ui.es.js +2 -2
- package/package.json +1 -1
- package/src/c_yui_map.js +13 -2
- 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/dist/gobj-ui.cjs.js
CHANGED
|
@@ -8536,7 +8536,7 @@ function decod_coordinates(gobj, coordinates) {
|
|
|
8536
8536
|
*
|
|
8537
8537
|
************************************************************/
|
|
8538
8538
|
function get_coordinates(gobj, device) {
|
|
8539
|
-
let coordinates = decod_coordinates(gobj, device.settings
|
|
8539
|
+
let coordinates = decod_coordinates(gobj, device.settings?.coordinates);
|
|
8540
8540
|
if (coordinates.lng === 0 && coordinates.lat === 0) coordinates = decod_coordinates(gobj, device.coordinates);
|
|
8541
8541
|
if (coordinates.lng === 0 && coordinates.lat === 0) {
|
|
8542
8542
|
coordinates.lng = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "default_longitude");
|
|
@@ -8585,7 +8585,7 @@ function devices2geojson(gobj, devices) {
|
|
|
8585
8585
|
id: device.id,
|
|
8586
8586
|
name: device.name,
|
|
8587
8587
|
image: device.image,
|
|
8588
|
-
connected: device.connected,
|
|
8588
|
+
connected: !!device.connected,
|
|
8589
8589
|
gobj_service_name: device.gobj_service_name
|
|
8590
8590
|
}
|
|
8591
8591
|
};
|
package/dist/gobj-ui.es.js
CHANGED
|
@@ -8531,7 +8531,7 @@ function decod_coordinates(gobj, coordinates) {
|
|
|
8531
8531
|
*
|
|
8532
8532
|
************************************************************/
|
|
8533
8533
|
function get_coordinates(gobj, device) {
|
|
8534
|
-
let coordinates = decod_coordinates(gobj, device.settings
|
|
8534
|
+
let coordinates = decod_coordinates(gobj, device.settings?.coordinates);
|
|
8535
8535
|
if (coordinates.lng === 0 && coordinates.lat === 0) coordinates = decod_coordinates(gobj, device.coordinates);
|
|
8536
8536
|
if (coordinates.lng === 0 && coordinates.lat === 0) {
|
|
8537
8537
|
coordinates.lng = gobj_read_attr(gobj, "default_longitude");
|
|
@@ -8580,7 +8580,7 @@ function devices2geojson(gobj, devices) {
|
|
|
8580
8580
|
id: device.id,
|
|
8581
8581
|
name: device.name,
|
|
8582
8582
|
image: device.image,
|
|
8583
|
-
connected: device.connected,
|
|
8583
|
+
connected: !!device.connected,
|
|
8584
8584
|
gobj_service_name: device.gobj_service_name
|
|
8585
8585
|
}
|
|
8586
8586
|
};
|
package/package.json
CHANGED
package/src/c_yui_map.js
CHANGED
|
@@ -293,7 +293,7 @@ function get_coordinates(gobj, device)
|
|
|
293
293
|
* Coordinates can be set in device.settings or in device
|
|
294
294
|
* If devices.settings are null then get it from device
|
|
295
295
|
*/
|
|
296
|
-
let coordinates = decod_coordinates(gobj, device.settings
|
|
296
|
+
let coordinates = decod_coordinates(gobj, device.settings?.coordinates);
|
|
297
297
|
if(coordinates.lng === 0 && coordinates.lat === 0) {
|
|
298
298
|
coordinates = decod_coordinates(gobj, device.coordinates);
|
|
299
299
|
}
|
|
@@ -369,7 +369,18 @@ function devices2geojson(gobj, devices)
|
|
|
369
369
|
id: device.id,
|
|
370
370
|
name: device.name,
|
|
371
371
|
image: device.image,
|
|
372
|
-
|
|
372
|
+
/*
|
|
373
|
+
* A REAL boolean. Four style expressions test this with
|
|
374
|
+
* ['case', ...], and maplibre asserts a STRICT boolean
|
|
375
|
+
* there, so null, absent, or the 1/0 a backend may send all
|
|
376
|
+
* fail the assertion. The consequences are not cosmetic:
|
|
377
|
+
* the point paints with the property default instead of
|
|
378
|
+
* green/red, and the cluster accumulator turns NULL, which
|
|
379
|
+
* the cluster colour compares against point_count -- never
|
|
380
|
+
* equal, so the whole cluster stays red as if something were
|
|
381
|
+
* disconnected.
|
|
382
|
+
*/
|
|
383
|
+
connected: !!device.connected,
|
|
373
384
|
gobj_service_name: device.gobj_service_name,
|
|
374
385
|
}
|
|
375
386
|
};
|
|
@@ -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
|
+
});
|