@x-tech-laboratories/x-libs 0.6.0 → 0.7.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 -11
- package/dist/backups.d.ts +31 -0
- package/dist/backups.js +43 -0
- package/dist/durations.d.ts +1 -0
- package/dist/durations.js +10 -0
- package/dist/electron/app.d.ts +13 -0
- package/dist/electron/app.js +27 -0
- package/dist/electron/bridge.d.ts +3 -1
- package/dist/electron/bridge.js +21 -0
- package/dist/electron/events.d.ts +21 -0
- package/dist/electron/events.js +25 -0
- package/dist/electron/fetch.d.ts +25 -0
- package/dist/electron/fetch.js +67 -0
- package/dist/electron/window.d.ts +11 -0
- package/dist/electron/window.js +49 -6
- package/dist/log.d.ts +30 -0
- package/dist/log.js +79 -0
- package/dist/server/backup_files.d.ts +3 -0
- package/dist/server/backup_files.js +25 -0
- package/dist/web/drag.d.ts +3 -0
- package/dist/web/drag.js +13 -0
- package/dist/web/format.d.ts +1 -0
- package/dist/web/format.js +5 -0
- package/dist/web/prefs.d.ts +12 -0
- package/dist/web/prefs.js +62 -0
- package/dist/web/react/controls.d.ts +39 -4
- package/dist/web/react/controls.js +51 -7
- package/dist/web/react/layout.d.ts +26 -6
- package/dist/web/react/layout.js +15 -6
- package/dist/web/react/prefs.d.ts +2 -0
- package/dist/web/react/prefs.js +11 -0
- package/dist/web/react/reveal.d.ts +9 -0
- package/dist/web/react/reveal.js +30 -0
- package/dist/web/react/shell.d.ts +6 -2
- package/dist/web/react/shell.js +12 -3
- package/dist/web/react/table.d.ts +24 -1
- package/dist/web/react/table.js +84 -16
- package/dist/web/react/time.d.ts +1 -0
- package/dist/web/react/time.js +11 -1
- package/dist/web/react/toasts.d.ts +12 -0
- package/dist/web/react/toasts.js +31 -0
- package/dist/web/react/tree.d.ts +7 -2
- package/dist/web/react/tree.js +22 -7
- package/dist/web/react/watch.d.ts +3 -0
- package/dist/web/react/watch.js +5 -0
- package/dist/web/selection.d.ts +25 -0
- package/dist/web/selection.js +106 -0
- package/dist/web/tables.d.ts +1 -0
- package/dist/web/tables.js +8 -0
- package/dist/web/tree.d.ts +2 -0
- package/dist/web/tree.js +21 -0
- package/dist/web/virtual.d.ts +7 -0
- package/dist/web/virtual.js +10 -0
- package/dist/web/watch.d.ts +6 -0
- package/dist/web/watch.js +17 -0
- package/package.json +1 -1
package/dist/web/react/table.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// A React table drawn like the page's own on TanStack Table: sorting by one column or several, columns the user moves, resizes,
|
|
3
|
-
// hides and filters, rows nested under rows, and cards on phones.
|
|
4
|
-
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
// hides and filters, rows nested under rows, rows in groups under headings, rows dragged by the app's rules, and cards on phones.
|
|
4
|
+
import { Fragment, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
5
5
|
import { getCoreRowModel, getExpandedRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
|
|
6
6
|
import { clamp_width, column_id, compare_values, move_column, ordered_columns, sort_after_click, sorting_list } from "../tables.js";
|
|
7
|
+
import { placement_at } from "../tree.js";
|
|
7
8
|
import { ContextMenu, Popover } from "./menus.js";
|
|
8
9
|
import { ResizeHandle } from "./split.js";
|
|
9
10
|
const is_empty = (value) => value === null || value === undefined || value === "";
|
|
@@ -17,7 +18,8 @@ function filtered_tree(rows, passes, get_children, kept_children) {
|
|
|
17
18
|
}
|
|
18
19
|
// ── Table ────────────────────────────────────────────────────────────────────
|
|
19
20
|
export function DataTable(props) {
|
|
20
|
-
const { columns,
|
|
21
|
+
const { columns, row_key, caption, get_children, row_passes, groups, allow_inside_drop = true } = props;
|
|
22
|
+
const rows = useMemo(() => groups ? groups.flatMap((group) => group.rows) : props.rows, [groups, props.rows]);
|
|
21
23
|
const [own_expanded, set_own_expanded] = useState(true);
|
|
22
24
|
const [open_filter, set_open_filter] = useState(null);
|
|
23
25
|
const filter_closed = useRef({ id: "", at: 0 });
|
|
@@ -27,9 +29,14 @@ export function DataTable(props) {
|
|
|
27
29
|
const resizing = useRef(false);
|
|
28
30
|
const header_cells = useRef(new Map());
|
|
29
31
|
const [measured, set_measured] = useState({});
|
|
32
|
+
const [row_dragging, set_row_dragging] = useState(false);
|
|
33
|
+
const [row_drop, set_row_drop] = useState(null);
|
|
30
34
|
const by_id = useMemo(() => new Map(columns.map((column) => [column_id(column), column])), [columns]);
|
|
31
35
|
const all_ids = columns.map(column_id);
|
|
32
|
-
const
|
|
36
|
+
const manual = props.manual_sorting === true;
|
|
37
|
+
const is_sortable = (column) => Boolean(column) && (manual ? column.sortable !== false : Boolean(column.sort));
|
|
38
|
+
// Rows that come sorted keep every key, even one naming no column, so a click never drops it.
|
|
39
|
+
const sorting = manual ? sorting_list(props.sorting) : sorting_list(props.sorting).filter((key) => is_sortable(by_id.get(key.key)));
|
|
33
40
|
const hidden = props.hidden_columns ?? [];
|
|
34
41
|
const widths = props.column_widths ?? {};
|
|
35
42
|
const expanded = props.expanded ?? own_expanded;
|
|
@@ -61,7 +68,7 @@ export function DataTable(props) {
|
|
|
61
68
|
getRowId: row_key ? (row) => String(row_key(row)) : undefined,
|
|
62
69
|
getSubRows: children_of ? (row) => children_of(row) ?? [] : undefined,
|
|
63
70
|
state: {
|
|
64
|
-
sorting: sorting.map((key) => ({ id: key.key, desc: key.descending })),
|
|
71
|
+
sorting: sorting.filter((key) => by_id.has(key.key)).map((key) => ({ id: key.key, desc: key.descending })),
|
|
65
72
|
columnOrder: ordered_columns(props.column_order ?? all_ids, all_ids),
|
|
66
73
|
columnVisibility: Object.fromEntries(hidden.map((id) => [id, false])),
|
|
67
74
|
expanded: expanded === true ? true : Object.fromEntries(expanded.map((id) => [id, true])),
|
|
@@ -70,6 +77,7 @@ export function DataTable(props) {
|
|
|
70
77
|
getSortedRowModel: getSortedRowModel(),
|
|
71
78
|
getExpandedRowModel: getExpandedRowModel(),
|
|
72
79
|
getRowCanExpand: (row) => row.subRows.length > 0,
|
|
80
|
+
manualSorting: manual,
|
|
73
81
|
});
|
|
74
82
|
const shown = table.getVisibleLeafColumns().map((column) => by_id.get(column.id));
|
|
75
83
|
function toggle_row(row) {
|
|
@@ -162,12 +170,53 @@ export function DataTable(props) {
|
|
|
162
170
|
const fixed = column.hideable === false || (visible && shown.length === 1);
|
|
163
171
|
return { label: column.label, checked: visible, disabled: fixed, keep_open: true, run: () => props.on_hidden_columns_change(visible ? [...hidden, id] : hidden.filter((each) => each !== id)) };
|
|
164
172
|
});
|
|
173
|
+
// ── Dragging rows ──
|
|
174
|
+
// A drag started in this table lands anywhere, and any drag lands where the app's rule allows it.
|
|
175
|
+
function row_placement(row, event) {
|
|
176
|
+
if (!props.on_row_drop)
|
|
177
|
+
return null;
|
|
178
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
179
|
+
const placement = placement_at(box.height > 0 ? (event.clientY - box.top) / box.height : 0.5, allow_inside_drop);
|
|
180
|
+
const allowed = props.can_drop_on_row ? props.can_drop_on_row(row.original, placement) : row_dragging;
|
|
181
|
+
return allowed ? placement : null;
|
|
182
|
+
}
|
|
183
|
+
function row_drag_start(row, event) {
|
|
184
|
+
set_row_dragging(true);
|
|
185
|
+
props.on_row_drag_start(row.original, event);
|
|
186
|
+
// Some browsers start a drag only once it carries data.
|
|
187
|
+
if (event.dataTransfer && event.dataTransfer.types.length === 0)
|
|
188
|
+
event.dataTransfer.setData("text/plain", row.id);
|
|
189
|
+
}
|
|
190
|
+
function row_drag_over(row, event) {
|
|
191
|
+
const placement = row_placement(row, event);
|
|
192
|
+
if (!placement) {
|
|
193
|
+
if (row_drop?.id === row.id)
|
|
194
|
+
set_row_drop(null);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
if (row_drop?.id !== row.id || row_drop.placement !== placement)
|
|
199
|
+
set_row_drop({ id: row.id, placement });
|
|
200
|
+
}
|
|
201
|
+
function row_drag_drop(row, event) {
|
|
202
|
+
const placement = row_placement(row, event);
|
|
203
|
+
set_row_drop(null);
|
|
204
|
+
if (!placement)
|
|
205
|
+
return;
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
props.on_row_drop(row.original, placement, event);
|
|
208
|
+
}
|
|
209
|
+
function row_drag_end() {
|
|
210
|
+
set_row_dragging(false);
|
|
211
|
+
set_row_drop(null);
|
|
212
|
+
props.on_row_drag_end?.();
|
|
213
|
+
}
|
|
165
214
|
// ── Drawing ──
|
|
166
215
|
function header(column) {
|
|
167
216
|
const id = column_id(column);
|
|
168
217
|
const index = sorting.findIndex((key) => key.key === id);
|
|
169
218
|
const key = sorting[index];
|
|
170
|
-
const sortable =
|
|
219
|
+
const sortable = is_sortable(column) && Boolean(props.on_sort || props.on_sorting_change);
|
|
171
220
|
const width = width_of(column);
|
|
172
221
|
const movable = can_move(column);
|
|
173
222
|
const class_name = [
|
|
@@ -186,20 +235,39 @@ export function DataTable(props) {
|
|
|
186
235
|
}
|
|
187
236
|
function body_row(row) {
|
|
188
237
|
const original = row.original;
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
238
|
+
const row_class = [props.row_class?.(original), row_drop?.id === row.id && `drop-${row_drop.placement}`].filter(Boolean).join(" ") || undefined;
|
|
239
|
+
const after = props.render_after_row?.(original);
|
|
240
|
+
const draggable = Boolean(props.on_row_drag_start);
|
|
241
|
+
const keyed = props.on_row_key_down !== undefined;
|
|
242
|
+
const key = row_key ? String(row_key(original)) : row.id;
|
|
243
|
+
return (_jsxs(Fragment, { children: [_jsx("tr", { className: row_class, title: props.row_title?.(original), style: props.row_style?.(original), "data-row-key": keyed ? key : undefined, tabIndex: keyed ? (props.active_row !== undefined && String(props.active_row) === key ? 0 : -1) : undefined, "aria-selected": props.row_selected ? props.row_selected(original) : undefined, draggable: draggable || undefined, onDragStart: draggable ? (event) => row_drag_start(row, event) : undefined, onDragEnd: draggable ? row_drag_end : undefined, onDragOver: props.on_row_drop && ((event) => row_drag_over(row, event)), onDragLeave: props.on_row_drop && (() => { if (row_drop?.id === row.id)
|
|
244
|
+
set_row_drop(null); }), onDrop: props.on_row_drop && ((event) => row_drag_drop(row, event)), onClick: props.on_row_click && ((event) => props.on_row_click(original, event)), onDoubleClick: props.on_row_double_click && ((event) => props.on_row_double_click(original, event)), onKeyDown: props.on_row_key_down && ((event) => props.on_row_key_down(original, event)), onContextMenu: props.on_row_context_menu && ((event) => props.on_row_context_menu(original, event)), children: shown.map((column, index) => {
|
|
245
|
+
const label = column.label;
|
|
246
|
+
if (!tree || index > 0)
|
|
247
|
+
return _jsx("td", { className: column.class, "data-label": label, children: column.cell(original) }, column_id(column));
|
|
248
|
+
const open = row.getIsExpanded();
|
|
249
|
+
const count = row.subRows.length;
|
|
250
|
+
return (_jsxs("td", { className: [column.class, "tree-cell"].filter(Boolean).join(" "), "data-label": label, style: { "--tree-depth": row.depth }, children: [count > 0 ? (_jsx("button", { type: "button", className: "icon tree-toggle", "aria-expanded": open, "aria-label": open ? "Collapse" : "Expand", onClick: (event) => { event.stopPropagation(); toggle_row(row); }, children: open ? "▾" : "▸" })) : _jsx("span", { className: "tree-spacer" }), column.cell(original), count > 0 && !open && _jsx("span", { className: "tree-count", title: `${count} inside`, children: count })] }, column_id(column)));
|
|
251
|
+
}) }), after !== undefined && after !== null && typeof after !== "boolean" && _jsx("tr", { className: "row-extra", children: _jsx("td", { colSpan: shown.length, children: after }) })] }, row.id));
|
|
252
|
+
}
|
|
253
|
+
// Each group's heading and then its rows, which keep the order of the sorted whole, so each group is sorted on its own.
|
|
254
|
+
function grouped_body(groups) {
|
|
255
|
+
const group_of = new Map();
|
|
256
|
+
groups.forEach((group, index) => group.rows.forEach((row) => { if (!group_of.has(row))
|
|
257
|
+
group_of.set(row, index); }));
|
|
258
|
+
const rows_by_group = groups.map(() => []);
|
|
259
|
+
let current = 0;
|
|
260
|
+
for (const row of table.getRowModel().rows) {
|
|
261
|
+
if (row.depth === 0)
|
|
262
|
+
current = group_of.get(row.original) ?? current;
|
|
263
|
+
rows_by_group[current].push(row);
|
|
264
|
+
}
|
|
265
|
+
return groups.map((group, index) => (_jsxs(Fragment, { children: [_jsx("tr", { className: group.class_name ? `group-row ${group.class_name}` : "group-row", children: _jsx("th", { scope: "colgroup", colSpan: shown.length, children: group.label }) }), rows_by_group[index].map(body_row)] }, group.key)));
|
|
198
266
|
}
|
|
199
267
|
const filtered_column = open_filter ? by_id.get(open_filter.id) : undefined;
|
|
200
268
|
const filter_body = filtered_column?.filter;
|
|
201
269
|
const tools = (props.on_clear_filters && any_filter) || props.on_hidden_columns_change;
|
|
202
|
-
return (_jsxs(_Fragment, { children: [tools && (_jsxs("div", { className: "table-tools", children: [props.on_clear_filters && any_filter && _jsx("button", { type: "button", onClick: props.on_clear_filters, children: "Clear filters" }), props.on_hidden_columns_change && _jsx("button", { type: "button", "aria-haspopup": "menu", onClick: open_columns_menu, children: "Columns" })] })), _jsx("div", { className: "table-wrap", children: _jsxs("table", { className: "responsive", children: [caption && _jsx("caption", { className: "visually-hidden", children: caption }), _jsx("thead", { onContextMenu: props.on_hidden_columns_change && open_columns_menu, children: _jsx("tr", { children: shown.map(header) }) }), _jsx("tbody", { children: table.getRowModel().rows.map(body_row) })] }) }), open_filter && filtered_column && filter_body !== undefined && (_jsx(Popover, { anchor: open_filter.anchor, label: `Filter by ${filtered_column.label}`, on_close: close_filter, children: typeof filter_body === "function" ? filter_body(close_filter) : filter_body })), columns_menu && _jsx(ContextMenu, { x: columns_menu.x, y: columns_menu.y, label: "Columns", items: column_choices, on_close: () => set_columns_menu(null) })] }));
|
|
270
|
+
return (_jsxs(_Fragment, { children: [tools && (_jsxs("div", { className: "table-tools", children: [props.on_clear_filters && any_filter && _jsx("button", { type: "button", onClick: props.on_clear_filters, children: "Clear filters" }), props.on_hidden_columns_change && _jsx("button", { type: "button", "aria-haspopup": "menu", onClick: open_columns_menu, children: "Columns" })] })), _jsx("div", { className: "table-wrap", children: _jsxs("table", { className: "responsive", children: [caption && _jsx("caption", { className: "visually-hidden", children: caption }), _jsx("thead", { onContextMenu: props.on_hidden_columns_change && open_columns_menu, children: _jsx("tr", { children: shown.map(header) }) }), _jsx("tbody", { children: groups ? grouped_body(groups) : table.getRowModel().rows.map(body_row) })] }) }), open_filter && filtered_column && filter_body !== undefined && (_jsx(Popover, { anchor: open_filter.anchor, label: `Filter by ${filtered_column.label}`, on_close: close_filter, children: typeof filter_body === "function" ? filter_body(close_filter) : filter_body })), columns_menu && _jsx(ContextMenu, { x: columns_menu.x, y: columns_menu.y, label: "Columns", items: column_choices, on_close: () => set_columns_menu(null) })] }));
|
|
203
271
|
}
|
|
204
272
|
// ── Pages ────────────────────────────────────────────────────────────────────
|
|
205
273
|
// Newer and older buttons around the page number, or nothing when everything fits on one page.
|
package/dist/web/react/time.d.ts
CHANGED
package/dist/web/react/time.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
// Times as the page shows them: how long ago, with the full date and time on hover.
|
|
2
|
+
// Times as the page shows them: how long ago, with the full date and time on hover, and the current time kept fresh.
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
3
4
|
import { age, stamp } from "../format.js";
|
|
4
5
|
// A time as its age, such as "3h ago", or the missing text, a dash unless told otherwise, when there is none.
|
|
5
6
|
export function Age({ epoch, missing = "—" }) {
|
|
@@ -7,3 +8,12 @@ export function Age({ epoch, missing = "—" }) {
|
|
|
7
8
|
return _jsx(_Fragment, { children: missing });
|
|
8
9
|
return _jsx("span", { title: stamp(epoch), children: age(epoch) });
|
|
9
10
|
}
|
|
11
|
+
// The current time, read again every interval, a minute unless told otherwise, so a page notices the day turning.
|
|
12
|
+
export function use_now(interval_in_milliseconds = 60_000) {
|
|
13
|
+
const [now, set_now] = useState(() => new Date());
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const timer = setInterval(() => set_now(new Date()), interval_in_milliseconds);
|
|
16
|
+
return () => clearInterval(timer);
|
|
17
|
+
}, [interval_in_milliseconds]);
|
|
18
|
+
return now;
|
|
19
|
+
}
|
|
@@ -14,4 +14,16 @@ export interface ToastOptions {
|
|
|
14
14
|
}
|
|
15
15
|
export type ToastCloseReason = "timeout" | "dismissed" | "action" | "closed";
|
|
16
16
|
export declare function toast(message: string, options?: ToastOptions): () => void;
|
|
17
|
+
export interface UndoableOptions {
|
|
18
|
+
commit: () => void;
|
|
19
|
+
undo: () => void;
|
|
20
|
+
duration_in_milliseconds?: number;
|
|
21
|
+
undo_label?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface Undoable {
|
|
24
|
+
finish: () => void;
|
|
25
|
+
undo: () => void;
|
|
26
|
+
readonly settled: boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare function undoable(message: string, { commit, undo, duration_in_milliseconds, undo_label }: UndoableOptions): Undoable;
|
|
17
29
|
export declare function Toasts(): import("react").JSX.Element;
|
package/dist/web/react/toasts.js
CHANGED
|
@@ -25,6 +25,37 @@ export function toast(message, options = {}) {
|
|
|
25
25
|
setTimeout(() => close_toast(id, "timeout"), options.duration_in_milliseconds ?? 6000);
|
|
26
26
|
return () => close_toast(id, "closed");
|
|
27
27
|
}
|
|
28
|
+
// Shows the message with an Undo button, then either undoes or commits the action, exactly once.
|
|
29
|
+
export function undoable(message, { commit, undo, duration_in_milliseconds = 8000, undo_label = "Undo" }) {
|
|
30
|
+
let settled = false;
|
|
31
|
+
let undo_wanted = false;
|
|
32
|
+
const settle = (reason) => {
|
|
33
|
+
if (settled)
|
|
34
|
+
return;
|
|
35
|
+
settled = true;
|
|
36
|
+
if (reason === "action" || undo_wanted)
|
|
37
|
+
undo();
|
|
38
|
+
else
|
|
39
|
+
commit();
|
|
40
|
+
};
|
|
41
|
+
const close = toast(message, {
|
|
42
|
+
action: { text: undo_label, run: () => { } },
|
|
43
|
+
duration_in_milliseconds,
|
|
44
|
+
on_close: settle,
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
finish: () => close(),
|
|
48
|
+
undo: () => {
|
|
49
|
+
if (settled)
|
|
50
|
+
return;
|
|
51
|
+
undo_wanted = true;
|
|
52
|
+
close();
|
|
53
|
+
},
|
|
54
|
+
get settled() {
|
|
55
|
+
return settled;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
28
59
|
// ── Host ─────────────────────────────────────────────────────────────────────
|
|
29
60
|
// Where toasts stack; the Shell holds one, and a page without the Shell renders its own.
|
|
30
61
|
export function Toasts() {
|
package/dist/web/react/tree.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type MouseEvent, type ReactNode } from "react";
|
|
1
|
+
import { type DragEvent, type MouseEvent, type ReactNode } from "react";
|
|
2
2
|
import { type DropPlacement, type TreeItem, type TreeMove } from "../tree.ts";
|
|
3
3
|
export type { DropPlacement, TreeItem, TreeMove } from "../tree.ts";
|
|
4
4
|
interface TreeProps<Item extends TreeItem> {
|
|
@@ -10,7 +10,10 @@ interface TreeProps<Item extends TreeItem> {
|
|
|
10
10
|
collapsed: ReadonlySet<string>;
|
|
11
11
|
on_collapsed_change: (collapsed: Set<string>) => void;
|
|
12
12
|
selected_id?: string | null;
|
|
13
|
+
selected_ids?: ReadonlySet<string>;
|
|
13
14
|
on_select?: (item: Item) => void;
|
|
15
|
+
on_activate?: (item: Item) => void;
|
|
16
|
+
row_class?: (item: Item) => string | undefined;
|
|
14
17
|
on_move?: (item: Item, move: TreeMove) => void;
|
|
15
18
|
can_drop?: (dragged: Item, target: Item, placement: DropPlacement) => boolean;
|
|
16
19
|
allow_inside?: boolean;
|
|
@@ -19,5 +22,7 @@ interface TreeProps<Item extends TreeItem> {
|
|
|
19
22
|
form: ReactNode;
|
|
20
23
|
} | null;
|
|
21
24
|
on_context_menu?: (event: MouseEvent<HTMLElement>, item: Item) => void;
|
|
25
|
+
accepts_external?: (event: DragEvent<HTMLElement>, item: Item) => boolean;
|
|
26
|
+
on_external_drop?: (event: DragEvent<HTMLElement>, item: Item) => void;
|
|
22
27
|
}
|
|
23
|
-
export declare function Tree<Item extends TreeItem>({ items, label, render_item, render_extra, name_of, collapsed, on_collapsed_change, selected_id, on_select, on_move, can_drop, allow_inside, new_item, on_context_menu, }: TreeProps<Item>): import("react").JSX.Element;
|
|
28
|
+
export declare function Tree<Item extends TreeItem>({ items, label, render_item, render_extra, name_of, collapsed, on_collapsed_change, selected_id, selected_ids, on_select, on_activate, row_class: class_of, on_move, can_drop, allow_inside, new_item, on_context_menu, accepts_external, on_external_drop, }: TreeProps<Item>): import("react").JSX.Element;
|
package/dist/web/react/tree.js
CHANGED
|
@@ -7,7 +7,7 @@ const KEY_MOVES = { ArrowUp: "up", ArrowDown: "down", ArrowRight: "indent", Arro
|
|
|
7
7
|
function visible_nodes(nodes, collapsed) {
|
|
8
8
|
return nodes.flatMap((node) => [node, ...(collapsed.has(node.item.id) ? [] : visible_nodes(node.children, collapsed))]);
|
|
9
9
|
}
|
|
10
|
-
export function Tree({ items, label, render_item, render_extra, name_of, collapsed, on_collapsed_change, selected_id = null, on_select, on_move, can_drop, allow_inside = true, new_item = null, on_context_menu, }) {
|
|
10
|
+
export function Tree({ items, label, render_item, render_extra, name_of, collapsed, on_collapsed_change, selected_id = null, selected_ids, on_select, on_activate, row_class: class_of, on_move, can_drop, allow_inside = true, new_item = null, on_context_menu, accepts_external, on_external_drop, }) {
|
|
11
11
|
const [focused_id, set_focused_id] = useState(null);
|
|
12
12
|
const [dragged_id, set_dragged_id] = useState(null);
|
|
13
13
|
const [drop_target, set_drop_target] = useState(null);
|
|
@@ -15,7 +15,9 @@ export function Tree({ items, label, render_item, render_extra, name_of, collaps
|
|
|
15
15
|
const moves_focus = useRef(false);
|
|
16
16
|
const roots = nest([...items].sort(by_position), items);
|
|
17
17
|
const shown = visible_nodes(roots, collapsed);
|
|
18
|
-
const
|
|
18
|
+
const is_selected = (id) => selected_id === id || Boolean(selected_ids?.has(id));
|
|
19
|
+
const first_selected = shown.find((node) => selected_ids?.has(node.item.id))?.item.id ?? null;
|
|
20
|
+
const tab_stop = [focused_id, selected_id, first_selected].find((id) => id !== null && shown.some((node) => node.item.id === id)) ?? shown[0]?.item.id;
|
|
19
21
|
useEffect(() => {
|
|
20
22
|
if (!moves_focus.current || focused_id === null)
|
|
21
23
|
return;
|
|
@@ -53,17 +55,29 @@ export function Tree({ items, label, render_item, render_extra, name_of, collaps
|
|
|
53
55
|
const placement = placement_at(box.height > 0 ? (event.clientY - box.top) / box.height : 0.5, allow_inside);
|
|
54
56
|
return is_allowed(dragged, target, placement) ? placement : null;
|
|
55
57
|
}
|
|
58
|
+
// A drag from outside the tree, such as a task, lands inside the row that accepts it.
|
|
59
|
+
function accepts_from_outside(event, target) {
|
|
60
|
+
return dragged_id === null && Boolean(accepts_external?.(event, target));
|
|
61
|
+
}
|
|
56
62
|
function drag_over(event, target) {
|
|
57
|
-
const
|
|
63
|
+
const external = accepts_from_outside(event, target);
|
|
64
|
+
const placement = external ? "inside" : placement_over(event, target);
|
|
58
65
|
if (!placement)
|
|
59
66
|
return;
|
|
60
67
|
event.preventDefault();
|
|
61
|
-
|
|
68
|
+
// An outside drag keeps the effect its source allowed, which may not include a move.
|
|
69
|
+
if (event.dataTransfer && !external)
|
|
62
70
|
event.dataTransfer.dropEffect = "move";
|
|
63
71
|
if (drop_target?.id !== target.id || drop_target.placement !== placement)
|
|
64
72
|
set_drop_target({ id: target.id, placement });
|
|
65
73
|
}
|
|
66
74
|
function drop(event, target) {
|
|
75
|
+
if (accepts_from_outside(event, target)) {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
end_drag();
|
|
78
|
+
on_external_drop?.(event, target);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
67
81
|
const placement = placement_over(event, target);
|
|
68
82
|
const dragged = items.find((item) => item.id === dragged_id);
|
|
69
83
|
end_drag();
|
|
@@ -147,22 +161,23 @@ export function Tree({ items, label, render_item, render_extra, name_of, collaps
|
|
|
147
161
|
const new_child = new_item_row(item.id);
|
|
148
162
|
const row_class = [
|
|
149
163
|
"tree-row",
|
|
164
|
+
class_of?.(item),
|
|
150
165
|
dragged_id === item.id && "dragging",
|
|
151
166
|
drop_target?.id === item.id && `drop-${drop_target.placement}`,
|
|
152
167
|
].filter(Boolean).join(" ");
|
|
153
168
|
return (_jsxs("li", { ref: (element) => { if (element)
|
|
154
169
|
row_elements.current.set(item.id, element);
|
|
155
170
|
else
|
|
156
|
-
row_elements.current.delete(item.id); }, role: "treeitem", className: "tree-item", "data-id": item.id, "aria-level": depth + 1, "aria-expanded": has_children ? !is_collapsed : undefined, "aria-selected": on_select
|
|
171
|
+
row_elements.current.delete(item.id); }, role: "treeitem", className: "tree-item", "data-id": item.id, "aria-level": depth + 1, "aria-expanded": has_children ? !is_collapsed : undefined, "aria-selected": on_select || selected_ids ? is_selected(item.id) : undefined, tabIndex: tab_stop === item.id ? 0 : -1, onFocus: (event) => { if (event.target === event.currentTarget)
|
|
157
172
|
set_focused_id(item.id); }, children: [_jsxs("div", { className: row_class, draggable: on_move ? true : undefined, onDragStart: (event) => {
|
|
158
173
|
event.dataTransfer?.setData("text/plain", item.id);
|
|
159
174
|
if (event.dataTransfer)
|
|
160
175
|
event.dataTransfer.effectAllowed = "move";
|
|
161
176
|
set_dragged_id(item.id);
|
|
162
177
|
}, onDragEnd: end_drag, onDragOver: (event) => drag_over(event, item), onDragLeave: () => { if (drop_target?.id === item.id)
|
|
163
|
-
set_drop_target(null); }, onDrop: (event) => drop(event, item), onClick: () => { focus_row(item.id); on_select?.(item); }, onContextMenu: on_context_menu && ((event) => on_context_menu(event, item)), children: [has_children
|
|
178
|
+
set_drop_target(null); }, onDrop: (event) => drop(event, item), onClick: () => { focus_row(item.id); on_select?.(item); }, onDoubleClick: on_activate && (() => on_activate(item)), onContextMenu: on_context_menu && ((event) => on_context_menu(event, item)), children: [has_children
|
|
164
179
|
? (_jsx("button", { type: "button", className: "tree-toggle", tabIndex: -1, "aria-label": is_collapsed ? `Expand ${name}` : `Collapse ${name}`, onClick: (event) => { event.stopPropagation(); set_collapsed(item.id, !is_collapsed); }, children: _jsx("span", { "aria-hidden": "true", children: is_collapsed ? "▸" : "▾" }) }))
|
|
165
180
|
: _jsx("span", { className: "tree-toggle", "aria-hidden": "true" }), _jsx("span", { className: "tree-label", children: render_item(item) }), render_extra && _jsx("span", { className: "tree-extra", children: render_extra(item) })] }), ((has_children && !is_collapsed) || new_child) && (_jsxs("ul", { role: "group", className: "tree-children", children: [!is_collapsed && children.map((child) => branch(child, depth + 1)), new_child] }))] }, item.id));
|
|
166
181
|
}
|
|
167
|
-
return (_jsxs("ul", { role: "tree", className: "tree", "aria-label": label, onKeyDown: key_down, children: [roots.map((node) => branch(node, 0)), new_item_row(null)] }));
|
|
182
|
+
return (_jsxs("ul", { role: "tree", className: "tree", "aria-label": label, "aria-multiselectable": selected_ids ? true : undefined, onKeyDown: key_down, children: [roots.map((node) => branch(node, 0)), new_item_row(null)] }));
|
|
168
183
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type SelectionIntent = "replace" | "toggle" | "range" | "range-add";
|
|
2
|
+
export declare function intent_for(modifiers: {
|
|
3
|
+
shift?: boolean;
|
|
4
|
+
ctrl?: boolean;
|
|
5
|
+
}): SelectionIntent;
|
|
6
|
+
export declare class Selection<Id = number> {
|
|
7
|
+
private readonly picked;
|
|
8
|
+
private anchor;
|
|
9
|
+
get size(): number;
|
|
10
|
+
has(id: Id): boolean;
|
|
11
|
+
ids(): Id[];
|
|
12
|
+
inspect(): {
|
|
13
|
+
size: number;
|
|
14
|
+
anchor: number | null;
|
|
15
|
+
};
|
|
16
|
+
clear(): void;
|
|
17
|
+
apply(ordered: readonly Id[], index: number, intent: SelectionIntent): void;
|
|
18
|
+
select_all(ordered: readonly Id[]): void;
|
|
19
|
+
invert(ordered: readonly Id[]): void;
|
|
20
|
+
retain(ordered: readonly Id[]): void;
|
|
21
|
+
}
|
|
22
|
+
export declare function toggled(picked: readonly string[], id: string): string[];
|
|
23
|
+
export declare function with_range(order: readonly string[], picked: readonly string[], anchor_id: string | null, id: string): string[];
|
|
24
|
+
export declare function still_shown(order: readonly string[], picked: readonly string[]): string[];
|
|
25
|
+
export declare function row_after_key(key: string, current: number, last: number, rows_per_page: number): number | null;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Picking rows in a list: plain, Ctrl and Shift clicks, an anchor ranges measure from, and the row an arrow or page key moves to.
|
|
2
|
+
export function intent_for(modifiers) {
|
|
3
|
+
if (modifiers.shift)
|
|
4
|
+
return modifiers.ctrl ? "range-add" : "range";
|
|
5
|
+
return modifiers.ctrl ? "toggle" : "replace";
|
|
6
|
+
}
|
|
7
|
+
// ── Selection ────────────────────────────────────────────────────────────────
|
|
8
|
+
export class Selection {
|
|
9
|
+
picked = new Set();
|
|
10
|
+
anchor = null;
|
|
11
|
+
get size() {
|
|
12
|
+
return this.picked.size;
|
|
13
|
+
}
|
|
14
|
+
has(id) {
|
|
15
|
+
return this.picked.has(id);
|
|
16
|
+
}
|
|
17
|
+
ids() {
|
|
18
|
+
return [...this.picked];
|
|
19
|
+
}
|
|
20
|
+
// The picks and the anchor, for finding why a range came out wrong.
|
|
21
|
+
inspect() {
|
|
22
|
+
return { size: this.picked.size, anchor: this.anchor };
|
|
23
|
+
}
|
|
24
|
+
clear() {
|
|
25
|
+
this.picked.clear();
|
|
26
|
+
this.anchor = null;
|
|
27
|
+
}
|
|
28
|
+
// Applies a click on the row at index in the list as shown.
|
|
29
|
+
apply(ordered, index, intent) {
|
|
30
|
+
if (index < 0 || index >= ordered.length)
|
|
31
|
+
return;
|
|
32
|
+
const id = ordered[index];
|
|
33
|
+
if (intent === "replace") {
|
|
34
|
+
this.picked.clear();
|
|
35
|
+
this.picked.add(id);
|
|
36
|
+
this.anchor = index;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (intent === "toggle") {
|
|
40
|
+
if (this.picked.has(id))
|
|
41
|
+
this.picked.delete(id);
|
|
42
|
+
else
|
|
43
|
+
this.picked.add(id);
|
|
44
|
+
this.anchor = index;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// The anchor stays put, so every extension measures from the same row.
|
|
48
|
+
const from = this.anchor ?? index;
|
|
49
|
+
const [low, high] = from <= index ? [from, index] : [index, from];
|
|
50
|
+
if (intent === "range")
|
|
51
|
+
this.picked.clear();
|
|
52
|
+
for (let row = low; row <= high; row += 1)
|
|
53
|
+
this.picked.add(ordered[row]);
|
|
54
|
+
}
|
|
55
|
+
select_all(ordered) {
|
|
56
|
+
for (const id of ordered)
|
|
57
|
+
this.picked.add(id);
|
|
58
|
+
}
|
|
59
|
+
invert(ordered) {
|
|
60
|
+
const next = ordered.filter((id) => !this.picked.has(id));
|
|
61
|
+
this.picked.clear();
|
|
62
|
+
for (const id of next)
|
|
63
|
+
this.picked.add(id);
|
|
64
|
+
}
|
|
65
|
+
// Drops picks no longer in the list, such as after a filter narrows it.
|
|
66
|
+
retain(ordered) {
|
|
67
|
+
const kept = new Set(ordered);
|
|
68
|
+
for (const id of this.picked)
|
|
69
|
+
if (!kept.has(id))
|
|
70
|
+
this.picked.delete(id);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// ── Ids ──────────────────────────────────────────────────────────────────────
|
|
74
|
+
// The same picking as plain lists of ids held in an app's state, the anchor kept by id so it survives the rows moving between clicks.
|
|
75
|
+
// Adds the id when missing and takes it out when there.
|
|
76
|
+
export function toggled(picked, id) {
|
|
77
|
+
return picked.includes(id) ? picked.filter((each) => each !== id) : [...picked, id];
|
|
78
|
+
}
|
|
79
|
+
// Every id between the anchor and the id in the shown order, both included, added to those picked; just the id when the anchor is not shown.
|
|
80
|
+
export function with_range(order, picked, anchor_id, id) {
|
|
81
|
+
const anchor_index = anchor_id === null ? -1 : order.indexOf(anchor_id);
|
|
82
|
+
const index = order.indexOf(id);
|
|
83
|
+
if (anchor_index === -1 || index === -1)
|
|
84
|
+
return [id];
|
|
85
|
+
const run = order.slice(Math.min(anchor_index, index), Math.max(anchor_index, index) + 1);
|
|
86
|
+
return [...picked.filter((each) => !run.includes(each)), ...run];
|
|
87
|
+
}
|
|
88
|
+
// Keeps only the picked ids still shown, in the shown order.
|
|
89
|
+
export function still_shown(order, picked) {
|
|
90
|
+
const picked_ids = new Set(picked);
|
|
91
|
+
return order.filter((id) => picked_ids.has(id));
|
|
92
|
+
}
|
|
93
|
+
// ── Keys ─────────────────────────────────────────────────────────────────────
|
|
94
|
+
// The row an arrow, page, Home or End key moves to, held inside the list, or null for any other key.
|
|
95
|
+
export function row_after_key(key, current, last, rows_per_page) {
|
|
96
|
+
const clamp = (row) => Math.min(last, Math.max(0, row));
|
|
97
|
+
switch (key) {
|
|
98
|
+
case "ArrowDown": return clamp(current + 1);
|
|
99
|
+
case "ArrowUp": return clamp(current - 1);
|
|
100
|
+
case "PageDown": return clamp(current + rows_per_page);
|
|
101
|
+
case "PageUp": return clamp(current - rows_per_page);
|
|
102
|
+
case "Home": return 0;
|
|
103
|
+
case "End": return Math.max(0, last);
|
|
104
|
+
default: return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/web/tables.d.ts
CHANGED
|
@@ -24,4 +24,5 @@ export declare function sort_after_click(sorting: readonly Sorting[], key: strin
|
|
|
24
24
|
export declare function sorting_from(query: URLSearchParams, fallback: Sorting): Sorting;
|
|
25
25
|
export declare function ordered_columns(saved_order: readonly string[], all_columns: readonly string[]): string[];
|
|
26
26
|
export declare function move_column(order: readonly string[], column: string, target: string, after: boolean): string[];
|
|
27
|
+
export declare function moved<Item>(items: readonly Item[], from: number, to: number): Item[];
|
|
27
28
|
export declare function clamp_width(width: number, min?: number, max?: number): number;
|
package/dist/web/tables.js
CHANGED
|
@@ -74,6 +74,14 @@ export function move_column(order, column, target, after) {
|
|
|
74
74
|
const index = rest.indexOf(target) + (after ? 1 : 0);
|
|
75
75
|
return [...rest.slice(0, index), column, ...rest.slice(index)];
|
|
76
76
|
}
|
|
77
|
+
// Moves the item at from to to, such as for a list's up and down buttons.
|
|
78
|
+
export function moved(items, from, to) {
|
|
79
|
+
if (from < 0 || from >= items.length)
|
|
80
|
+
return [...items];
|
|
81
|
+
const rest = items.filter((_, index) => index !== from);
|
|
82
|
+
rest.splice(Math.max(0, Math.min(to, rest.length)), 0, items[from]);
|
|
83
|
+
return rest;
|
|
84
|
+
}
|
|
77
85
|
// A width held between the column's narrowest and widest.
|
|
78
86
|
export function clamp_width(width, min = 48, max = 960) {
|
|
79
87
|
return Math.round(Math.min(max, Math.max(min, width)));
|
package/dist/web/tree.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export declare function by_position<Item extends {
|
|
|
19
19
|
export declare function children_of<Item extends TreeItem>(parent_id: string | null, items: Item[]): Item[];
|
|
20
20
|
export declare function subtree_ids(item_id: string, items: TreeItem[]): Set<string>;
|
|
21
21
|
export declare function nest<Item extends TreeItem>(shown: Item[], all?: Item[]): TreeNode<Item>[];
|
|
22
|
+
export declare function in_tree_order<Item extends TreeItem>(items: Item[], reversed?: boolean): Item[];
|
|
23
|
+
export declare function path_names<Item extends TreeItem>(item: Item, items: Item[], name_of: (item: Item) => string): string[];
|
|
22
24
|
export declare function placement_at(fraction_down: number, allow_inside: boolean): DropPlacement;
|
|
23
25
|
export declare function can_drop(dragged_id: string, target_id: string, items: TreeItem[]): boolean;
|
|
24
26
|
export declare function position_for_drop<Item extends {
|
package/dist/web/tree.js
CHANGED
|
@@ -34,6 +34,27 @@ export function nest(shown, all = shown) {
|
|
|
34
34
|
}
|
|
35
35
|
return top_nodes;
|
|
36
36
|
}
|
|
37
|
+
// Every item in tree order: each followed by the items below it, siblings by position, each set of siblings reversed when asked.
|
|
38
|
+
export function in_tree_order(items, reversed = false) {
|
|
39
|
+
const sorted = [...items].sort(by_position);
|
|
40
|
+
if (reversed)
|
|
41
|
+
sorted.reverse();
|
|
42
|
+
const walk = (nodes) => nodes.flatMap((node) => [node.item, ...walk(node.children)]);
|
|
43
|
+
const ordered = walk(nest(sorted, items));
|
|
44
|
+
const reached = new Set(ordered);
|
|
45
|
+
return [...ordered, ...sorted.filter((item) => !reached.has(item))];
|
|
46
|
+
}
|
|
47
|
+
// The names from the top of the tree down to the item, such as ["Work", "Clients"]; stops at a missing parent or a loop.
|
|
48
|
+
export function path_names(item, items, name_of) {
|
|
49
|
+
const item_by_id = new Map(items.map((each) => [each.id, each]));
|
|
50
|
+
const path = [item];
|
|
51
|
+
let parent = item.parent_id === null ? undefined : item_by_id.get(item.parent_id);
|
|
52
|
+
while (parent && !path.includes(parent)) {
|
|
53
|
+
path.unshift(parent);
|
|
54
|
+
parent = parent.parent_id === null ? undefined : item_by_id.get(parent.parent_id);
|
|
55
|
+
}
|
|
56
|
+
return path.map(name_of);
|
|
57
|
+
}
|
|
37
58
|
// ── Dropping ─────────────────────────────────────────────────────────────────
|
|
38
59
|
// The top and bottom quarters of a row drop beside it and the middle drops into it, or halves when nothing drops inside.
|
|
39
60
|
export function placement_at(fraction_down, allow_inside) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface VirtualWindow {
|
|
2
|
+
start: number;
|
|
3
|
+
end: number;
|
|
4
|
+
offset_px: number;
|
|
5
|
+
total_px: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function virtual_window(total_rows: number, row_height: number, scroll_top: number, viewport_height: number, overscan?: number): VirtualWindow;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Which rows of a long list a scrolled viewport needs, so only those are drawn.
|
|
2
|
+
export function virtual_window(total_rows, row_height, scroll_top, viewport_height, overscan = 8) {
|
|
3
|
+
if (row_height <= 0)
|
|
4
|
+
throw new Error("row_height must be positive");
|
|
5
|
+
const first_visible = Math.floor(Math.max(0, scroll_top) / row_height);
|
|
6
|
+
const visible_count = Math.ceil(viewport_height / row_height);
|
|
7
|
+
const start = Math.max(0, first_visible - overscan);
|
|
8
|
+
const end = Math.min(total_rows, first_visible + visible_count + overscan);
|
|
9
|
+
return { start, end, offset_px: start * row_height, total_px: total_rows * row_height };
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// A change counter a store bumps and a page watches, so only what reads the store redraws when it changes.
|
|
2
|
+
export function create_watch() {
|
|
3
|
+
let version = 0;
|
|
4
|
+
const listeners = new Set();
|
|
5
|
+
return {
|
|
6
|
+
version: () => version,
|
|
7
|
+
bump: () => {
|
|
8
|
+
version += 1;
|
|
9
|
+
for (const listener of listeners)
|
|
10
|
+
listener();
|
|
11
|
+
},
|
|
12
|
+
subscribe: (listener) => {
|
|
13
|
+
listeners.add(listener);
|
|
14
|
+
return () => void listeners.delete(listener);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@x-tech-laboratories/x-libs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Code the X-apps share: agents and their long runs, passwords, durations, and the web page's helpers and widgets",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"repository": {
|