@x-tech-laboratories/x-libs 0.4.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 -13
- 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 -0
- package/dist/web/menus.js +28 -1
- package/dist/web/{preact → react}/badges.d.ts +9 -9
- package/dist/web/{preact → react}/badges.js +6 -6
- package/dist/web/{preact → react}/controls.d.ts +7 -7
- package/dist/web/{preact → react}/controls.js +7 -6
- package/dist/web/{preact → react}/dialog.d.ts +10 -10
- package/dist/web/{preact → react}/dialog.js +14 -9
- package/dist/web/{preact → react}/layout.d.ts +33 -33
- 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/{preact → react}/states.d.ts +7 -7
- package/dist/web/{preact → react}/states.js +6 -5
- package/dist/web/react/table.d.ts +46 -0
- package/dist/web/react/table.js +210 -0
- package/dist/web/{preact → react}/time.d.ts +1 -1
- package/dist/web/{preact → react}/time.js +1 -1
- package/dist/web/{preact → react}/toasts.d.ts +3 -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/tables.d.ts +18 -1
- package/dist/web/tables.js +70 -8
- package/dist/web/theme.d.ts +6 -1
- package/dist/web/theme.js +31 -21
- package/dist/web/tree.d.ts +35 -0
- package/dist/web/tree.js +107 -0
- package/package.json +15 -4
- package/dist/web/preact/layout.js +0 -71
- package/dist/web/preact/menus.d.ts +0 -19
- package/dist/web/preact/menus.js +0 -70
- package/dist/web/preact/shell.d.ts +0 -27
- package/dist/web/preact/shell.js +0 -63
- package/dist/web/preact/table.d.ts +0 -23
- package/dist/web/preact/table.js +0 -20
- package/dist/web/preact/toasts.js +0 -34
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface Shortcut {
|
|
2
|
+
keys: string;
|
|
3
|
+
when?: "always" | "outside_fields";
|
|
4
|
+
over_overlays?: boolean;
|
|
5
|
+
keep_default?: boolean;
|
|
6
|
+
run: (event: KeyboardEvent) => void;
|
|
7
|
+
}
|
|
8
|
+
interface KeyCombination {
|
|
9
|
+
key: string;
|
|
10
|
+
ctrl: boolean;
|
|
11
|
+
meta: boolean;
|
|
12
|
+
alt: boolean;
|
|
13
|
+
shift: boolean | "any";
|
|
14
|
+
}
|
|
15
|
+
export declare function is_apple_platform(): boolean;
|
|
16
|
+
export declare function parse_keys(keys: string, apple?: boolean): KeyCombination;
|
|
17
|
+
export declare function matches_keys(keys: string, event: KeyboardEvent, apple?: boolean): boolean;
|
|
18
|
+
export declare function describe_keys(keys: string, apple?: boolean): string;
|
|
19
|
+
export declare function is_typing(target: EventTarget | null): boolean;
|
|
20
|
+
export declare function is_overlay_open(document: Document): boolean;
|
|
21
|
+
export declare function is_shortcut_allowed(shortcut: Shortcut, event: KeyboardEvent): boolean;
|
|
22
|
+
export declare function use_shortcuts(shortcuts: Shortcut[]): void;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Keyboard shortcuts for a page: "mod+z" style bindings that stay quiet while text is typed or a dialog or popover is open.
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
// ── Parsing ──────────────────────────────────────────────────────────────────
|
|
4
|
+
const KEY_ALIASES = {
|
|
5
|
+
esc: "escape", return: "enter", space: " ", spacebar: " ", del: "delete",
|
|
6
|
+
up: "arrowup", down: "arrowdown", left: "arrowleft", right: "arrowright",
|
|
7
|
+
plus: "+", cmd: "meta", command: "meta", control: "ctrl", option: "alt",
|
|
8
|
+
};
|
|
9
|
+
export function is_apple_platform() {
|
|
10
|
+
return typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform);
|
|
11
|
+
}
|
|
12
|
+
// A punctuation key such as "?" needs Shift on some layouts and not others, so Shift is ignored unless named.
|
|
13
|
+
export function parse_keys(keys, apple = is_apple_platform()) {
|
|
14
|
+
const parts = keys.toLowerCase().split(/\+(?!$)/).map((part) => KEY_ALIASES[part] ?? part);
|
|
15
|
+
const key = parts.at(-1) ?? "";
|
|
16
|
+
const modifiers = new Set(parts.slice(0, -1).map((part) => part === "mod" ? (apple ? "meta" : "ctrl") : part));
|
|
17
|
+
const is_letter_or_named = /^[a-z0-9]$/.test(key) || key.length > 1;
|
|
18
|
+
return {
|
|
19
|
+
key,
|
|
20
|
+
ctrl: modifiers.has("ctrl"),
|
|
21
|
+
meta: modifiers.has("meta"),
|
|
22
|
+
alt: modifiers.has("alt"),
|
|
23
|
+
shift: modifiers.has("shift") || (is_letter_or_named ? false : "any"),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
// Letters and digits also match by their physical key, so Alt+A still matches on a Mac where it types "å".
|
|
27
|
+
export function matches_keys(keys, event, apple = is_apple_platform()) {
|
|
28
|
+
const combination = parse_keys(keys, apple);
|
|
29
|
+
if (event.ctrlKey !== combination.ctrl || event.metaKey !== combination.meta || event.altKey !== combination.alt)
|
|
30
|
+
return false;
|
|
31
|
+
if (combination.shift !== "any" && event.shiftKey !== combination.shift)
|
|
32
|
+
return false;
|
|
33
|
+
if (event.key.toLowerCase() === combination.key)
|
|
34
|
+
return true;
|
|
35
|
+
return /^[a-z]$/.test(combination.key) ? event.code === `Key${combination.key.toUpperCase()}` : /^[0-9]$/.test(combination.key) && event.code === `Digit${combination.key}`;
|
|
36
|
+
}
|
|
37
|
+
// ── Help text ────────────────────────────────────────────────────────────────
|
|
38
|
+
const KEY_NAMES = {
|
|
39
|
+
" ": "Space", escape: "Esc", arrowup: "↑", arrowdown: "↓", arrowleft: "←", arrowright: "→",
|
|
40
|
+
enter: "Enter", delete: "Delete", backspace: "Backspace", tab: "Tab", home: "Home", end: "End",
|
|
41
|
+
pageup: "Page Up", pagedown: "Page Down",
|
|
42
|
+
};
|
|
43
|
+
// The keys as a person reads them: "Ctrl+Shift+Z" elsewhere, "⌘⇧Z" on Apple devices.
|
|
44
|
+
export function describe_keys(keys, apple = is_apple_platform()) {
|
|
45
|
+
const combination = parse_keys(keys, apple);
|
|
46
|
+
const key = KEY_NAMES[combination.key] ?? (combination.key.length === 1 ? combination.key.toUpperCase() : capitalised(combination.key));
|
|
47
|
+
const shift = combination.shift === true;
|
|
48
|
+
if (apple)
|
|
49
|
+
return [combination.ctrl && "⌃", combination.alt && "⌥", shift && "⇧", combination.meta && "⌘", key].filter(Boolean).join("");
|
|
50
|
+
return [combination.ctrl && "Ctrl", combination.alt && "Alt", shift && "Shift", combination.meta && "Meta", key].filter(Boolean).join("+");
|
|
51
|
+
}
|
|
52
|
+
function capitalised(text) {
|
|
53
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
54
|
+
}
|
|
55
|
+
// ── Guards ───────────────────────────────────────────────────────────────────
|
|
56
|
+
// Text fields, selects and editable regions keep their keys for typing.
|
|
57
|
+
export function is_typing(target) {
|
|
58
|
+
if (!(target instanceof Element))
|
|
59
|
+
return false;
|
|
60
|
+
if (target instanceof HTMLElement && target.isContentEditable)
|
|
61
|
+
return true;
|
|
62
|
+
return target.closest("input, textarea, select, [contenteditable]:not([contenteditable='false'])") !== null;
|
|
63
|
+
}
|
|
64
|
+
// An open dialog or popover takes the keyboard, so page shortcuts wait until it closes.
|
|
65
|
+
export function is_overlay_open(document) {
|
|
66
|
+
return document.querySelector("dialog[open], [popover]:popover-open") !== null;
|
|
67
|
+
}
|
|
68
|
+
// Whether the shortcut should fire for the event, before its keys are compared.
|
|
69
|
+
export function is_shortcut_allowed(shortcut, event) {
|
|
70
|
+
if (event.isComposing)
|
|
71
|
+
return false;
|
|
72
|
+
if ((shortcut.when ?? "outside_fields") === "outside_fields" && is_typing(event.target))
|
|
73
|
+
return false;
|
|
74
|
+
return shortcut.over_overlays === true || !is_overlay_open(document);
|
|
75
|
+
}
|
|
76
|
+
// ── Hook ─────────────────────────────────────────────────────────────────────
|
|
77
|
+
// Listens on the window while the component is shown; the first shortcut that matches runs, and the list may change on every render.
|
|
78
|
+
export function use_shortcuts(shortcuts) {
|
|
79
|
+
const latest = useRef(shortcuts);
|
|
80
|
+
latest.current = shortcuts;
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
function on_key(event) {
|
|
83
|
+
if (event.defaultPrevented)
|
|
84
|
+
return;
|
|
85
|
+
const shortcut = latest.current.find((each) => matches_keys(each.keys, event) && is_shortcut_allowed(each, event));
|
|
86
|
+
if (!shortcut)
|
|
87
|
+
return;
|
|
88
|
+
if (!shortcut.keep_default)
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
shortcut.run(event);
|
|
91
|
+
}
|
|
92
|
+
window.addEventListener("keydown", on_key);
|
|
93
|
+
return () => window.removeEventListener("keydown", on_key);
|
|
94
|
+
}, []);
|
|
95
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
2
|
import { type Axis, type PaneSpec } from "../split.ts";
|
|
3
3
|
export type { PaneSpec } from "../split.ts";
|
|
4
4
|
interface ResizeHandleProps {
|
|
@@ -12,12 +12,12 @@ interface ResizeHandleProps {
|
|
|
12
12
|
label: string;
|
|
13
13
|
class_name?: string;
|
|
14
14
|
}
|
|
15
|
-
export declare function ResizeHandle({ side, size, min, max, default_size, on_resize, on_resize_end, label, class_name }: ResizeHandleProps): import("
|
|
15
|
+
export declare function ResizeHandle({ side, size, min, max, default_size, on_resize, on_resize_end, label, class_name }: ResizeHandleProps): import("react").JSX.Element;
|
|
16
16
|
interface SplitProps {
|
|
17
17
|
storage_key: string;
|
|
18
18
|
axis?: Axis;
|
|
19
19
|
specs: PaneSpec[];
|
|
20
20
|
preferred?: (number | undefined)[];
|
|
21
|
-
panes:
|
|
21
|
+
panes: ReactNode[];
|
|
22
22
|
}
|
|
23
|
-
export declare function Split({ storage_key, axis, specs, preferred, panes }: SplitProps): import("
|
|
23
|
+
export declare function Split({ storage_key, axis, specs, preferred, panes }: SplitProps): import("react").JSX.Element;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "
|
|
2
|
-
//
|
|
3
|
-
import { Fragment } from "
|
|
4
|
-
import { useEffect, useRef, useState } from "preact/hooks";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// React resize handles: one on a panel's edge, and a split whose panes share a container and remember their sizes.
|
|
3
|
+
import { Fragment, useEffect, useRef, useState } from "react";
|
|
5
4
|
import { distribute, fit, follow_drag, key_step, resize_at, store_sizes, stored_sizes } from "../split.js";
|
|
6
5
|
// A thin bar on a panel's inner edge: drag it or press the arrow keys to resize, double-click it to reset.
|
|
7
6
|
export function ResizeHandle({ side, size, min, max, default_size, on_resize, on_resize_end, label, class_name }) {
|
|
@@ -11,19 +10,19 @@ export function ResizeHandle({ side, size, min, max, default_size, on_resize, on
|
|
|
11
10
|
function start_drag(event) {
|
|
12
11
|
const start_size = size;
|
|
13
12
|
let latest = size;
|
|
14
|
-
follow_drag(event.currentTarget, event, axis, (moved) => {
|
|
13
|
+
follow_drag(event.currentTarget, event.nativeEvent, axis, (moved) => {
|
|
15
14
|
latest = clamp(start_size + direction * moved);
|
|
16
15
|
on_resize(latest);
|
|
17
16
|
}, () => on_resize_end(latest));
|
|
18
17
|
}
|
|
19
18
|
function nudge(event) {
|
|
20
|
-
const step = key_step(event, axis);
|
|
19
|
+
const step = key_step(event.nativeEvent, axis);
|
|
21
20
|
if (step === null)
|
|
22
21
|
return;
|
|
23
22
|
event.preventDefault();
|
|
24
23
|
on_resize_end(clamp(size + direction * step));
|
|
25
24
|
}
|
|
26
|
-
return (_jsx("div", {
|
|
25
|
+
return (_jsx("div", { className: ["resize-handle", axis === "vertical" && "vertical", class_name].filter(Boolean).join(" "), role: "separator", "aria-orientation": axis === "horizontal" ? "vertical" : "horizontal", "aria-label": label, "aria-valuenow": size, "aria-valuemin": min, "aria-valuemax": max, tabIndex: 0, onPointerDown: start_drag, onDoubleClick: () => on_resize_end(default_size), onKeyDown: nudge }));
|
|
27
26
|
}
|
|
28
27
|
// Panes side by side or stacked, with a handle between each two; double-clicking a handle restores the first sizes.
|
|
29
28
|
export function Split({ storage_key, axis = "horizontal", specs, preferred, panes }) {
|
|
@@ -76,13 +75,13 @@ export function Split({ storage_key, axis = "horizontal", specs, preferred, pane
|
|
|
76
75
|
function start_drag(index, event) {
|
|
77
76
|
const before = [...latest.current];
|
|
78
77
|
let moved_to = before;
|
|
79
|
-
follow_drag(event.currentTarget, event, axis, (moved) => {
|
|
78
|
+
follow_drag(event.currentTarget, event.nativeEvent, axis, (moved) => {
|
|
80
79
|
moved_to = resize_at(before, index, moved, specs);
|
|
81
80
|
change(moved_to, false);
|
|
82
81
|
}, () => store_sizes(storage_key, moved_to));
|
|
83
82
|
}
|
|
84
83
|
function nudge(index, event) {
|
|
85
|
-
const step = key_step(event, axis);
|
|
84
|
+
const step = key_step(event.nativeEvent, axis);
|
|
86
85
|
if (step === null)
|
|
87
86
|
return;
|
|
88
87
|
event.preventDefault();
|
|
@@ -91,5 +90,5 @@ export function Split({ storage_key, axis = "horizontal", specs, preferred, pane
|
|
|
91
90
|
const template = sizes.length === specs.length
|
|
92
91
|
? sizes.flatMap((size, index) => index === 0 ? [`${size}px`] : ["auto", `${size}px`]).join(" ")
|
|
93
92
|
: specs.map(() => "1fr").join(" auto ");
|
|
94
|
-
return (_jsx("div", { ref: container,
|
|
93
|
+
return (_jsx("div", { ref: container, className: `split ${axis}`, style: axis === "horizontal" ? { gridTemplateColumns: template } : { gridTemplateRows: template }, children: panes.map((pane, index) => (_jsxs(Fragment, { children: [index > 0 && (_jsx("div", { className: axis === "horizontal" ? "resize-handle" : "resize-handle vertical", role: "separator", tabIndex: 0, "aria-orientation": axis === "horizontal" ? "vertical" : "horizontal", "aria-label": "Resize panes", "aria-valuenow": sizes[index - 1] ?? 0, onPointerDown: (event) => start_drag(index - 1, event), onKeyDown: (event) => nudge(index - 1, event), onDoubleClick: () => change(distribute(extent(), specs, preferred), true) })), _jsx("div", { className: "split-pane", children: pane })] }, index))) }));
|
|
95
94
|
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
2
|
export declare function Skeleton({ lines }: {
|
|
3
3
|
lines?: number;
|
|
4
|
-
}): import("
|
|
4
|
+
}): import("react").JSX.Element;
|
|
5
5
|
export declare function Empty({ title, hint }: {
|
|
6
6
|
title: string;
|
|
7
|
-
hint?:
|
|
8
|
-
}): import("
|
|
7
|
+
hint?: ReactNode;
|
|
8
|
+
}): import("react").JSX.Element;
|
|
9
9
|
export declare function Failure({ error, retry }: {
|
|
10
10
|
error: unknown;
|
|
11
11
|
retry: () => void;
|
|
12
|
-
}): import("
|
|
12
|
+
}): import("react").JSX.Element;
|
|
13
13
|
export interface Resource<Data> {
|
|
14
14
|
data: Data | null;
|
|
15
15
|
error: unknown;
|
|
@@ -23,5 +23,5 @@ export declare function use_resource<Data>(work: () => Promise<Data>, dependenci
|
|
|
23
23
|
export declare function Loaded<Data>({ resource, lines, children }: {
|
|
24
24
|
resource: Resource<Data>;
|
|
25
25
|
lines?: number;
|
|
26
|
-
children: (data: Data) =>
|
|
27
|
-
}): import("
|
|
26
|
+
children: (data: Data) => ReactNode;
|
|
27
|
+
}): import("react").JSX.Element;
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "
|
|
2
|
-
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// What a part of the page shows while it loads, when it has nothing, and when loading failed, and the hook that loads it.
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
4
|
import { pass_login_needed } from "../states.js";
|
|
4
5
|
// ── States ───────────────────────────────────────────────────────────────────
|
|
5
6
|
export function Skeleton({ lines = 3 }) {
|
|
6
|
-
return (_jsx("div", {
|
|
7
|
+
return (_jsx("div", { className: "skeleton", "aria-busy": "true", "aria-label": "Loading", children: Array.from({ length: lines }, (_, index) => _jsx("div", { className: "skeleton-line", style: { width: `${90 - index * 15}%` } }, index)) }));
|
|
7
8
|
}
|
|
8
9
|
export function Empty({ title, hint }) {
|
|
9
|
-
return (_jsxs("div", {
|
|
10
|
+
return (_jsxs("div", { className: "empty", children: [_jsx("p", { className: "empty-title", children: title }), hint && _jsx("p", { className: "muted", children: hint })] }));
|
|
10
11
|
}
|
|
11
12
|
export function Failure({ error, retry }) {
|
|
12
|
-
return (_jsxs("div", {
|
|
13
|
+
return (_jsxs("div", { className: "failure", role: "alert", children: [_jsxs("p", { children: ["Could not load this: ", error instanceof Error ? error.message : String(error)] }), _jsx("button", { onClick: retry, children: "Retry" })] }));
|
|
13
14
|
}
|
|
14
15
|
// Loads what the work fetches whenever the dependencies change, passing a lapsed login on to the page.
|
|
15
16
|
export function use_resource(work, dependencies, options = {}) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type MouseEvent, type ReactNode } from "react";
|
|
2
|
+
import { type Sorting } from "../tables.ts";
|
|
3
|
+
export type { Sorting } from "../tables.ts";
|
|
4
|
+
export interface TableColumn<Row> {
|
|
5
|
+
label: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
cell: (row: Row) => ReactNode;
|
|
8
|
+
sort?: (row: Row) => string | number | null;
|
|
9
|
+
class?: string;
|
|
10
|
+
width?: number;
|
|
11
|
+
min_width?: number;
|
|
12
|
+
max_width?: number;
|
|
13
|
+
hideable?: boolean;
|
|
14
|
+
movable?: boolean;
|
|
15
|
+
filter?: ReactNode | ((close: () => void) => ReactNode);
|
|
16
|
+
filter_active?: boolean;
|
|
17
|
+
}
|
|
18
|
+
interface DataTableProps<Row> {
|
|
19
|
+
columns: TableColumn<Row>[];
|
|
20
|
+
rows: Row[];
|
|
21
|
+
row_key?: (row: Row) => string | number;
|
|
22
|
+
caption?: string;
|
|
23
|
+
sorting?: Sorting | Sorting[];
|
|
24
|
+
on_sort?: (sorting: Sorting) => void;
|
|
25
|
+
on_sorting_change?: (sorting: Sorting[]) => void;
|
|
26
|
+
column_order?: string[];
|
|
27
|
+
on_column_order_change?: (order: string[]) => void;
|
|
28
|
+
column_widths?: Record<string, number>;
|
|
29
|
+
on_column_widths_change?: (widths: Record<string, number>, done: boolean) => void;
|
|
30
|
+
hidden_columns?: string[];
|
|
31
|
+
on_hidden_columns_change?: (hidden: string[]) => void;
|
|
32
|
+
row_passes?: (row: Row) => boolean;
|
|
33
|
+
on_clear_filters?: () => void;
|
|
34
|
+
get_children?: (row: Row) => Row[] | undefined;
|
|
35
|
+
expanded?: string[] | true;
|
|
36
|
+
on_expanded_change?: (expanded: string[]) => void;
|
|
37
|
+
on_row_click?: (row: Row, event: MouseEvent<HTMLTableRowElement>) => void;
|
|
38
|
+
on_row_context_menu?: (row: Row, event: MouseEvent<HTMLTableRowElement>) => void;
|
|
39
|
+
row_class?: (row: Row) => string | undefined;
|
|
40
|
+
}
|
|
41
|
+
export declare function DataTable<Row>(props: DataTableProps<Row>): import("react").JSX.Element;
|
|
42
|
+
export declare function Pager({ page, has_more, go }: {
|
|
43
|
+
page: number;
|
|
44
|
+
has_more: boolean;
|
|
45
|
+
go: (page: number) => void;
|
|
46
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
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";
|
|
5
|
+
import { getCoreRowModel, getExpandedRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
|
|
6
|
+
import { clamp_width, column_id, compare_values, move_column, ordered_columns, sort_after_click, sorting_list } from "../tables.js";
|
|
7
|
+
import { ContextMenu, Popover } from "./menus.js";
|
|
8
|
+
import { ResizeHandle } from "./split.js";
|
|
9
|
+
const is_empty = (value) => value === null || value === undefined || value === "";
|
|
10
|
+
// The rows that pass and, in a tree, those above a row that passes, with each row's passing children.
|
|
11
|
+
function filtered_tree(rows, passes, get_children, kept_children) {
|
|
12
|
+
return rows.filter((row) => {
|
|
13
|
+
const children = get_children ? filtered_tree(get_children(row) ?? [], passes, get_children, kept_children) : [];
|
|
14
|
+
kept_children.set(row, children);
|
|
15
|
+
return passes(row) || children.length > 0;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
// ── Table ────────────────────────────────────────────────────────────────────
|
|
19
|
+
export function DataTable(props) {
|
|
20
|
+
const { columns, rows, row_key, caption, get_children, row_passes } = props;
|
|
21
|
+
const [own_expanded, set_own_expanded] = useState(true);
|
|
22
|
+
const [open_filter, set_open_filter] = useState(null);
|
|
23
|
+
const filter_closed = useRef({ id: "", at: 0 });
|
|
24
|
+
const [columns_menu, set_columns_menu] = useState(null);
|
|
25
|
+
const [dragged, set_dragged] = useState(null);
|
|
26
|
+
const [drop_target, set_drop_target] = useState(null);
|
|
27
|
+
const resizing = useRef(false);
|
|
28
|
+
const header_cells = useRef(new Map());
|
|
29
|
+
const [measured, set_measured] = useState({});
|
|
30
|
+
const by_id = useMemo(() => new Map(columns.map((column) => [column_id(column), column])), [columns]);
|
|
31
|
+
const all_ids = columns.map(column_id);
|
|
32
|
+
const sorting = sorting_list(props.sorting).filter((key) => by_id.get(key.key)?.sort);
|
|
33
|
+
const hidden = props.hidden_columns ?? [];
|
|
34
|
+
const widths = props.column_widths ?? {};
|
|
35
|
+
const expanded = props.expanded ?? own_expanded;
|
|
36
|
+
const tree = get_children !== undefined;
|
|
37
|
+
// ── Rows ──
|
|
38
|
+
const { data, children_of } = useMemo(() => {
|
|
39
|
+
if (!row_passes)
|
|
40
|
+
return { data: rows, children_of: get_children };
|
|
41
|
+
const kept_children = new Map();
|
|
42
|
+
const kept = filtered_tree(rows, row_passes, get_children, kept_children);
|
|
43
|
+
return { data: kept, children_of: get_children && ((row) => kept_children.get(row) ?? []) };
|
|
44
|
+
}, [rows, row_passes, get_children]);
|
|
45
|
+
// TanStack flips a descending column's order, so an empty value is flipped first to stay last.
|
|
46
|
+
const descending = useRef(new Set());
|
|
47
|
+
descending.current = new Set(sorting.filter((key) => key.descending).map((key) => key.key));
|
|
48
|
+
const definitions = useMemo(() => columns.map((column) => ({
|
|
49
|
+
id: column_id(column),
|
|
50
|
+
accessorFn: column.sort,
|
|
51
|
+
enableSorting: Boolean(column.sort),
|
|
52
|
+
sortingFn: (left, right, id) => {
|
|
53
|
+
const [first, second] = [left.getValue(id), right.getValue(id)];
|
|
54
|
+
const order = compare_values(first, second);
|
|
55
|
+
return (is_empty(first) || is_empty(second)) && descending.current.has(id) ? -order : order;
|
|
56
|
+
},
|
|
57
|
+
})), [columns]);
|
|
58
|
+
const table = useReactTable({
|
|
59
|
+
data,
|
|
60
|
+
columns: definitions,
|
|
61
|
+
getRowId: row_key ? (row) => String(row_key(row)) : undefined,
|
|
62
|
+
getSubRows: children_of ? (row) => children_of(row) ?? [] : undefined,
|
|
63
|
+
state: {
|
|
64
|
+
sorting: sorting.map((key) => ({ id: key.key, desc: key.descending })),
|
|
65
|
+
columnOrder: ordered_columns(props.column_order ?? all_ids, all_ids),
|
|
66
|
+
columnVisibility: Object.fromEntries(hidden.map((id) => [id, false])),
|
|
67
|
+
expanded: expanded === true ? true : Object.fromEntries(expanded.map((id) => [id, true])),
|
|
68
|
+
},
|
|
69
|
+
getCoreRowModel: getCoreRowModel(),
|
|
70
|
+
getSortedRowModel: getSortedRowModel(),
|
|
71
|
+
getExpandedRowModel: getExpandedRowModel(),
|
|
72
|
+
getRowCanExpand: (row) => row.subRows.length > 0,
|
|
73
|
+
});
|
|
74
|
+
const shown = table.getVisibleLeafColumns().map((column) => by_id.get(column.id));
|
|
75
|
+
function toggle_row(row) {
|
|
76
|
+
const open = expanded === true
|
|
77
|
+
? table.getCoreRowModel().flatRows.filter((each) => each.subRows.length > 0).map((each) => each.id)
|
|
78
|
+
: expanded;
|
|
79
|
+
const next = row.getIsExpanded() ? open.filter((id) => id !== row.id) : [...open, row.id];
|
|
80
|
+
if (props.expanded === undefined)
|
|
81
|
+
set_own_expanded(next);
|
|
82
|
+
props.on_expanded_change?.(next);
|
|
83
|
+
}
|
|
84
|
+
// ── Sorting ──
|
|
85
|
+
function sort_by(id, adding) {
|
|
86
|
+
if (props.on_sorting_change)
|
|
87
|
+
return props.on_sorting_change(sort_after_click(sorting, id, adding));
|
|
88
|
+
const current = sorting.find((key) => key.key === id);
|
|
89
|
+
props.on_sort?.({ key: id, descending: current ? !current.descending : false });
|
|
90
|
+
}
|
|
91
|
+
// ── Widths ──
|
|
92
|
+
const width_of = (column) => widths[column_id(column)] ?? column.width;
|
|
93
|
+
// A column never sized is measured, so the first drag of its edge starts from where it is.
|
|
94
|
+
useLayoutEffect(() => {
|
|
95
|
+
if (!props.on_column_widths_change)
|
|
96
|
+
return;
|
|
97
|
+
const sizes = Object.fromEntries([...header_cells.current].map(([id, cell]) => [id, cell.offsetWidth]));
|
|
98
|
+
set_measured((before) => JSON.stringify(before) === JSON.stringify(sizes) ? before : sizes);
|
|
99
|
+
}, [shown.map(column_id).join("\n"), props.on_column_widths_change]);
|
|
100
|
+
function resize(column, width, done) {
|
|
101
|
+
props.on_column_widths_change?.({ ...widths, [column_id(column)]: width }, done);
|
|
102
|
+
}
|
|
103
|
+
// ── Moving ──
|
|
104
|
+
const can_move = (column) => Boolean(props.on_column_order_change) && column.movable !== false;
|
|
105
|
+
const order = () => table.getState().columnOrder;
|
|
106
|
+
function move(id, target, after) {
|
|
107
|
+
if (id !== target)
|
|
108
|
+
props.on_column_order_change?.(move_column(order(), id, target, after));
|
|
109
|
+
}
|
|
110
|
+
// A column dropped on the left half of another goes before it, and on the right half after it.
|
|
111
|
+
function drag_over(column, event) {
|
|
112
|
+
if (!dragged || !can_move(column))
|
|
113
|
+
return;
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
const cell = event.currentTarget.getBoundingClientRect();
|
|
116
|
+
set_drop_target({ id: column_id(column), after: event.clientX > cell.left + cell.width / 2 });
|
|
117
|
+
}
|
|
118
|
+
function drop(event) {
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
if (dragged && drop_target)
|
|
121
|
+
move(dragged, drop_target.id, drop_target.after);
|
|
122
|
+
set_dragged(null);
|
|
123
|
+
set_drop_target(null);
|
|
124
|
+
}
|
|
125
|
+
// Alt with an arrow moves the column one place along, past any hidden ones.
|
|
126
|
+
function move_by_key(column, event) {
|
|
127
|
+
if (!event.altKey || !can_move(column) || (event.key !== "ArrowLeft" && event.key !== "ArrowRight"))
|
|
128
|
+
return;
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
const ids = shown.map(column_id);
|
|
131
|
+
const index = ids.indexOf(column_id(column));
|
|
132
|
+
const target = ids[index + (event.key === "ArrowLeft" ? -1 : 1)];
|
|
133
|
+
if (target && by_id.get(target)?.movable !== false)
|
|
134
|
+
move(column_id(column), target, event.key === "ArrowRight");
|
|
135
|
+
}
|
|
136
|
+
// ── Filters ──
|
|
137
|
+
function toggle_filter(id, anchor) {
|
|
138
|
+
// The click that closed the popover by landing outside it is the same one that reaches its button.
|
|
139
|
+
const just_closed = filter_closed.current.id === id && Date.now() - filter_closed.current.at < 300;
|
|
140
|
+
set_open_filter(open_filter?.id === id || just_closed ? null : { id, anchor });
|
|
141
|
+
}
|
|
142
|
+
function close_filter() {
|
|
143
|
+
if (open_filter)
|
|
144
|
+
filter_closed.current = { id: open_filter.id, at: Date.now() };
|
|
145
|
+
set_open_filter(null);
|
|
146
|
+
}
|
|
147
|
+
const any_filter = columns.some((column) => column.filter_active);
|
|
148
|
+
// ── Hiding ──
|
|
149
|
+
function open_columns_menu(event) {
|
|
150
|
+
if (!props.on_hidden_columns_change)
|
|
151
|
+
return;
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
if (event.type === "contextmenu")
|
|
154
|
+
return set_columns_menu({ x: event.clientX, y: event.clientY });
|
|
155
|
+
const button = event.currentTarget.getBoundingClientRect();
|
|
156
|
+
set_columns_menu({ x: button.left, y: button.bottom + 4 });
|
|
157
|
+
}
|
|
158
|
+
const column_choices = columns.map((column) => {
|
|
159
|
+
const id = column_id(column);
|
|
160
|
+
const visible = !hidden.includes(id);
|
|
161
|
+
// The last column on show stays, so the table never empties.
|
|
162
|
+
const fixed = column.hideable === false || (visible && shown.length === 1);
|
|
163
|
+
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
|
+
});
|
|
165
|
+
// ── Drawing ──
|
|
166
|
+
function header(column) {
|
|
167
|
+
const id = column_id(column);
|
|
168
|
+
const index = sorting.findIndex((key) => key.key === id);
|
|
169
|
+
const key = sorting[index];
|
|
170
|
+
const sortable = Boolean(column.sort) && Boolean(props.on_sort || props.on_sorting_change);
|
|
171
|
+
const width = width_of(column);
|
|
172
|
+
const movable = can_move(column);
|
|
173
|
+
const class_name = [
|
|
174
|
+
column.class, column.filter_active && "filtered", dragged === id && "dragged", drop_target?.id === id && (drop_target.after ? "drop-after" : "drop-before"),
|
|
175
|
+
].filter(Boolean).join(" ") || undefined;
|
|
176
|
+
const marker = key && (_jsxs("span", { className: "sort-marker", "aria-hidden": "true", children: [key.descending ? " ↓" : " ↑", sorting.length > 1 && _jsx("sup", { className: "sort-rank", children: index + 1 })] }));
|
|
177
|
+
return (_jsxs("th", { ref: (cell) => { if (cell)
|
|
178
|
+
header_cells.current.set(id, cell);
|
|
179
|
+
else
|
|
180
|
+
header_cells.current.delete(id); }, className: class_name, scope: "col", style: width === undefined ? undefined : { width, minWidth: width, maxWidth: width }, "aria-sort": sortable ? (key ? (key.descending ? "descending" : "ascending") : "none") : undefined, draggable: movable || undefined, title: movable ? "Drag to move this column" : undefined, onPointerDownCapture: (event) => { resizing.current = event.target.classList.contains("resize-handle"); }, onDragStart: (event) => {
|
|
181
|
+
if (resizing.current)
|
|
182
|
+
return event.preventDefault();
|
|
183
|
+
event.dataTransfer.setData("text/plain", id);
|
|
184
|
+
set_dragged(id);
|
|
185
|
+
}, onDragEnd: () => { set_dragged(null); set_drop_target(null); }, onDragOver: (event) => drag_over(column, event), onDragLeave: () => set_drop_target(null), onDrop: drop, children: [sortable ? (_jsxs("button", { type: "button", className: "sort", title: props.on_sorting_change ? "Click to sort by this column, Shift+click to add it to the sort" : undefined, onClick: (event) => sort_by(id, event.shiftKey), onKeyDown: (event) => move_by_key(column, event), children: [column.label, marker] })) : movable ? (_jsx("span", { tabIndex: 0, onKeyDown: (event) => move_by_key(column, event), children: column.label })) : column.label, column.filter !== undefined && (_jsx("button", { type: "button", className: column.filter_active ? "icon filter-button active" : "icon filter-button", "aria-label": `Filter by ${column.label}`, "aria-expanded": open_filter?.id === id, title: column.filter_active ? `Filtered by ${column.label}` : `Filter by ${column.label}`, onClick: (event) => toggle_filter(id, event.currentTarget), children: _jsx("svg", { viewBox: "0 0 16 16", width: "12", height: "12", "aria-hidden": "true", children: _jsx("path", { d: "M2 3h12l-4.5 5.5V13l-3 1.5v-6z", fill: "currentColor" }) }) })), props.on_column_widths_change && (_jsx(ResizeHandle, { side: "left", size: width ?? measured[id] ?? column.min_width ?? 48, min: column.min_width ?? 48, max: column.max_width ?? 960, default_size: clamp_width(column.width ?? measured[id] ?? 120, column.min_width, column.max_width), on_resize: (size) => resize(column, size, false), on_resize_end: (size) => resize(column, size, true), label: `Resize the ${column.label} column`, class_name: "column-resize" }))] }, id));
|
|
186
|
+
}
|
|
187
|
+
function body_row(row) {
|
|
188
|
+
const original = row.original;
|
|
189
|
+
const extra = props.row_class?.(original);
|
|
190
|
+
return (_jsx("tr", { className: extra, onClick: props.on_row_click && ((event) => props.on_row_click(original, event)), onContextMenu: props.on_row_context_menu && ((event) => props.on_row_context_menu(original, event)), children: shown.map((column, index) => {
|
|
191
|
+
const label = column.label;
|
|
192
|
+
if (!tree || index > 0)
|
|
193
|
+
return _jsx("td", { className: column.class, "data-label": label, children: column.cell(original) }, column_id(column));
|
|
194
|
+
const open = row.getIsExpanded();
|
|
195
|
+
const count = row.subRows.length;
|
|
196
|
+
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)));
|
|
197
|
+
}) }, row.id));
|
|
198
|
+
}
|
|
199
|
+
const filtered_column = open_filter ? by_id.get(open_filter.id) : undefined;
|
|
200
|
+
const filter_body = filtered_column?.filter;
|
|
201
|
+
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) })] }));
|
|
203
|
+
}
|
|
204
|
+
// ── Pages ────────────────────────────────────────────────────────────────────
|
|
205
|
+
// Newer and older buttons around the page number, or nothing when everything fits on one page.
|
|
206
|
+
export function Pager({ page, has_more, go }) {
|
|
207
|
+
if (page === 1 && !has_more)
|
|
208
|
+
return null;
|
|
209
|
+
return (_jsxs("nav", { className: "pager", "aria-label": "Pages", children: [_jsx("button", { disabled: page <= 1, onClick: () => go(page - 1), children: "\u2190 Newer" }), _jsxs("span", { className: "muted", children: ["Page ", page] }), _jsx("button", { disabled: !has_more, onClick: () => go(page + 1), children: "Older \u2192" })] }));
|
|
210
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment as _Fragment, jsx as _jsx } from "
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
// Times as the page shows them: how long ago, with the full date and time on hover.
|
|
3
3
|
import { age, stamp } from "../format.js";
|
|
4
4
|
// A time as its age, such as "3h ago", or the missing text, a dash unless told otherwise, when there is none.
|
|
@@ -10,6 +10,8 @@ export interface ToastOptions {
|
|
|
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;
|
|
15
|
-
export declare function Toasts(): import("
|
|
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;
|