@x-tech-laboratories/x-libs 0.14.1 → 0.15.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 (151) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/client.d.ts +32 -0
  3. package/dist/agent/client.js +148 -0
  4. package/dist/agent/commands.d.ts +23 -0
  5. package/dist/agent/commands.js +118 -0
  6. package/dist/agent/keys.d.ts +14 -0
  7. package/dist/agent/keys.js +81 -0
  8. package/dist/agent/long_runs.d.ts +32 -0
  9. package/dist/agent/long_runs.js +316 -0
  10. package/dist/agent/protocol.d.ts +148 -0
  11. package/dist/agent/protocol.js +131 -0
  12. package/dist/agent/session.d.ts +50 -0
  13. package/dist/agent/session.js +225 -0
  14. package/dist/agent/tasks.d.ts +17 -0
  15. package/dist/agent/tasks.js +15 -0
  16. package/dist/backups.d.ts +31 -0
  17. package/dist/backups.js +43 -0
  18. package/dist/dates.d.ts +9 -0
  19. package/dist/dates.js +60 -0
  20. package/dist/durations.d.ts +11 -0
  21. package/dist/durations.js +49 -0
  22. package/dist/electron/app.d.ts +13 -0
  23. package/dist/electron/app.js +27 -0
  24. package/dist/electron/bridge.d.ts +55 -0
  25. package/dist/electron/bridge.js +105 -0
  26. package/dist/electron/events.d.ts +21 -0
  27. package/dist/electron/events.js +25 -0
  28. package/dist/electron/fetch.d.ts +52 -0
  29. package/dist/electron/fetch.js +175 -0
  30. package/dist/electron/window.d.ts +96 -0
  31. package/dist/electron/window.js +128 -0
  32. package/dist/log.d.ts +30 -0
  33. package/dist/log.js +79 -0
  34. package/dist/server/agents/agent.d.ts +27 -0
  35. package/dist/server/agents/agent.js +16 -0
  36. package/dist/server/agents/connection.d.ts +51 -0
  37. package/dist/server/agents/connection.js +239 -0
  38. package/dist/server/agents/hub.d.ts +86 -0
  39. package/dist/server/agents/hub.js +241 -0
  40. package/dist/server/agents/local.d.ts +24 -0
  41. package/dist/server/agents/local.js +62 -0
  42. package/dist/server/agents/route.d.ts +3 -0
  43. package/dist/server/agents/route.js +24 -0
  44. package/dist/server/backup_files.d.ts +3 -0
  45. package/dist/server/backup_files.js +25 -0
  46. package/dist/server/config.d.ts +39 -0
  47. package/dist/server/config.js +109 -0
  48. package/dist/server/cors.d.ts +8 -0
  49. package/dist/server/cors.js +13 -0
  50. package/dist/server/events.d.ts +33 -0
  51. package/dist/server/events.js +68 -0
  52. package/dist/server/hash_password.d.ts +1 -0
  53. package/dist/server/hash_password.js +46 -0
  54. package/dist/server/login_page.d.ts +8 -0
  55. package/dist/server/login_page.js +79 -0
  56. package/dist/server/passwords.d.ts +8 -0
  57. package/dist/server/passwords.js +59 -0
  58. package/dist/server/replies.d.ts +12 -0
  59. package/dist/server/replies.js +41 -0
  60. package/dist/server/sign_in.d.ts +27 -0
  61. package/dist/server/sign_in.js +95 -0
  62. package/dist/server/web_folder.d.ts +11 -0
  63. package/dist/server/web_folder.js +40 -0
  64. package/dist/web/dom.d.ts +7 -0
  65. package/dist/web/dom.js +32 -0
  66. package/dist/web/drag.d.ts +3 -0
  67. package/dist/web/drag.js +13 -0
  68. package/dist/web/errors.d.ts +1 -0
  69. package/dist/web/errors.js +27 -0
  70. package/dist/web/format.d.ts +11 -0
  71. package/dist/web/format.js +82 -0
  72. package/dist/web/menus.d.ts +17 -0
  73. package/dist/web/menus.js +40 -0
  74. package/dist/web/prefs.d.ts +12 -0
  75. package/dist/web/prefs.js +62 -0
  76. package/dist/web/react/badges.d.ts +35 -0
  77. package/dist/web/react/badges.js +32 -0
  78. package/dist/web/react/controls.d.ts +86 -0
  79. package/dist/web/react/controls.js +154 -0
  80. package/dist/web/react/dialog.d.ts +45 -0
  81. package/dist/web/react/dialog.js +82 -0
  82. package/dist/web/react/following.d.ts +25 -0
  83. package/dist/web/react/following.js +34 -0
  84. package/dist/web/react/layout.d.ts +111 -0
  85. package/dist/web/react/layout.js +85 -0
  86. package/dist/web/react/list_picker.d.ts +19 -0
  87. package/dist/web/react/list_picker.js +50 -0
  88. package/dist/web/react/markdown.d.ts +8 -0
  89. package/dist/web/react/markdown.js +40 -0
  90. package/dist/web/react/media.d.ts +24 -0
  91. package/dist/web/react/media.js +18 -0
  92. package/dist/web/react/menu_button.d.ts +10 -0
  93. package/dist/web/react/menu_button.js +16 -0
  94. package/dist/web/react/menus.d.ts +31 -0
  95. package/dist/web/react/page_view.d.ts +16 -0
  96. package/dist/web/react/page_view.js +6 -0
  97. package/dist/web/react/prefs.d.ts +2 -0
  98. package/dist/web/react/prefs.js +11 -0
  99. package/dist/web/react/progress.d.ts +10 -0
  100. package/dist/web/react/progress.js +9 -0
  101. package/dist/web/react/rating.d.ts +8 -0
  102. package/dist/web/react/rating.js +23 -0
  103. package/dist/web/react/reveal.d.ts +10 -0
  104. package/dist/web/react/reveal.js +30 -0
  105. package/dist/web/react/shell.d.ts +46 -0
  106. package/dist/web/react/shell.js +94 -0
  107. package/dist/web/react/shortcuts.d.ts +23 -0
  108. package/dist/web/react/shortcuts.js +95 -0
  109. package/dist/web/react/split.d.ts +23 -0
  110. package/dist/web/react/split.js +94 -0
  111. package/dist/web/react/states.d.ts +36 -0
  112. package/dist/web/react/states.js +64 -0
  113. package/dist/web/react/store.d.ts +4 -0
  114. package/dist/web/react/store.js +7 -0
  115. package/dist/web/react/suggestions.d.ts +23 -0
  116. package/dist/web/react/suggestions.js +57 -0
  117. package/dist/web/react/table.d.ts +78 -0
  118. package/dist/web/react/table.js +404 -0
  119. package/dist/web/react/test_stubs.d.ts +1 -0
  120. package/dist/web/react/test_stubs.js +31 -0
  121. package/dist/web/react/time.d.ts +5 -0
  122. package/dist/web/react/time.js +19 -0
  123. package/dist/web/react/toasts.d.ts +29 -0
  124. package/dist/web/react/toasts.js +72 -0
  125. package/dist/web/react/tree.d.ts +28 -0
  126. package/dist/web/react/tree.js +183 -0
  127. package/dist/web/react/volume.d.ts +8 -0
  128. package/dist/web/react/volume.js +42 -0
  129. package/dist/web/react/watch.d.ts +3 -0
  130. package/dist/web/react/watch.js +5 -0
  131. package/dist/web/router.d.ts +7 -0
  132. package/dist/web/router.js +29 -0
  133. package/dist/web/selection.d.ts +25 -0
  134. package/dist/web/selection.js +106 -0
  135. package/dist/web/split.d.ts +15 -0
  136. package/dist/web/split.js +104 -0
  137. package/dist/web/states.d.ts +3 -0
  138. package/dist/web/states.js +10 -0
  139. package/dist/web/store.d.ts +11 -0
  140. package/dist/web/store.js +17 -0
  141. package/dist/web/tables.d.ts +28 -0
  142. package/dist/web/tables.js +88 -0
  143. package/dist/web/theme.d.ts +14 -0
  144. package/dist/web/theme.js +52 -0
  145. package/dist/web/tree.d.ts +40 -0
  146. package/dist/web/tree.js +130 -0
  147. package/dist/web/virtual.d.ts +7 -0
  148. package/dist/web/virtual.js +10 -0
  149. package/dist/web/watch.d.ts +11 -0
  150. package/dist/web/watch.js +37 -0
  151. package/package.json +1 -1
@@ -0,0 +1,46 @@
1
+ import { type ReactNode } from "react";
2
+ import { type Route } from "../router.ts";
3
+ import { type ThemeChoice } from "../theme.ts";
4
+ export declare function use_route(): Route;
5
+ export declare function ThemeButton({ choice }: {
6
+ choice: ThemeChoice;
7
+ }): import("react").JSX.Element;
8
+ export interface ShellSection {
9
+ id: string;
10
+ label: string;
11
+ href: string;
12
+ }
13
+ export interface SidebarWidth {
14
+ default_width: number;
15
+ min: number;
16
+ max: number;
17
+ storage_key?: string;
18
+ width?: number;
19
+ on_change?: (width: number, done: boolean) => void;
20
+ }
21
+ interface ShellProps {
22
+ brand: string;
23
+ brand_icon?: string;
24
+ sections?: ShellSection[];
25
+ active?: string;
26
+ sidebar?: ReactNode;
27
+ sidebar_width?: SidebarWidth;
28
+ theme: ThemeChoice;
29
+ user?: string | null;
30
+ logged_out?: boolean;
31
+ user_href?: string;
32
+ on_logout?: () => void;
33
+ brand_href?: string;
34
+ menu_open?: boolean;
35
+ on_menu_open_change?: (open: boolean) => void;
36
+ main_class?: string;
37
+ tools?: ReactNode;
38
+ sidebar_foot?: ReactNode;
39
+ status_bar?: ReactNode;
40
+ view_key?: string;
41
+ contained?: boolean;
42
+ brand_in_sidebar?: boolean;
43
+ children: ReactNode;
44
+ }
45
+ export declare function Shell({ brand, brand_icon, sections, active, sidebar: sidebar_content, sidebar_width, theme, user, logged_out: held_logged_out, user_href, on_logout, brand_href, tools, sidebar_foot, status_bar, view_key, menu_open: held_menu_open, on_menu_open_change, main_class, contained, brand_in_sidebar, children, }: ShellProps): import("react").JSX.Element;
46
+ export {};
@@ -0,0 +1,94 @@
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, a status bar, 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, brand_icon, sections = [], active, sidebar: sidebar_content, sidebar_width, theme, user, logged_out: held_logged_out, user_href, on_logout, brand_href = "#/", tools, sidebar_foot, status_bar, view_key, menu_open: held_menu_open, on_menu_open_change, main_class, contained = false, brand_in_sidebar = false, children, }) {
45
+ const [own_menu_open, set_own_menu_open] = useState(false);
46
+ const menu_open = held_menu_open ?? own_menu_open;
47
+ const main = useRef(null);
48
+ const menu_button = useRef(null);
49
+ const sidebar = useRef(null);
50
+ const first_view = useRef(true);
51
+ const logged_out = held_logged_out ?? user === null;
52
+ const user_name = typeof user === "string" && user !== "" ? user : undefined;
53
+ const [width, change_width] = use_sidebar_width(sidebar_width);
54
+ // Every opening and closing of the phone menu goes through here, so an app that holds it hears each one.
55
+ function set_menu_open(open) {
56
+ if (open === menu_open)
57
+ return;
58
+ if (held_menu_open === undefined)
59
+ set_own_menu_open(open);
60
+ on_menu_open_change?.(open);
61
+ }
62
+ // The login page has no theme button, so the saved theme is set here.
63
+ useEffect(() => theme.apply(), []);
64
+ useEffect(() => {
65
+ document.body.classList.toggle("logged-out", logged_out);
66
+ document.body.classList.toggle("menu-open", menu_open);
67
+ document.body.classList.toggle("has-status-bar", status_bar !== undefined);
68
+ document.body.classList.toggle("contained", contained);
69
+ }, [logged_out, menu_open, status_bar !== undefined, contained]);
70
+ useEffect(() => {
71
+ if (menu_open)
72
+ sidebar.current?.querySelector("a[href], button:not(:disabled), input, select, textarea, [tabindex='0']")?.focus();
73
+ if (!menu_open)
74
+ return;
75
+ const escape = (event) => {
76
+ if (event.key !== "Escape")
77
+ return;
78
+ set_menu_open(false);
79
+ menu_button.current?.focus();
80
+ };
81
+ document.addEventListener("keydown", escape);
82
+ return () => document.removeEventListener("keydown", escape);
83
+ }, [menu_open]);
84
+ // A new view closes the menu and takes focus, which tells a screen reader the page changed, but not on the first load.
85
+ useEffect(() => {
86
+ set_menu_open(false);
87
+ if (!first_view.current)
88
+ main.current?.focus({ preventScroll: true });
89
+ first_view.current = false;
90
+ window.scrollTo(0, 0);
91
+ main.current?.scrollTo?.(0, 0);
92
+ }, [view_key]);
93
+ 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" }), !(brand_in_sidebar && !logged_out) && _jsxs("a", { href: brand_href, className: "brand", children: [brand_icon && _jsx("img", { className: "brand-icon", src: brand_icon, alt: "" }), brand] }), !logged_out && tools, !logged_out && _jsx(ThemeButton, { choice: theme }), user_name !== undefined && (user_href ? _jsx("a", { className: "account-link", href: user_href, children: user_name }) : _jsx("span", { className: "user-name", children: user_name })), user_name !== undefined && 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: [brand_in_sidebar && _jsxs("a", { href: brand_href, className: "sidebar-brand", children: [brand_icon && _jsx("img", { className: "brand-icon", src: brand_icon, alt: "" }), brand] }), sidebar_content !== undefined && sidebar_content !== null ? _jsx("div", { className: "sidebar-content", 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", className: main_class, tabIndex: -1, children: children })] }), status_bar !== undefined && _jsx("footer", { className: "status-bar", children: status_bar }), _jsx(Toasts, {}), _jsx(Dialogs, {}), _jsx(ContextMenus, {})] }));
94
+ }
@@ -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
+ }
@@ -0,0 +1,23 @@
1
+ import { type ReactNode } from "react";
2
+ import { type Axis, type PaneSpec } from "../split.ts";
3
+ export type { PaneSpec } from "../split.ts";
4
+ interface ResizeHandleProps {
5
+ side: "left" | "right" | "top" | "bottom";
6
+ size: number;
7
+ min: number;
8
+ max: number;
9
+ default_size: number;
10
+ on_resize: (size: number) => void;
11
+ on_resize_end: (size: number) => void;
12
+ label: string;
13
+ class_name?: string;
14
+ }
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
+ interface SplitProps {
17
+ storage_key: string;
18
+ axis?: Axis;
19
+ specs: PaneSpec[];
20
+ preferred?: (number | undefined)[];
21
+ panes: ReactNode[];
22
+ }
23
+ export declare function Split({ storage_key, axis, specs, preferred, panes }: SplitProps): import("react").JSX.Element;
@@ -0,0 +1,94 @@
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";
4
+ import { distribute, fit, follow_drag, key_step, resize_at, store_sizes, stored_sizes } from "../split.js";
5
+ // A thin bar on a panel's inner edge: drag it or press the arrow keys to resize, double-click it to reset.
6
+ export function ResizeHandle({ side, size, min, max, default_size, on_resize, on_resize_end, label, class_name }) {
7
+ const axis = side === "left" || side === "right" ? "horizontal" : "vertical";
8
+ const direction = side === "left" || side === "top" ? 1 : -1;
9
+ const clamp = (value) => Math.round(Math.min(max, Math.max(min, value)));
10
+ function start_drag(event) {
11
+ const start_size = size;
12
+ let latest = size;
13
+ follow_drag(event.currentTarget, event.nativeEvent, axis, (moved) => {
14
+ latest = clamp(start_size + direction * moved);
15
+ on_resize(latest);
16
+ }, () => on_resize_end(latest));
17
+ }
18
+ function nudge(event) {
19
+ const step = key_step(event.nativeEvent, axis);
20
+ if (step === null)
21
+ return;
22
+ event.preventDefault();
23
+ on_resize_end(clamp(size + direction * step));
24
+ }
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 }));
26
+ }
27
+ // Panes side by side or stacked, with a handle between each two; double-clicking a handle restores the first sizes.
28
+ export function Split({ storage_key, axis = "horizontal", specs, preferred, panes }) {
29
+ const container = useRef(null);
30
+ const [sizes, set_sizes] = useState([]);
31
+ const latest = useRef(sizes);
32
+ latest.current = sizes;
33
+ // The length the panes share: the container's less the handles between them.
34
+ function extent() {
35
+ const element = container.current;
36
+ if (!element)
37
+ return 0;
38
+ const along = (child) => axis === "horizontal" ? child.offsetWidth : child.offsetHeight;
39
+ const handles = [...element.querySelectorAll(":scope > .resize-handle")].reduce((sum, handle) => sum + along(handle), 0);
40
+ return (axis === "horizontal" ? element.clientWidth : element.clientHeight) - handles;
41
+ }
42
+ function first_sizes(total) {
43
+ const saved = stored_sizes(storage_key, specs.length);
44
+ return saved ? fit(saved, total, specs) : distribute(total, specs, preferred);
45
+ }
46
+ function change(next, keep) {
47
+ set_sizes(next);
48
+ if (keep)
49
+ store_sizes(storage_key, next);
50
+ }
51
+ useEffect(() => {
52
+ const total = extent();
53
+ if (total > 0)
54
+ set_sizes(first_sizes(total));
55
+ // Sizing straight from the observer would resize what it watches, so the change waits for the next frame.
56
+ let queued = 0;
57
+ const observer = new ResizeObserver(() => {
58
+ if (queued)
59
+ return;
60
+ queued = requestAnimationFrame(() => {
61
+ queued = 0;
62
+ const length = extent();
63
+ if (length <= 0)
64
+ return;
65
+ set_sizes(latest.current.length === specs.length ? fit(latest.current, length, specs) : first_sizes(length));
66
+ });
67
+ });
68
+ observer.observe(container.current);
69
+ return () => {
70
+ if (queued)
71
+ cancelAnimationFrame(queued);
72
+ observer.disconnect();
73
+ };
74
+ }, []);
75
+ function start_drag(index, event) {
76
+ const before = [...latest.current];
77
+ let moved_to = before;
78
+ follow_drag(event.currentTarget, event.nativeEvent, axis, (moved) => {
79
+ moved_to = resize_at(before, index, moved, specs);
80
+ change(moved_to, false);
81
+ }, () => store_sizes(storage_key, moved_to));
82
+ }
83
+ function nudge(index, event) {
84
+ const step = key_step(event.nativeEvent, axis);
85
+ if (step === null)
86
+ return;
87
+ event.preventDefault();
88
+ change(resize_at(sizes, index, step, specs), true);
89
+ }
90
+ const template = sizes.length === specs.length
91
+ ? sizes.flatMap((size, index) => index === 0 ? [`${size}px`] : ["auto", `${size}px`]).join(" ")
92
+ : specs.map(() => "1fr").join(" auto ");
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))) }));
94
+ }
@@ -0,0 +1,36 @@
1
+ import { type ReactNode } from "react";
2
+ import type { Watch } from "../watch.ts";
3
+ export declare function Skeleton({ lines }: {
4
+ lines?: number;
5
+ }): import("react").JSX.Element;
6
+ export declare function Empty({ title, hint }: {
7
+ title: string;
8
+ hint?: ReactNode;
9
+ }): import("react").JSX.Element;
10
+ export declare function Failure({ error, retry }: {
11
+ error: unknown;
12
+ retry: () => void;
13
+ }): import("react").JSX.Element;
14
+ export type ResourceStatus = "idle" | "loading" | "loaded" | "failed";
15
+ export interface Resource<Data> {
16
+ data: Data | null;
17
+ error: unknown;
18
+ loading: boolean;
19
+ status: ResourceStatus;
20
+ reload: (quiet?: boolean) => Promise<void>;
21
+ }
22
+ export interface ResourceOptions {
23
+ refresh_in_milliseconds?: number;
24
+ enabled?: boolean;
25
+ watch?: Watch;
26
+ }
27
+ export declare function use_resource<Data>(work: () => Promise<Data>, dependencies: unknown[], options?: ResourceOptions): Resource<Data>;
28
+ interface LoadedProps<Data> {
29
+ resource: Resource<Data>;
30
+ lines?: number;
31
+ idle?: ReactNode;
32
+ empty?: ReactNode;
33
+ children: (data: Data) => ReactNode;
34
+ }
35
+ export declare function Loaded<Data>({ resource, lines, idle, empty, children }: LoadedProps<Data>): import("react").JSX.Element;
36
+ export {};
@@ -0,0 +1,64 @@
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 { describe_error } from "../errors.js";
5
+ import { pass_login_needed } from "../states.js";
6
+ // ── States ───────────────────────────────────────────────────────────────────
7
+ export function Skeleton({ lines = 3 }) {
8
+ 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)) }));
9
+ }
10
+ export function Empty({ title, hint }) {
11
+ return (_jsxs("div", { className: "empty", children: [_jsx("p", { className: "empty-title", children: title }), hint && _jsx("p", { className: "muted", children: hint })] }));
12
+ }
13
+ export function Failure({ error, retry }) {
14
+ return (_jsxs("div", { className: "failure", role: "alert", children: [_jsxs("p", { children: ["Could not load this: ", describe_error(error)] }), _jsx("button", { onClick: retry, children: "Retry" })] }));
15
+ }
16
+ // Loads what the work fetches whenever the dependencies change, passing a lapsed login on to the page.
17
+ export function use_resource(work, dependencies, options = {}) {
18
+ const enabled = options.enabled ?? true;
19
+ const [state, set_state] = useState({ data: null, error: null, status: enabled ? "loading" : "idle" });
20
+ const latest_work = useRef(work);
21
+ latest_work.current = work;
22
+ const generation = useRef(0);
23
+ const reload = useCallback((quiet = false) => {
24
+ const mine = ++generation.current;
25
+ if (!quiet)
26
+ set_state({ data: null, error: null, status: "loading" });
27
+ return latest_work.current().then((data) => { if (mine === generation.current)
28
+ set_state({ data, error: null, status: "loaded" }); }, (error) => {
29
+ if (pass_login_needed(error) || mine !== generation.current)
30
+ return;
31
+ set_state((before) => quiet && before.data !== null ? before : { data: null, error, status: "failed" });
32
+ });
33
+ }, []);
34
+ useEffect(() => {
35
+ if (enabled)
36
+ return void reload();
37
+ // Turning off drops the load under way and what it showed.
38
+ generation.current += 1;
39
+ set_state((before) => before.status === "idle" ? before : { data: null, error: null, status: "idle" });
40
+ }, [enabled, ...dependencies]);
41
+ useEffect(() => {
42
+ if (!options.refresh_in_milliseconds || !enabled)
43
+ return;
44
+ const timer = setInterval(() => { if (document.visibilityState === "visible")
45
+ void reload(true); }, options.refresh_in_milliseconds);
46
+ return () => clearInterval(timer);
47
+ }, [options.refresh_in_milliseconds, enabled]);
48
+ useEffect(() => {
49
+ if (!options.watch || !enabled)
50
+ return;
51
+ return options.watch.subscribe(() => void reload(true));
52
+ }, [options.watch, enabled]);
53
+ return { ...state, loading: state.status === "loading", reload };
54
+ }
55
+ // Shows a skeleton while the resource first loads, an error with Retry if that fails, and what children build from it after, going by its status so a null result still shows.
56
+ export function Loaded({ resource, lines, idle = null, empty, children }) {
57
+ if (resource.status === "loaded")
58
+ return _jsx(_Fragment, { children: resource.data === null && empty !== undefined ? empty : children(resource.data) });
59
+ if (resource.status === "idle")
60
+ return _jsx(_Fragment, { children: idle });
61
+ if (resource.status === "failed")
62
+ return _jsx(Failure, { error: resource.error, retry: () => void resource.reload() });
63
+ return _jsx(Skeleton, { lines: lines });
64
+ }
@@ -0,0 +1,4 @@
1
+ import type { Store } from "../store.ts";
2
+ export type { Store, StoreOptions } from "../store.ts";
3
+ export { create_store } from "../store.ts";
4
+ export declare function use_store<Value>(store: Store<Value>): Value;
@@ -0,0 +1,7 @@
1
+ // Reads a store's value and redraws the component when it changes.
2
+ import { use_watch } from "./watch.js";
3
+ export { create_store } from "../store.js";
4
+ export function use_store(store) {
5
+ use_watch(store.watch);
6
+ return store.get();
7
+ }
@@ -0,0 +1,23 @@
1
+ import { type FocusEvent, type KeyboardEvent, type ReactNode } from "react";
2
+ export interface FieldProps<Field extends HTMLElement> {
3
+ "aria-autocomplete": "list";
4
+ "aria-expanded": boolean;
5
+ "aria-controls": string;
6
+ "aria-activedescendant": string | undefined;
7
+ onKeyDown: (event: KeyboardEvent<Field>) => void;
8
+ onBlur: (event: FocusEvent<Field>) => void;
9
+ }
10
+ interface SuggestionsProps<Item, Field extends HTMLElement> {
11
+ value: string;
12
+ source: (value: string) => Item[] | Promise<Item[]>;
13
+ key_of: (item: Item) => string;
14
+ render: (item: Item) => ReactNode;
15
+ detail?: (item: Item) => ReactNode;
16
+ on_take: (item: Item) => void;
17
+ label: string;
18
+ on_key_down?: (event: KeyboardEvent<Field>) => void;
19
+ on_blur?: (event: FocusEvent<Field>) => void;
20
+ children: (field: FieldProps<Field>) => ReactNode;
21
+ }
22
+ export declare function Suggestions<Item, Field extends HTMLElement>({ value, source, key_of, render, detail, on_take, label, on_key_down, on_blur, children }: SuggestionsProps<Item, Field>): import("react").JSX.Element;
23
+ export {};
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Completions listed under a text field as it is typed into, such as slash commands: arrow keys choose, Tab or Enter take, Escape dismisses.
3
+ import { useEffect, useId, useRef, useState } from "react";
4
+ export function Suggestions({ value, source, key_of, render, detail, on_take, label, on_key_down, on_blur, children }) {
5
+ const list_id = useId();
6
+ const [items, set_items] = useState([]);
7
+ const [index, set_index] = useState(0);
8
+ const latest_value = useRef(value);
9
+ latest_value.current = value;
10
+ useEffect(() => {
11
+ let stale = false;
12
+ Promise.resolve(source(value)).then((found) => {
13
+ if (stale || latest_value.current !== value)
14
+ return;
15
+ set_items(found);
16
+ set_index(0);
17
+ });
18
+ return () => { stale = true; };
19
+ }, [value]);
20
+ function take(choice) {
21
+ const item = items[choice];
22
+ if (item === undefined)
23
+ return;
24
+ set_items([]);
25
+ on_take(item);
26
+ }
27
+ function key_down(event) {
28
+ if (items.length > 0 && !event.ctrlKey && !event.metaKey && !event.altKey) {
29
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
30
+ event.preventDefault();
31
+ set_index((index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length);
32
+ return;
33
+ }
34
+ if (event.key === "Tab" || event.key === "Enter") {
35
+ event.preventDefault();
36
+ take(index);
37
+ return;
38
+ }
39
+ if (event.key === "Escape") {
40
+ event.preventDefault();
41
+ set_items([]);
42
+ return;
43
+ }
44
+ }
45
+ on_key_down?.(event);
46
+ }
47
+ const option_id = (position) => `${list_id}-${position}`;
48
+ const field = {
49
+ "aria-autocomplete": "list",
50
+ "aria-expanded": items.length > 0,
51
+ "aria-controls": list_id,
52
+ "aria-activedescendant": items.length > 0 ? option_id(index) : undefined,
53
+ onKeyDown: key_down,
54
+ onBlur: (event) => { set_items([]); on_blur?.(event); },
55
+ };
56
+ return (_jsxs("div", { className: "suggest-wrap", children: [children(field), items.length > 0 && (_jsx("ul", { id: list_id, className: "suggestions menu", role: "listbox", "aria-label": label, children: items.map((item, position) => (_jsxs("li", { id: option_id(position), role: "option", className: "menu-item", "aria-selected": position === index, onMouseDown: (event) => { event.preventDefault(); take(position); }, children: [_jsx("span", { className: "suggestion-main", children: render(item) }), detail && _jsx("span", { className: "suggestion-detail muted small", children: detail(item) })] }, key_of(item)))) }))] }));
57
+ }