@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.
Files changed (67) hide show
  1. package/README.md +63 -10
  2. package/dist/dates.d.ts +9 -0
  3. package/dist/dates.js +60 -0
  4. package/dist/durations.d.ts +8 -1
  5. package/dist/durations.js +9 -5
  6. package/dist/electron/bridge.d.ts +30 -0
  7. package/dist/electron/bridge.js +32 -0
  8. package/dist/electron/window.d.ts +85 -0
  9. package/dist/electron/window.js +85 -0
  10. package/dist/web/menus.d.ts +4 -7
  11. package/dist/web/menus.js +28 -86
  12. package/dist/web/react/badges.d.ts +26 -0
  13. package/dist/web/react/badges.js +28 -0
  14. package/dist/web/react/controls.d.ts +37 -0
  15. package/dist/web/react/controls.js +44 -0
  16. package/dist/web/react/dialog.d.ts +35 -0
  17. package/dist/web/react/dialog.js +78 -0
  18. package/dist/web/react/layout.d.ts +80 -0
  19. package/dist/web/react/layout.js +71 -0
  20. package/dist/web/react/menus.d.ts +31 -0
  21. package/dist/web/react/menus.js +159 -0
  22. package/dist/web/react/shell.d.ts +37 -0
  23. package/dist/web/react/shell.js +81 -0
  24. package/dist/web/react/shortcuts.d.ts +23 -0
  25. package/dist/web/react/shortcuts.js +95 -0
  26. package/dist/web/{preact → react}/split.d.ts +4 -4
  27. package/dist/web/{preact → react}/split.js +9 -10
  28. package/dist/web/react/states.d.ts +27 -0
  29. package/dist/web/react/states.js +49 -0
  30. package/dist/web/react/table.d.ts +46 -0
  31. package/dist/web/react/table.js +210 -0
  32. package/dist/web/react/time.d.ts +4 -0
  33. package/dist/web/react/time.js +9 -0
  34. package/dist/web/{toasts.d.ts → react/toasts.d.ts} +4 -1
  35. package/dist/web/react/toasts.js +41 -0
  36. package/dist/web/react/tree.d.ts +23 -0
  37. package/dist/web/react/tree.js +168 -0
  38. package/dist/web/states.d.ts +1 -8
  39. package/dist/web/states.js +6 -43
  40. package/dist/web/tables.d.ts +21 -14
  41. package/dist/web/tables.js +69 -33
  42. package/dist/web/theme.d.ts +7 -2
  43. package/dist/web/theme.js +35 -22
  44. package/dist/web/tree.d.ts +35 -0
  45. package/dist/web/tree.js +107 -0
  46. package/package.json +15 -4
  47. package/dist/web/badges.d.ts +0 -10
  48. package/dist/web/badges.js +0 -30
  49. package/dist/web/controls.d.ts +0 -6
  50. package/dist/web/controls.js +0 -55
  51. package/dist/web/dialogs.d.ts +0 -18
  52. package/dist/web/dialogs.js +0 -49
  53. package/dist/web/layout.d.ts +0 -28
  54. package/dist/web/layout.js +0 -53
  55. package/dist/web/preact/controls.d.ts +0 -24
  56. package/dist/web/preact/controls.js +0 -36
  57. package/dist/web/preact/dialog.d.ts +0 -11
  58. package/dist/web/preact/dialog.js +0 -20
  59. package/dist/web/preact/layout.d.ts +0 -80
  60. package/dist/web/preact/layout.js +0 -69
  61. package/dist/web/preact/menus.d.ts +0 -18
  62. package/dist/web/preact/menus.js +0 -29
  63. package/dist/web/preact/table.d.ts +0 -18
  64. package/dist/web/preact/table.js +0 -13
  65. package/dist/web/shell.d.ts +0 -7
  66. package/dist/web/shell.js +0 -51
  67. package/dist/web/toasts.js +0 -22
@@ -0,0 +1,81 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The frame around every view: the top bar, the sidebar that slides in on phones and resizes on wide screens, the theme button, and the toasts, dialogs and menus.
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { current_route } from "../router.js";
5
+ import { store_sizes, stored_sizes } from "../split.js";
6
+ import { next_theme } from "../theme.js";
7
+ import { Dialogs } from "./dialog.js";
8
+ import { ContextMenus } from "./menus.js";
9
+ import { ResizeHandle } from "./split.js";
10
+ import { Toasts } from "./toasts.js";
11
+ // ── Route ────────────────────────────────────────────────────────────────────
12
+ // The hash route, read again whenever it changes.
13
+ export function use_route() {
14
+ const [route, set_route] = useState(current_route);
15
+ useEffect(() => {
16
+ const changed = () => set_route(current_route());
17
+ window.addEventListener("hashchange", changed);
18
+ return () => window.removeEventListener("hashchange", changed);
19
+ }, []);
20
+ return route;
21
+ }
22
+ // ── Theme ────────────────────────────────────────────────────────────────────
23
+ const theme_labels = { system: ["◐", "Theme: system"], light: ["☀", "Theme: light"], dark: ["☾", "Theme: dark"] };
24
+ // Steps through system, light and dark, its glyph and label showing the current one.
25
+ export function ThemeButton({ choice }) {
26
+ const [theme, set_theme] = useState(choice.get);
27
+ useEffect(() => choice.on_change(set_theme), []);
28
+ const [icon, text] = theme_labels[theme];
29
+ return (_jsx("button", { id: "theme", className: "icon", title: `${text} (click to change)`, "aria-label": `${text}, click to change`, onClick: () => choice.set(next_theme(choice.get())), children: icon }));
30
+ }
31
+ // The sidebar's width: the app's own, else the one kept in this browser, else the default.
32
+ function use_sidebar_width(spec) {
33
+ const [kept, set_kept] = useState(() => spec?.storage_key ? stored_sizes(spec.storage_key, 1)?.[0] : undefined);
34
+ const width = spec ? Math.min(spec.max, Math.max(spec.min, spec.width ?? kept ?? spec.default_width)) : undefined;
35
+ const change = (next, done) => {
36
+ if (spec?.width === undefined)
37
+ set_kept(next);
38
+ if (done && spec?.storage_key)
39
+ store_sizes(spec.storage_key, [next]);
40
+ spec?.on_change?.(next, done);
41
+ };
42
+ return [width, change];
43
+ }
44
+ export function Shell({ brand, sections = [], active, sidebar: sidebar_content, sidebar_width, theme, user, user_href, on_logout, tools, sidebar_foot, view_key, children }) {
45
+ const [menu_open, set_menu_open] = useState(false);
46
+ const main = useRef(null);
47
+ const menu_button = useRef(null);
48
+ const sidebar = useRef(null);
49
+ const first_view = useRef(true);
50
+ const logged_out = user === null;
51
+ const [width, change_width] = use_sidebar_width(sidebar_width);
52
+ // The login page has no theme button, so the saved theme is set here.
53
+ useEffect(() => theme.apply(), []);
54
+ useEffect(() => {
55
+ document.body.classList.toggle("logged-out", logged_out);
56
+ document.body.classList.toggle("menu-open", menu_open);
57
+ }, [logged_out, menu_open]);
58
+ useEffect(() => {
59
+ if (menu_open)
60
+ sidebar.current?.querySelector("a[href], button:not(:disabled), input, select, textarea, [tabindex='0']")?.focus();
61
+ if (!menu_open)
62
+ return;
63
+ const escape = (event) => {
64
+ if (event.key !== "Escape")
65
+ return;
66
+ set_menu_open(false);
67
+ menu_button.current?.focus();
68
+ };
69
+ document.addEventListener("keydown", escape);
70
+ return () => document.removeEventListener("keydown", escape);
71
+ }, [menu_open]);
72
+ // A new view closes the menu and takes focus, which tells a screen reader the page changed, but not on the first load.
73
+ useEffect(() => {
74
+ set_menu_open(false);
75
+ if (!first_view.current)
76
+ main.current?.focus({ preventScroll: true });
77
+ first_view.current = false;
78
+ window.scrollTo(0, 0);
79
+ }, [view_key]);
80
+ return (_jsxs(_Fragment, { children: [_jsx("a", { className: "skip", href: "#main", children: "Skip to content" }), _jsxs("header", { className: "topbar", children: [_jsx("button", { ref: menu_button, className: "icon menu-button", "aria-label": "Open the menu", "aria-expanded": menu_open, "aria-controls": "sidebar", onClick: () => set_menu_open(!menu_open), children: "\u2630" }), _jsx("a", { href: "#/", className: "brand", children: brand }), !logged_out && tools, !logged_out && _jsx(ThemeButton, { choice: theme }), !logged_out && (user_href ? _jsx("a", { className: "account-link", href: user_href, children: user }) : _jsx("span", { className: "user-name", children: user })), !logged_out && on_logout && _jsx("button", { onClick: on_logout, children: "Log out" })] }), _jsxs("div", { className: "layout", style: width === undefined ? undefined : { "--sidebar-width": `${width}px` }, children: [_jsxs("nav", { ref: sidebar, id: "sidebar", className: "sidebar", "aria-label": "Main", children: [sidebar_content ?? sections.map((section) => (_jsx("a", { href: section.href, "data-section": section.id, className: section.id === active ? "active" : undefined, "aria-current": section.id === active ? "page" : undefined, children: section.label }, section.id))), sidebar_foot, sidebar_width && width !== undefined && (_jsx(ResizeHandle, { side: "left", size: width, min: sidebar_width.min, max: sidebar_width.max, default_size: sidebar_width.default_width, on_resize: (next) => change_width(next, false), on_resize_end: (next) => change_width(next, true), label: "Resize the sidebar", class_name: "sidebar-resize" }))] }), _jsx("div", { className: "scrim", hidden: !menu_open, onClick: () => set_menu_open(false) }), _jsx("main", { ref: main, id: "main", tabIndex: -1, children: children })] }), _jsx(Toasts, {}), _jsx(Dialogs, {}), _jsx(ContextMenus, {})] }));
81
+ }
@@ -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 ComponentChildren } from "preact";
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("preact").JSX.Element;
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: ComponentChildren[];
21
+ panes: ReactNode[];
22
22
  }
23
- export declare function Split({ storage_key, axis, specs, preferred, panes }: SplitProps): import("preact").JSX.Element;
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 "preact/jsx-runtime";
2
- // Preact resize handles: one on a panel's edge, and a split whose panes share a container and remember their sizes.
3
- import { Fragment } from "preact";
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", { class: ["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, onDblClick: () => on_resize_end(default_size), onKeyDown: nudge }));
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, class: `split ${axis}`, style: axis === "horizontal" ? { gridTemplateColumns: template } : { gridTemplateRows: template }, children: panes.map((pane, index) => (_jsxs(Fragment, { children: [index > 0 && (_jsx("div", { class: 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), onDblClick: () => change(distribute(extent(), specs, preferred), true) })), _jsx("div", { class: "split-pane", children: pane })] }, index))) }));
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
  }
@@ -0,0 +1,27 @@
1
+ import { type ReactNode } from "react";
2
+ export declare function Skeleton({ lines }: {
3
+ lines?: number;
4
+ }): import("react").JSX.Element;
5
+ export declare function Empty({ title, hint }: {
6
+ title: string;
7
+ hint?: ReactNode;
8
+ }): import("react").JSX.Element;
9
+ export declare function Failure({ error, retry }: {
10
+ error: unknown;
11
+ retry: () => void;
12
+ }): import("react").JSX.Element;
13
+ export interface Resource<Data> {
14
+ data: Data | null;
15
+ error: unknown;
16
+ loading: boolean;
17
+ reload: (quiet?: boolean) => Promise<void>;
18
+ }
19
+ export interface ResourceOptions {
20
+ refresh_in_milliseconds?: number;
21
+ }
22
+ export declare function use_resource<Data>(work: () => Promise<Data>, dependencies: unknown[], options?: ResourceOptions): Resource<Data>;
23
+ export declare function Loaded<Data>({ resource, lines, children }: {
24
+ resource: Resource<Data>;
25
+ lines?: number;
26
+ children: (data: Data) => ReactNode;
27
+ }): import("react").JSX.Element;
@@ -0,0 +1,49 @@
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";
4
+ import { pass_login_needed } from "../states.js";
5
+ // ── States ───────────────────────────────────────────────────────────────────
6
+ export function Skeleton({ lines = 3 }) {
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)) }));
8
+ }
9
+ export function Empty({ title, hint }) {
10
+ return (_jsxs("div", { className: "empty", children: [_jsx("p", { className: "empty-title", children: title }), hint && _jsx("p", { className: "muted", children: hint })] }));
11
+ }
12
+ export function Failure({ error, retry }) {
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" })] }));
14
+ }
15
+ // Loads what the work fetches whenever the dependencies change, passing a lapsed login on to the page.
16
+ export function use_resource(work, dependencies, options = {}) {
17
+ const [state, set_state] = useState({ data: null, error: null, loading: true });
18
+ const latest_work = useRef(work);
19
+ latest_work.current = work;
20
+ const generation = useRef(0);
21
+ const reload = useCallback((quiet = false) => {
22
+ const mine = ++generation.current;
23
+ if (!quiet)
24
+ set_state({ data: null, error: null, loading: true });
25
+ return latest_work.current().then((data) => { if (mine === generation.current)
26
+ set_state({ data, error: null, loading: false }); }, (error) => {
27
+ if (pass_login_needed(error) || mine !== generation.current)
28
+ return;
29
+ set_state((before) => quiet && before.data !== null ? { ...before, loading: false } : { data: null, error, loading: false });
30
+ });
31
+ }, []);
32
+ useEffect(() => void reload(), dependencies);
33
+ useEffect(() => {
34
+ if (!options.refresh_in_milliseconds)
35
+ return;
36
+ const timer = setInterval(() => { if (document.visibilityState === "visible")
37
+ void reload(true); }, options.refresh_in_milliseconds);
38
+ return () => clearInterval(timer);
39
+ }, [options.refresh_in_milliseconds]);
40
+ return { ...state, reload };
41
+ }
42
+ // Shows a skeleton while the resource first loads, an error with Retry if that fails, and what children build from it after.
43
+ export function Loaded({ resource, lines, children }) {
44
+ if (resource.data !== null)
45
+ return _jsx(_Fragment, { children: children(resource.data) });
46
+ if (resource.error !== null)
47
+ return _jsx(Failure, { error: resource.error, retry: () => void resource.reload() });
48
+ return _jsx(Skeleton, { lines: lines });
49
+ }
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ export declare function Age({ epoch, missing }: {
2
+ epoch: number | null | undefined;
3
+ missing?: string;
4
+ }): import("react").JSX.Element;