@x-tech-laboratories/x-libs 0.3.0 → 0.6.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 +63 -10
- package/dist/dates.d.ts +9 -0
- package/dist/dates.js +60 -0
- package/dist/durations.d.ts +8 -1
- package/dist/durations.js +9 -5
- package/dist/electron/bridge.d.ts +30 -0
- package/dist/electron/bridge.js +32 -0
- package/dist/electron/window.d.ts +85 -0
- package/dist/electron/window.js +85 -0
- package/dist/web/menus.d.ts +4 -7
- package/dist/web/menus.js +28 -86
- package/dist/web/react/badges.d.ts +26 -0
- package/dist/web/react/badges.js +28 -0
- package/dist/web/react/controls.d.ts +37 -0
- package/dist/web/react/controls.js +44 -0
- package/dist/web/react/dialog.d.ts +35 -0
- package/dist/web/react/dialog.js +78 -0
- package/dist/web/react/layout.d.ts +80 -0
- package/dist/web/react/layout.js +71 -0
- package/dist/web/react/menus.d.ts +31 -0
- package/dist/web/react/menus.js +159 -0
- package/dist/web/react/shell.d.ts +37 -0
- package/dist/web/react/shell.js +81 -0
- package/dist/web/react/shortcuts.d.ts +23 -0
- package/dist/web/react/shortcuts.js +95 -0
- package/dist/web/{preact → react}/split.d.ts +4 -4
- package/dist/web/{preact → react}/split.js +9 -10
- package/dist/web/react/states.d.ts +27 -0
- package/dist/web/react/states.js +49 -0
- package/dist/web/react/table.d.ts +46 -0
- package/dist/web/react/table.js +210 -0
- package/dist/web/react/time.d.ts +4 -0
- package/dist/web/react/time.js +9 -0
- package/dist/web/{toasts.d.ts → react/toasts.d.ts} +4 -1
- package/dist/web/react/toasts.js +41 -0
- package/dist/web/react/tree.d.ts +23 -0
- package/dist/web/react/tree.js +168 -0
- package/dist/web/states.d.ts +1 -8
- package/dist/web/states.js +6 -43
- package/dist/web/tables.d.ts +21 -14
- package/dist/web/tables.js +69 -33
- package/dist/web/theme.d.ts +7 -2
- package/dist/web/theme.js +35 -22
- package/dist/web/tree.d.ts +35 -0
- package/dist/web/tree.js +107 -0
- package/package.json +15 -4
- package/dist/web/badges.d.ts +0 -10
- package/dist/web/badges.js +0 -30
- package/dist/web/controls.d.ts +0 -6
- package/dist/web/controls.js +0 -55
- package/dist/web/dialogs.d.ts +0 -18
- package/dist/web/dialogs.js +0 -49
- package/dist/web/layout.d.ts +0 -28
- package/dist/web/layout.js +0 -53
- package/dist/web/preact/controls.d.ts +0 -24
- package/dist/web/preact/controls.js +0 -36
- package/dist/web/preact/dialog.d.ts +0 -11
- package/dist/web/preact/dialog.js +0 -20
- package/dist/web/preact/layout.d.ts +0 -80
- package/dist/web/preact/layout.js +0 -69
- package/dist/web/preact/menus.d.ts +0 -18
- package/dist/web/preact/menus.js +0 -29
- package/dist/web/preact/table.d.ts +0 -18
- package/dist/web/preact/table.js +0 -13
- package/dist/web/shell.d.ts +0 -7
- package/dist/web/shell.js +0 -51
- package/dist/web/toasts.js +0 -22
|
@@ -0,0 +1,9 @@
|
|
|
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.
|
|
3
|
+
import { age, stamp } from "../format.js";
|
|
4
|
+
// A time as its age, such as "3h ago", or the missing text, a dash unless told otherwise, when there is none.
|
|
5
|
+
export function Age({ epoch, missing = "—" }) {
|
|
6
|
+
if (!epoch)
|
|
7
|
+
return _jsx(_Fragment, { children: missing });
|
|
8
|
+
return _jsx("span", { title: stamp(epoch), children: age(epoch) });
|
|
9
|
+
}
|
|
@@ -3,12 +3,15 @@ export interface ToastOptions {
|
|
|
3
3
|
link?: {
|
|
4
4
|
href: string;
|
|
5
5
|
text: string;
|
|
6
|
-
};
|
|
6
|
+
} | null;
|
|
7
7
|
action?: {
|
|
8
8
|
text: string;
|
|
9
9
|
run: () => void;
|
|
10
10
|
};
|
|
11
11
|
sticky?: boolean;
|
|
12
12
|
duration_in_milliseconds?: number;
|
|
13
|
+
on_close?: (reason: ToastCloseReason) => void;
|
|
13
14
|
}
|
|
15
|
+
export type ToastCloseReason = "timeout" | "dismissed" | "action" | "closed";
|
|
14
16
|
export declare function toast(message: string, options?: ToastOptions): () => void;
|
|
17
|
+
export declare function Toasts(): import("react").JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Short notes in the page's corner after something happened, with an optional link or action such as Undo.
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
let shown = [];
|
|
5
|
+
let next_id = 1;
|
|
6
|
+
const listeners = new Set();
|
|
7
|
+
function publish(toasts) {
|
|
8
|
+
shown = toasts;
|
|
9
|
+
for (const listener of listeners)
|
|
10
|
+
listener(shown);
|
|
11
|
+
}
|
|
12
|
+
// Takes the toast away and tells its owner why, once only.
|
|
13
|
+
function close_toast(id, reason) {
|
|
14
|
+
const item = shown.find((each) => each.id === id);
|
|
15
|
+
if (!item)
|
|
16
|
+
return;
|
|
17
|
+
publish(shown.filter((each) => each.id !== id));
|
|
18
|
+
item.on_close?.(reason);
|
|
19
|
+
}
|
|
20
|
+
// Shows the message and returns a function that closes it early.
|
|
21
|
+
export function toast(message, options = {}) {
|
|
22
|
+
const id = next_id++;
|
|
23
|
+
publish([...shown, { ...options, id, message }]);
|
|
24
|
+
if (!(options.sticky ?? options.tone === "bad"))
|
|
25
|
+
setTimeout(() => close_toast(id, "timeout"), options.duration_in_milliseconds ?? 6000);
|
|
26
|
+
return () => close_toast(id, "closed");
|
|
27
|
+
}
|
|
28
|
+
// ── Host ─────────────────────────────────────────────────────────────────────
|
|
29
|
+
// Where toasts stack; the Shell holds one, and a page without the Shell renders its own.
|
|
30
|
+
export function Toasts() {
|
|
31
|
+
const [toasts, set_toasts] = useState(shown);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
listeners.add(set_toasts);
|
|
34
|
+
return () => void listeners.delete(set_toasts);
|
|
35
|
+
}, []);
|
|
36
|
+
return (_jsx("div", { id: "toasts", className: "toasts", children: toasts.map((item) => {
|
|
37
|
+
const tone = item.tone ?? "info";
|
|
38
|
+
const close = () => close_toast(item.id, "dismissed");
|
|
39
|
+
return (_jsxs("div", { className: `toast tone-${tone}`, role: tone === "bad" ? "alert" : "status", children: [_jsx("span", { children: item.message }), item.link && _jsx("a", { href: item.link.href, onClick: close, children: item.link.text }), item.action && _jsx("button", { className: "toast-action", onClick: () => { close_toast(item.id, "action"); item.action.run(); }, children: item.action.text }), _jsx("button", { className: "icon", "aria-label": "Dismiss", onClick: close, children: "\u00D7" })] }, item.id));
|
|
40
|
+
}) }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type MouseEvent, type ReactNode } from "react";
|
|
2
|
+
import { type DropPlacement, type TreeItem, type TreeMove } from "../tree.ts";
|
|
3
|
+
export type { DropPlacement, TreeItem, TreeMove } from "../tree.ts";
|
|
4
|
+
interface TreeProps<Item extends TreeItem> {
|
|
5
|
+
items: Item[];
|
|
6
|
+
label: string;
|
|
7
|
+
render_item: (item: Item) => ReactNode;
|
|
8
|
+
render_extra?: (item: Item) => ReactNode;
|
|
9
|
+
name_of?: (item: Item) => string;
|
|
10
|
+
collapsed: ReadonlySet<string>;
|
|
11
|
+
on_collapsed_change: (collapsed: Set<string>) => void;
|
|
12
|
+
selected_id?: string | null;
|
|
13
|
+
on_select?: (item: Item) => void;
|
|
14
|
+
on_move?: (item: Item, move: TreeMove) => void;
|
|
15
|
+
can_drop?: (dragged: Item, target: Item, placement: DropPlacement) => boolean;
|
|
16
|
+
allow_inside?: boolean;
|
|
17
|
+
new_item?: {
|
|
18
|
+
parent_id: string | null;
|
|
19
|
+
form: ReactNode;
|
|
20
|
+
} | null;
|
|
21
|
+
on_context_menu?: (event: MouseEvent<HTMLElement>, item: Item) => void;
|
|
22
|
+
}
|
|
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;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A tree of rows: fold and unfold, walk it with the arrow keys, and reorder it by dragging or with Alt and the arrow keys.
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { by_position, can_drop as is_outside_subtree, key_drop, move_for_drop, nest, placement_at, } from "../tree.js";
|
|
5
|
+
const KEY_MOVES = { ArrowUp: "up", ArrowDown: "down", ArrowRight: "indent", ArrowLeft: "outdent" };
|
|
6
|
+
// The rows a person can see, top to bottom, skipping those inside folded rows.
|
|
7
|
+
function visible_nodes(nodes, collapsed) {
|
|
8
|
+
return nodes.flatMap((node) => [node, ...(collapsed.has(node.item.id) ? [] : visible_nodes(node.children, collapsed))]);
|
|
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, }) {
|
|
11
|
+
const [focused_id, set_focused_id] = useState(null);
|
|
12
|
+
const [dragged_id, set_dragged_id] = useState(null);
|
|
13
|
+
const [drop_target, set_drop_target] = useState(null);
|
|
14
|
+
const row_elements = useRef(new Map());
|
|
15
|
+
const moves_focus = useRef(false);
|
|
16
|
+
const roots = nest([...items].sort(by_position), items);
|
|
17
|
+
const shown = visible_nodes(roots, collapsed);
|
|
18
|
+
const tab_stop = [focused_id, selected_id].find((id) => id !== null && shown.some((node) => node.item.id === id)) ?? shown[0]?.item.id;
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!moves_focus.current || focused_id === null)
|
|
21
|
+
return;
|
|
22
|
+
moves_focus.current = false;
|
|
23
|
+
row_elements.current.get(focused_id)?.focus();
|
|
24
|
+
}, [focused_id, items]);
|
|
25
|
+
// ── Folding ────────────────────────────────────────────────────────────────
|
|
26
|
+
function set_collapsed(id, is_collapsed) {
|
|
27
|
+
if (collapsed.has(id) === is_collapsed)
|
|
28
|
+
return;
|
|
29
|
+
const next = new Set(collapsed);
|
|
30
|
+
if (is_collapsed)
|
|
31
|
+
next.add(id);
|
|
32
|
+
else
|
|
33
|
+
next.delete(id);
|
|
34
|
+
on_collapsed_change(next);
|
|
35
|
+
}
|
|
36
|
+
// ── Moving ─────────────────────────────────────────────────────────────────
|
|
37
|
+
function is_allowed(dragged, target, placement) {
|
|
38
|
+
if (placement === "inside" && !allow_inside)
|
|
39
|
+
return false;
|
|
40
|
+
return is_outside_subtree(dragged.id, target.id, items) && (can_drop?.(dragged, target, placement) ?? true);
|
|
41
|
+
}
|
|
42
|
+
// A row dropped into a folded one unfolds it, so the moved row stays in sight.
|
|
43
|
+
function move(item, target, placement) {
|
|
44
|
+
if (placement === "inside")
|
|
45
|
+
set_collapsed(target.id, false);
|
|
46
|
+
on_move(item, move_for_drop(item, target, placement, items));
|
|
47
|
+
}
|
|
48
|
+
function placement_over(event, target) {
|
|
49
|
+
const dragged = items.find((item) => item.id === dragged_id);
|
|
50
|
+
if (!dragged)
|
|
51
|
+
return null;
|
|
52
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
53
|
+
const placement = placement_at(box.height > 0 ? (event.clientY - box.top) / box.height : 0.5, allow_inside);
|
|
54
|
+
return is_allowed(dragged, target, placement) ? placement : null;
|
|
55
|
+
}
|
|
56
|
+
function drag_over(event, target) {
|
|
57
|
+
const placement = placement_over(event, target);
|
|
58
|
+
if (!placement)
|
|
59
|
+
return;
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
if (event.dataTransfer)
|
|
62
|
+
event.dataTransfer.dropEffect = "move";
|
|
63
|
+
if (drop_target?.id !== target.id || drop_target.placement !== placement)
|
|
64
|
+
set_drop_target({ id: target.id, placement });
|
|
65
|
+
}
|
|
66
|
+
function drop(event, target) {
|
|
67
|
+
const placement = placement_over(event, target);
|
|
68
|
+
const dragged = items.find((item) => item.id === dragged_id);
|
|
69
|
+
end_drag();
|
|
70
|
+
if (!placement || !dragged)
|
|
71
|
+
return;
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
move(dragged, target, placement);
|
|
74
|
+
}
|
|
75
|
+
function end_drag() {
|
|
76
|
+
set_dragged_id(null);
|
|
77
|
+
set_drop_target(null);
|
|
78
|
+
}
|
|
79
|
+
// ── Keyboard ───────────────────────────────────────────────────────────────
|
|
80
|
+
function focus_row(id) {
|
|
81
|
+
if (id === undefined)
|
|
82
|
+
return;
|
|
83
|
+
moves_focus.current = true;
|
|
84
|
+
set_focused_id(id);
|
|
85
|
+
}
|
|
86
|
+
function key_down(event) {
|
|
87
|
+
const row = event.target.closest("[role='treeitem']");
|
|
88
|
+
if (row !== event.target)
|
|
89
|
+
return;
|
|
90
|
+
const index = shown.findIndex((node) => node.item.id === row.dataset.id);
|
|
91
|
+
const node = shown[index];
|
|
92
|
+
if (!node)
|
|
93
|
+
return;
|
|
94
|
+
const { item } = node;
|
|
95
|
+
const is_open = node.children.length > 0 && !collapsed.has(item.id);
|
|
96
|
+
if (event.altKey && on_move && KEY_MOVES[event.key]) {
|
|
97
|
+
const key_target = key_drop(item, KEY_MOVES[event.key], items);
|
|
98
|
+
if (key_target && is_allowed(item, key_target.target, key_target.placement)) {
|
|
99
|
+
move(item, key_target.target, key_target.placement);
|
|
100
|
+
focus_row(item.id);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (event.altKey || event.ctrlKey || event.metaKey) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
else if (event.key === "ArrowDown")
|
|
107
|
+
focus_row(shown[index + 1]?.item.id);
|
|
108
|
+
else if (event.key === "ArrowUp")
|
|
109
|
+
focus_row(shown[index - 1]?.item.id);
|
|
110
|
+
else if (event.key === "Home")
|
|
111
|
+
focus_row(shown[0]?.item.id);
|
|
112
|
+
else if (event.key === "End")
|
|
113
|
+
focus_row(shown.at(-1)?.item.id);
|
|
114
|
+
else if (event.key === "ArrowRight") {
|
|
115
|
+
if (is_open)
|
|
116
|
+
focus_row(node.children[0].item.id);
|
|
117
|
+
else if (node.children.length > 0)
|
|
118
|
+
set_collapsed(item.id, false);
|
|
119
|
+
else
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
else if (event.key === "ArrowLeft") {
|
|
123
|
+
if (is_open)
|
|
124
|
+
set_collapsed(item.id, true);
|
|
125
|
+
else if (item.parent_id !== null && shown.some((each) => each.item.id === item.parent_id))
|
|
126
|
+
focus_row(item.parent_id);
|
|
127
|
+
else
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
else if ((event.key === "Enter" || event.key === " ") && on_select)
|
|
131
|
+
on_select(item);
|
|
132
|
+
else
|
|
133
|
+
return;
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
}
|
|
136
|
+
// ── Rows ───────────────────────────────────────────────────────────────────
|
|
137
|
+
function new_item_row(parent_id) {
|
|
138
|
+
if (!new_item || new_item.parent_id !== parent_id)
|
|
139
|
+
return null;
|
|
140
|
+
return _jsx("li", { role: "none", className: "tree-new", children: new_item.form });
|
|
141
|
+
}
|
|
142
|
+
function branch(node, depth) {
|
|
143
|
+
const { item, children } = node;
|
|
144
|
+
const has_children = children.length > 0;
|
|
145
|
+
const is_collapsed = collapsed.has(item.id);
|
|
146
|
+
const name = name_of?.(item) ?? "row";
|
|
147
|
+
const new_child = new_item_row(item.id);
|
|
148
|
+
const row_class = [
|
|
149
|
+
"tree-row",
|
|
150
|
+
dragged_id === item.id && "dragging",
|
|
151
|
+
drop_target?.id === item.id && `drop-${drop_target.placement}`,
|
|
152
|
+
].filter(Boolean).join(" ");
|
|
153
|
+
return (_jsxs("li", { ref: (element) => { if (element)
|
|
154
|
+
row_elements.current.set(item.id, element);
|
|
155
|
+
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 ? selected_id === item.id : undefined, tabIndex: tab_stop === item.id ? 0 : -1, onFocus: (event) => { if (event.target === event.currentTarget)
|
|
157
|
+
set_focused_id(item.id); }, children: [_jsxs("div", { className: row_class, draggable: on_move ? true : undefined, onDragStart: (event) => {
|
|
158
|
+
event.dataTransfer?.setData("text/plain", item.id);
|
|
159
|
+
if (event.dataTransfer)
|
|
160
|
+
event.dataTransfer.effectAllowed = "move";
|
|
161
|
+
set_dragged_id(item.id);
|
|
162
|
+
}, 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
|
|
164
|
+
? (_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
|
+
: _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
|
+
}
|
|
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)] }));
|
|
168
|
+
}
|
package/dist/web/states.d.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
import { type Child } from "./dom.ts";
|
|
2
1
|
export declare class LoginNeeded extends Error {
|
|
3
2
|
}
|
|
4
|
-
export declare function
|
|
5
|
-
export declare function empty(title: string, hint?: Child): HTMLElement;
|
|
6
|
-
export declare function failure(error: unknown, retry: () => void): HTMLElement;
|
|
7
|
-
export declare function load(target: HTMLElement, work: () => Promise<Child>, options?: {
|
|
8
|
-
quiet?: boolean;
|
|
9
|
-
}): Promise<boolean>;
|
|
10
|
-
export declare function attempt<Answer>(target: HTMLElement, quiet: boolean, work: () => Promise<Answer>, retry: () => void): Promise<Answer | null>;
|
|
3
|
+
export declare function pass_login_needed(error: unknown): boolean;
|
package/dist/web/states.js
CHANGED
|
@@ -1,47 +1,10 @@
|
|
|
1
|
-
// What a
|
|
2
|
-
import { h, nodes } from "./dom.js";
|
|
3
|
-
// ── Login ────────────────────────────────────────────────────────────────────
|
|
4
|
-
// Thrown by a request whose session has lapsed; loading passes it on so the page can ask for a login.
|
|
1
|
+
// What a request throws when the session has lapsed, so a page can tell it from any other failure and ask for a login.
|
|
5
2
|
export class LoginNeeded extends Error {
|
|
6
3
|
}
|
|
7
|
-
//
|
|
8
|
-
export function
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
export function empty(title, hint = null) {
|
|
12
|
-
return h("div", { class: "empty" }, h("p", { class: "empty-title" }, title), hint && h("p", { class: "muted" }, hint));
|
|
13
|
-
}
|
|
14
|
-
export function failure(error, retry) {
|
|
15
|
-
return h("div", { class: "failure", role: "alert" }, h("p", {}, `Could not load this: ${error instanceof Error ? error.message : String(error)}`), h("button", { onclick: retry }, "Retry"));
|
|
16
|
-
}
|
|
17
|
-
// ── Loading ──────────────────────────────────────────────────────────────────
|
|
18
|
-
// Fills the target with what the work builds, showing a skeleton first and an error with Retry if it fails.
|
|
19
|
-
export async function load(target, work, options = {}) {
|
|
20
|
-
if (!options.quiet)
|
|
21
|
-
target.replaceChildren(skeleton());
|
|
22
|
-
try {
|
|
23
|
-
target.replaceChildren(...nodes(await work()));
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
catch (error) {
|
|
27
|
-
// A login is the page's to ask for; a quiet refresh keeps what is already shown.
|
|
28
|
-
if (error instanceof LoginNeeded)
|
|
29
|
-
throw error;
|
|
30
|
-
if (!options.quiet)
|
|
31
|
-
target.replaceChildren(failure(error, () => void load(target, work)));
|
|
4
|
+
// Hands a LoginNeeded on as an unhandled rejection, which the page's login listens for.
|
|
5
|
+
export function pass_login_needed(error) {
|
|
6
|
+
if (!(error instanceof LoginNeeded))
|
|
32
7
|
return false;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// Fetches what a view needs, putting an error with Retry in the target when a first load fails; null when it failed.
|
|
36
|
-
export async function attempt(target, quiet, work, retry) {
|
|
37
|
-
try {
|
|
38
|
-
return await work();
|
|
39
|
-
}
|
|
40
|
-
catch (error) {
|
|
41
|
-
if (error instanceof LoginNeeded)
|
|
42
|
-
throw error;
|
|
43
|
-
if (!quiet)
|
|
44
|
-
target.replaceChildren(failure(error, retry));
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
8
|
+
void Promise.reject(error);
|
|
9
|
+
return true;
|
|
47
10
|
}
|
package/dist/web/tables.d.ts
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
|
-
import { type Child } from "./dom.ts";
|
|
2
|
-
export interface Column<Row> {
|
|
3
|
-
label: string;
|
|
4
|
-
cell: (row: Row) => Child;
|
|
5
|
-
sort?: (row: Row) => string | number | null;
|
|
6
|
-
class?: string;
|
|
7
|
-
}
|
|
8
1
|
export interface Sorting {
|
|
9
2
|
key: string;
|
|
10
3
|
descending: boolean;
|
|
11
4
|
}
|
|
12
|
-
export interface
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
5
|
+
export interface SortableColumn<Row> {
|
|
6
|
+
label: string;
|
|
7
|
+
id?: string;
|
|
8
|
+
sort?: (row: Row) => string | number | null;
|
|
9
|
+
}
|
|
10
|
+
export type SortValue = string | number | null | undefined;
|
|
11
|
+
export declare function column_id(column: {
|
|
12
|
+
id?: string;
|
|
13
|
+
label: string;
|
|
14
|
+
}): string;
|
|
15
|
+
export declare function sorting_list(sorting: Sorting | readonly Sorting[] | undefined): Sorting[];
|
|
16
|
+
export declare function compare_values(first: SortValue, second: SortValue, descending?: boolean): number;
|
|
17
|
+
export declare function sorted_rows<Row>(columns: SortableColumn<Row>[], rows: Row[], sorting: Sorting | readonly Sorting[] | undefined): Row[];
|
|
18
|
+
export interface TreeRow<Row> {
|
|
19
|
+
row: Row;
|
|
20
|
+
children: TreeRow<Row>[];
|
|
16
21
|
}
|
|
17
|
-
export declare function
|
|
22
|
+
export declare function sorted_tree<Row>(columns: SortableColumn<Row>[], rows: Row[], sorting: Sorting | readonly Sorting[] | undefined, get_children: (row: Row) => Row[] | undefined): TreeRow<Row>[];
|
|
23
|
+
export declare function sort_after_click(sorting: readonly Sorting[], key: string, adding: boolean): Sorting[];
|
|
18
24
|
export declare function sorting_from(query: URLSearchParams, fallback: Sorting): Sorting;
|
|
19
|
-
export declare function
|
|
20
|
-
export declare function
|
|
25
|
+
export declare function ordered_columns(saved_order: readonly string[], all_columns: readonly string[]): string[];
|
|
26
|
+
export declare function move_column(order: readonly string[], column: string, target: string, after: boolean): string[];
|
|
27
|
+
export declare function clamp_width(width: number, min?: number, max?: number): number;
|
package/dist/web/tables.js
CHANGED
|
@@ -1,44 +1,80 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// The order a sorted table shows its rows in, the sorting a click or a view's query asks for, and the order of its columns.
|
|
2
|
+
export function column_id(column) {
|
|
3
|
+
return column.id ?? column.label;
|
|
4
|
+
}
|
|
5
|
+
// One sorting or several as a list, the first deciding first.
|
|
6
|
+
export function sorting_list(sorting) {
|
|
7
|
+
if (!sorting)
|
|
8
|
+
return [];
|
|
9
|
+
return Array.isArray(sorting) ? [...sorting] : [sorting];
|
|
10
|
+
}
|
|
11
|
+
const is_empty = (value) => value === null || value === undefined || value === "";
|
|
12
|
+
// Numbers by size and text by its words and numbers, with an empty value last whichever way the column sorts.
|
|
13
|
+
export function compare_values(first, second, descending = false) {
|
|
14
|
+
if (is_empty(first) || is_empty(second))
|
|
15
|
+
return Number(is_empty(first)) - Number(is_empty(second));
|
|
16
|
+
const order = typeof first === "number" && typeof second === "number"
|
|
17
|
+
? first - second
|
|
18
|
+
: String(first).localeCompare(String(second), undefined, { numeric: true, sensitivity: "base" });
|
|
19
|
+
return descending ? -order : order;
|
|
20
|
+
}
|
|
21
|
+
// The rows in the order the sortings ask for, the later ones breaking ties, and rows missing a value last.
|
|
5
22
|
export function sorted_rows(columns, rows, sorting) {
|
|
6
|
-
const
|
|
7
|
-
|
|
23
|
+
const keys = sorting_list(sorting).flatMap((key) => {
|
|
24
|
+
const column = columns.find((candidate) => column_id(candidate) === key.key && candidate.sort);
|
|
25
|
+
return column?.sort ? [{ value: column.sort, descending: key.descending }] : [];
|
|
26
|
+
});
|
|
27
|
+
if (keys.length === 0)
|
|
8
28
|
return rows;
|
|
9
|
-
const value = column.sort;
|
|
10
29
|
return [...rows].sort((left, right) => {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
30
|
+
for (const key of keys) {
|
|
31
|
+
const order = compare_values(key.value(left), key.value(right), key.descending);
|
|
32
|
+
if (order !== 0)
|
|
33
|
+
return order;
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
14
36
|
});
|
|
15
37
|
}
|
|
38
|
+
// Sorts each level of a tree on its own, so children stay under their parents.
|
|
39
|
+
export function sorted_tree(columns, rows, sorting, get_children) {
|
|
40
|
+
return sorted_rows(columns, rows, sorting).map((row) => ({ row, children: sorted_tree(columns, get_children(row) ?? [], sorting, get_children) }));
|
|
41
|
+
}
|
|
42
|
+
// A plain click sorts by that column alone; a Shift+click adds it to the sort or flips it; a third click takes it out.
|
|
43
|
+
export function sort_after_click(sorting, key, adding) {
|
|
44
|
+
const current = sorting.find((each) => each.key === key);
|
|
45
|
+
const next = !current ? { key, descending: false } : current.descending ? null : { key, descending: true };
|
|
46
|
+
if (!adding) {
|
|
47
|
+
if (current && sorting.length === 1)
|
|
48
|
+
return next ? [next] : [];
|
|
49
|
+
return [{ key, descending: false }];
|
|
50
|
+
}
|
|
51
|
+
if (!current)
|
|
52
|
+
return [...sorting, next];
|
|
53
|
+
return next ? sorting.map((each) => (each.key === key ? next : each)) : sorting.filter((each) => each.key !== key);
|
|
54
|
+
}
|
|
16
55
|
// The sorting a view's query asks for, such as ?sort=Name&dir=desc.
|
|
17
56
|
export function sorting_from(query, fallback) {
|
|
18
57
|
const key = query.get("sort");
|
|
19
58
|
return key ? { key, descending: query.get("dir") === "desc" } : fallback;
|
|
20
59
|
}
|
|
21
|
-
// ──
|
|
22
|
-
//
|
|
23
|
-
export function
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return
|
|
37
|
-
}
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (page === 1 && !has_more)
|
|
42
|
-
return null;
|
|
43
|
-
return h("nav", { class: "pager", "aria-label": "Pages" }, h("button", { disabled: page <= 1, onclick: () => go(page - 1) }, "← Newer"), h("span", { class: "muted" }, `Page ${page}`), h("button", { disabled: !has_more, onclick: () => go(page + 1) }, "Older →"));
|
|
60
|
+
// ── Columns ──────────────────────────────────────────────────────────────────
|
|
61
|
+
// Every column in its saved order, leaving out ones gone since and placing a new one just after the column before it.
|
|
62
|
+
export function ordered_columns(saved_order, all_columns) {
|
|
63
|
+
const ordered = saved_order.filter((column, index) => all_columns.includes(column) && saved_order.indexOf(column) === index);
|
|
64
|
+
all_columns.forEach((column, index) => {
|
|
65
|
+
if (ordered.includes(column))
|
|
66
|
+
return;
|
|
67
|
+
ordered.splice(index === 0 ? 0 : ordered.indexOf(all_columns[index - 1]) + 1, 0, column);
|
|
68
|
+
});
|
|
69
|
+
return ordered;
|
|
70
|
+
}
|
|
71
|
+
// Moves a column to just before or after another one.
|
|
72
|
+
export function move_column(order, column, target, after) {
|
|
73
|
+
const rest = order.filter((each) => each !== column);
|
|
74
|
+
const index = rest.indexOf(target) + (after ? 1 : 0);
|
|
75
|
+
return [...rest.slice(0, index), column, ...rest.slice(index)];
|
|
76
|
+
}
|
|
77
|
+
// A width held between the column's narrowest and widest.
|
|
78
|
+
export function clamp_width(width, min = 48, max = 960) {
|
|
79
|
+
return Math.round(Math.min(max, Math.max(min, width)));
|
|
44
80
|
}
|
package/dist/web/theme.d.ts
CHANGED
|
@@ -2,8 +2,13 @@ export type Theme = "system" | "light" | "dark";
|
|
|
2
2
|
export interface ThemeChoice {
|
|
3
3
|
get(): Theme;
|
|
4
4
|
set(theme: Theme): void;
|
|
5
|
-
on_change(listener: (theme: Theme) => void): void;
|
|
5
|
+
on_change(listener: (theme: Theme) => void): () => void;
|
|
6
6
|
apply(theme?: Theme): void;
|
|
7
7
|
}
|
|
8
|
-
export
|
|
8
|
+
export interface ThemeStorage {
|
|
9
|
+
read(): Theme | undefined;
|
|
10
|
+
write(theme: Theme): void;
|
|
11
|
+
}
|
|
12
|
+
export declare function local_theme_storage(storage_key: string): ThemeStorage;
|
|
13
|
+
export declare function theme_choice(where: string | ThemeStorage): ThemeChoice;
|
|
9
14
|
export declare function next_theme(theme: Theme): Theme;
|
package/dist/web/theme.js
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
|
-
// The light, dark or system theme, kept in this browser and set as data-theme on the page.
|
|
2
|
-
// The choice kept under the storage key, such as "x-nix-theme".
|
|
3
|
-
export function
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
// The light, dark or system theme, kept in this browser or the app's own settings, and set as data-theme on the page.
|
|
2
|
+
// The choice kept in this browser under the storage key, such as "x-nix-theme".
|
|
3
|
+
export function local_theme_storage(storage_key) {
|
|
4
|
+
return {
|
|
5
|
+
read: () => {
|
|
6
|
+
try {
|
|
7
|
+
const saved = localStorage.getItem(storage_key);
|
|
8
|
+
return saved === "light" || saved === "dark" ? saved : undefined;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
write: (theme) => {
|
|
15
|
+
try {
|
|
16
|
+
if (theme === "system")
|
|
17
|
+
localStorage.removeItem(storage_key);
|
|
18
|
+
else
|
|
19
|
+
localStorage.setItem(storage_key, theme);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Without storage the choice lasts until the page is closed.
|
|
23
|
+
}
|
|
24
|
+
},
|
|
13
25
|
};
|
|
26
|
+
}
|
|
27
|
+
// The choice kept under a storage key in this browser, or in the storage the app hands over.
|
|
28
|
+
export function theme_choice(where) {
|
|
29
|
+
const storage = typeof where === "string" ? local_theme_storage(where) : where;
|
|
30
|
+
const listeners = new Set();
|
|
31
|
+
const get = () => storage.read() ?? "system";
|
|
14
32
|
const apply = (theme = get()) => {
|
|
15
33
|
if (theme === "system")
|
|
16
34
|
delete document.documentElement.dataset.theme;
|
|
@@ -20,18 +38,13 @@ export function theme_choice(storage_key) {
|
|
|
20
38
|
listener(theme);
|
|
21
39
|
};
|
|
22
40
|
const set = (theme) => {
|
|
23
|
-
|
|
24
|
-
if (theme === "system")
|
|
25
|
-
localStorage.removeItem(storage_key);
|
|
26
|
-
else
|
|
27
|
-
localStorage.setItem(storage_key, theme);
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
// Without storage the choice lasts until the page is closed.
|
|
31
|
-
}
|
|
41
|
+
storage.write(theme);
|
|
32
42
|
apply(theme);
|
|
33
43
|
};
|
|
34
|
-
return { get, set, apply, on_change: (listener) =>
|
|
44
|
+
return { get, set, apply, on_change: (listener) => {
|
|
45
|
+
listeners.add(listener);
|
|
46
|
+
return () => void listeners.delete(listener);
|
|
47
|
+
} };
|
|
35
48
|
}
|
|
36
49
|
// The header's button steps through system, light and dark.
|
|
37
50
|
export function next_theme(theme) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface TreeItem {
|
|
2
|
+
id: string;
|
|
3
|
+
parent_id: string | null;
|
|
4
|
+
position: number;
|
|
5
|
+
}
|
|
6
|
+
export interface TreeNode<Item> {
|
|
7
|
+
item: Item;
|
|
8
|
+
children: TreeNode<Item>[];
|
|
9
|
+
}
|
|
10
|
+
export type SidePlacement = "before" | "after";
|
|
11
|
+
export type DropPlacement = SidePlacement | "inside";
|
|
12
|
+
export interface TreeMove {
|
|
13
|
+
parent_id: string | null;
|
|
14
|
+
position: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function by_position<Item extends {
|
|
17
|
+
position: number;
|
|
18
|
+
}>(first: Item, second: Item): number;
|
|
19
|
+
export declare function children_of<Item extends TreeItem>(parent_id: string | null, items: Item[]): Item[];
|
|
20
|
+
export declare function subtree_ids(item_id: string, items: TreeItem[]): Set<string>;
|
|
21
|
+
export declare function nest<Item extends TreeItem>(shown: Item[], all?: Item[]): TreeNode<Item>[];
|
|
22
|
+
export declare function placement_at(fraction_down: number, allow_inside: boolean): DropPlacement;
|
|
23
|
+
export declare function can_drop(dragged_id: string, target_id: string, items: TreeItem[]): boolean;
|
|
24
|
+
export declare function position_for_drop<Item extends {
|
|
25
|
+
id: string;
|
|
26
|
+
position: number;
|
|
27
|
+
}>(dragged: Item, target: Item, placement: SidePlacement, items: Item[]): number;
|
|
28
|
+
export declare function move_for_drop<Item extends TreeItem>(dragged: Item, target: Item, placement: DropPlacement, items: Item[]): TreeMove;
|
|
29
|
+
export declare function moves_for_drop<Item extends TreeItem>(dragged: Item[], target: Item, placement: DropPlacement, items: Item[]): TreeMove[];
|
|
30
|
+
export type KeyMove = "up" | "down" | "indent" | "outdent";
|
|
31
|
+
export declare function key_drop<Item extends TreeItem>(item: Item, direction: KeyMove, items: Item[]): {
|
|
32
|
+
target: Item;
|
|
33
|
+
placement: DropPlacement;
|
|
34
|
+
} | null;
|
|
35
|
+
export declare function move_for_key<Item extends TreeItem>(item: Item, direction: KeyMove, items: Item[]): TreeMove | null;
|