@x-tech-laboratories/x-libs 0.4.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -13
- package/dist/backups.d.ts +31 -0
- package/dist/backups.js +43 -0
- package/dist/dates.d.ts +9 -0
- package/dist/dates.js +60 -0
- package/dist/durations.d.ts +9 -1
- package/dist/durations.js +19 -5
- package/dist/electron/app.d.ts +13 -0
- package/dist/electron/app.js +27 -0
- package/dist/electron/bridge.d.ts +32 -0
- package/dist/electron/bridge.js +53 -0
- package/dist/electron/events.d.ts +21 -0
- package/dist/electron/events.js +25 -0
- package/dist/electron/fetch.d.ts +25 -0
- package/dist/electron/fetch.js +67 -0
- package/dist/electron/window.d.ts +96 -0
- package/dist/electron/window.js +128 -0
- package/dist/log.d.ts +30 -0
- package/dist/log.js +79 -0
- package/dist/server/backup_files.d.ts +3 -0
- package/dist/server/backup_files.js +25 -0
- package/dist/web/drag.d.ts +3 -0
- package/dist/web/drag.js +13 -0
- package/dist/web/format.d.ts +1 -0
- package/dist/web/format.js +5 -0
- package/dist/web/menus.d.ts +4 -0
- package/dist/web/menus.js +28 -1
- package/dist/web/prefs.d.ts +12 -0
- package/dist/web/prefs.js +62 -0
- package/dist/web/{preact → react}/badges.d.ts +9 -9
- package/dist/web/{preact → react}/badges.js +6 -6
- package/dist/web/react/controls.d.ts +72 -0
- package/dist/web/react/controls.js +88 -0
- package/dist/web/{preact → react}/dialog.d.ts +10 -10
- package/dist/web/{preact → react}/dialog.js +14 -9
- package/dist/web/react/layout.d.ts +100 -0
- package/dist/web/react/layout.js +80 -0
- package/dist/web/react/menus.d.ts +31 -0
- package/dist/web/react/menus.js +159 -0
- package/dist/web/react/prefs.d.ts +2 -0
- package/dist/web/react/prefs.js +11 -0
- package/dist/web/react/reveal.d.ts +9 -0
- package/dist/web/react/reveal.js +30 -0
- package/dist/web/react/shell.d.ts +41 -0
- package/dist/web/react/shell.js +90 -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 +69 -0
- package/dist/web/react/table.js +278 -0
- package/dist/web/{preact → react}/time.d.ts +2 -1
- package/dist/web/react/time.js +19 -0
- package/dist/web/react/toasts.d.ts +29 -0
- package/dist/web/react/toasts.js +72 -0
- package/dist/web/react/tree.d.ts +28 -0
- package/dist/web/react/tree.js +183 -0
- package/dist/web/react/watch.d.ts +3 -0
- package/dist/web/react/watch.js +5 -0
- package/dist/web/selection.d.ts +25 -0
- package/dist/web/selection.js +106 -0
- package/dist/web/tables.d.ts +19 -1
- package/dist/web/tables.js +78 -8
- package/dist/web/theme.d.ts +6 -1
- package/dist/web/theme.js +31 -21
- package/dist/web/tree.d.ts +37 -0
- package/dist/web/tree.js +128 -0
- package/dist/web/virtual.d.ts +7 -0
- package/dist/web/virtual.js +10 -0
- package/dist/web/watch.d.ts +6 -0
- package/dist/web/watch.js +17 -0
- package/package.json +15 -4
- package/dist/web/preact/controls.d.ts +0 -37
- package/dist/web/preact/controls.js +0 -43
- package/dist/web/preact/layout.d.ts +0 -80
- 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/time.js +0 -9
- package/dist/web/preact/toasts.d.ts +0 -15
- package/dist/web/preact/toasts.js +0 -34
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// A remembered setting as component state, read once when the component mounts and written on every change.
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { browser_storage, read_pref, write_pref } from "../prefs.js";
|
|
4
|
+
export function use_pref(key, initial, validate, storage = browser_storage()) {
|
|
5
|
+
const [value, set_value] = useState(() => read_pref(storage, key, initial, validate));
|
|
6
|
+
const change = (next) => {
|
|
7
|
+
set_value(next);
|
|
8
|
+
write_pref(storage, key, next);
|
|
9
|
+
};
|
|
10
|
+
return [value, change];
|
|
11
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface RevealOptions {
|
|
2
|
+
root_margin?: string;
|
|
3
|
+
}
|
|
4
|
+
export interface Reveal {
|
|
5
|
+
shown_count: number;
|
|
6
|
+
more_ref: (element: Element | null) => void;
|
|
7
|
+
reset: () => void;
|
|
8
|
+
}
|
|
9
|
+
export declare function use_reveal(page_size: number, options?: RevealOptions): Reveal;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A long list shown a page at a time, the next page added as its end scrolls into view.
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
export function use_reveal(page_size, options = {}) {
|
|
4
|
+
const root_margin = options.root_margin ?? "200px";
|
|
5
|
+
const [shown_count, set_shown_count] = useState(page_size);
|
|
6
|
+
const observer = useRef(null);
|
|
7
|
+
const marker = useRef(null);
|
|
8
|
+
const more_ref = useCallback((element) => {
|
|
9
|
+
observer.current?.disconnect();
|
|
10
|
+
observer.current = null;
|
|
11
|
+
marker.current = element;
|
|
12
|
+
if (!element)
|
|
13
|
+
return;
|
|
14
|
+
observer.current = new IntersectionObserver((entries) => {
|
|
15
|
+
if (entries.some((entry) => entry.isIntersecting))
|
|
16
|
+
set_shown_count((count) => count + page_size);
|
|
17
|
+
}, { rootMargin: root_margin });
|
|
18
|
+
observer.current.observe(element);
|
|
19
|
+
}, [page_size, root_margin]);
|
|
20
|
+
// Watching the marker afresh after each page asks again whether it is still in view, so a short page keeps filling.
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!observer.current || !marker.current)
|
|
23
|
+
return;
|
|
24
|
+
observer.current.unobserve(marker.current);
|
|
25
|
+
observer.current.observe(marker.current);
|
|
26
|
+
}, [shown_count]);
|
|
27
|
+
useEffect(() => () => observer.current?.disconnect(), []);
|
|
28
|
+
const reset = useCallback(() => set_shown_count(page_size), [page_size]);
|
|
29
|
+
return { shown_count, more_ref, reset };
|
|
30
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
sections?: ShellSection[];
|
|
24
|
+
active?: string;
|
|
25
|
+
sidebar?: ReactNode;
|
|
26
|
+
sidebar_width?: SidebarWidth;
|
|
27
|
+
theme: ThemeChoice;
|
|
28
|
+
user?: string | null;
|
|
29
|
+
user_href?: string;
|
|
30
|
+
on_logout?: () => void;
|
|
31
|
+
brand_href?: string;
|
|
32
|
+
menu_open?: boolean;
|
|
33
|
+
on_menu_open_change?: (open: boolean) => void;
|
|
34
|
+
main_class?: string;
|
|
35
|
+
tools?: ReactNode;
|
|
36
|
+
sidebar_foot?: ReactNode;
|
|
37
|
+
view_key?: string;
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
}
|
|
40
|
+
export declare function Shell({ brand, sections, active, sidebar: sidebar_content, sidebar_width, theme, user, user_href, on_logout, brand_href, tools, sidebar_foot, view_key, menu_open: held_menu_open, on_menu_open_change, main_class, children, }: ShellProps): import("react").JSX.Element;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
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, brand_href = "#/", tools, sidebar_foot, view_key, menu_open: held_menu_open, on_menu_open_change, main_class, 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 = user === null;
|
|
52
|
+
const [width, change_width] = use_sidebar_width(sidebar_width);
|
|
53
|
+
// Every opening and closing of the phone menu goes through here, so an app that holds it hears each one.
|
|
54
|
+
function set_menu_open(open) {
|
|
55
|
+
if (open === menu_open)
|
|
56
|
+
return;
|
|
57
|
+
if (held_menu_open === undefined)
|
|
58
|
+
set_own_menu_open(open);
|
|
59
|
+
on_menu_open_change?.(open);
|
|
60
|
+
}
|
|
61
|
+
// The login page has no theme button, so the saved theme is set here.
|
|
62
|
+
useEffect(() => theme.apply(), []);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
document.body.classList.toggle("logged-out", logged_out);
|
|
65
|
+
document.body.classList.toggle("menu-open", menu_open);
|
|
66
|
+
}, [logged_out, menu_open]);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (menu_open)
|
|
69
|
+
sidebar.current?.querySelector("a[href], button:not(:disabled), input, select, textarea, [tabindex='0']")?.focus();
|
|
70
|
+
if (!menu_open)
|
|
71
|
+
return;
|
|
72
|
+
const escape = (event) => {
|
|
73
|
+
if (event.key !== "Escape")
|
|
74
|
+
return;
|
|
75
|
+
set_menu_open(false);
|
|
76
|
+
menu_button.current?.focus();
|
|
77
|
+
};
|
|
78
|
+
document.addEventListener("keydown", escape);
|
|
79
|
+
return () => document.removeEventListener("keydown", escape);
|
|
80
|
+
}, [menu_open]);
|
|
81
|
+
// A new view closes the menu and takes focus, which tells a screen reader the page changed, but not on the first load.
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
set_menu_open(false);
|
|
84
|
+
if (!first_view.current)
|
|
85
|
+
main.current?.focus({ preventScroll: true });
|
|
86
|
+
first_view.current = false;
|
|
87
|
+
window.scrollTo(0, 0);
|
|
88
|
+
}, [view_key]);
|
|
89
|
+
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: brand_href, className: "brand", children: brand }), !logged_out && tools, !logged_out && _jsx(ThemeButton, { choice: theme }), typeof user === "string" && (user_href ? _jsx("a", { className: "account-link", href: user_href, children: user }) : _jsx("span", { className: "user-name", children: user })), typeof user === "string" && 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", className: main_class, tabIndex: -1, children: children })] }), _jsx(Toasts, {}), _jsx(Dialogs, {}), _jsx(ContextMenus, {})] }));
|
|
90
|
+
}
|
|
@@ -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,69 @@
|
|
|
1
|
+
import { type CSSProperties, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
|
|
2
|
+
import { type Sorting } from "../tables.ts";
|
|
3
|
+
import { type DropPlacement } from "../tree.ts";
|
|
4
|
+
export type { Sorting } from "../tables.ts";
|
|
5
|
+
export type { DropPlacement } from "../tree.ts";
|
|
6
|
+
export interface TableColumn<Row> {
|
|
7
|
+
label: string;
|
|
8
|
+
id?: string;
|
|
9
|
+
cell: (row: Row) => ReactNode;
|
|
10
|
+
sort?: (row: Row) => string | number | null;
|
|
11
|
+
sortable?: boolean;
|
|
12
|
+
class?: string;
|
|
13
|
+
width?: number;
|
|
14
|
+
min_width?: number;
|
|
15
|
+
max_width?: number;
|
|
16
|
+
hideable?: boolean;
|
|
17
|
+
movable?: boolean;
|
|
18
|
+
filter?: ReactNode | ((close: () => void) => ReactNode);
|
|
19
|
+
filter_active?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface RowGroup<Row> {
|
|
22
|
+
key: string;
|
|
23
|
+
label: ReactNode;
|
|
24
|
+
rows: Row[];
|
|
25
|
+
class_name?: string;
|
|
26
|
+
}
|
|
27
|
+
interface DataTableProps<Row> {
|
|
28
|
+
columns: TableColumn<Row>[];
|
|
29
|
+
rows: Row[];
|
|
30
|
+
groups?: RowGroup<Row>[];
|
|
31
|
+
row_key?: (row: Row) => string | number;
|
|
32
|
+
caption?: string;
|
|
33
|
+
sorting?: Sorting | Sorting[];
|
|
34
|
+
manual_sorting?: boolean;
|
|
35
|
+
on_sort?: (sorting: Sorting) => void;
|
|
36
|
+
on_sorting_change?: (sorting: Sorting[]) => void;
|
|
37
|
+
column_order?: string[];
|
|
38
|
+
on_column_order_change?: (order: string[]) => void;
|
|
39
|
+
column_widths?: Record<string, number>;
|
|
40
|
+
on_column_widths_change?: (widths: Record<string, number>, done: boolean) => void;
|
|
41
|
+
hidden_columns?: string[];
|
|
42
|
+
on_hidden_columns_change?: (hidden: string[]) => void;
|
|
43
|
+
row_passes?: (row: Row) => boolean;
|
|
44
|
+
on_clear_filters?: () => void;
|
|
45
|
+
get_children?: (row: Row) => Row[] | undefined;
|
|
46
|
+
expanded?: string[] | true;
|
|
47
|
+
on_expanded_change?: (expanded: string[]) => void;
|
|
48
|
+
on_row_click?: (row: Row, event: MouseEvent<HTMLTableRowElement>) => void;
|
|
49
|
+
on_row_context_menu?: (row: Row, event: MouseEvent<HTMLTableRowElement>) => void;
|
|
50
|
+
on_row_double_click?: (row: Row, event: MouseEvent<HTMLTableRowElement>) => void;
|
|
51
|
+
on_row_key_down?: (row: Row, event: KeyboardEvent<HTMLTableRowElement>) => void;
|
|
52
|
+
active_row?: string | number;
|
|
53
|
+
row_selected?: (row: Row) => boolean;
|
|
54
|
+
row_class?: (row: Row) => string | undefined;
|
|
55
|
+
row_title?: (row: Row) => string | undefined;
|
|
56
|
+
row_style?: (row: Row) => CSSProperties | undefined;
|
|
57
|
+
render_after_row?: (row: Row) => ReactNode;
|
|
58
|
+
on_row_drag_start?: (row: Row, event: DragEvent<HTMLTableRowElement>) => void;
|
|
59
|
+
on_row_drag_end?: () => void;
|
|
60
|
+
can_drop_on_row?: (row: Row, placement: DropPlacement) => boolean;
|
|
61
|
+
allow_inside_drop?: boolean;
|
|
62
|
+
on_row_drop?: (row: Row, placement: DropPlacement, event: DragEvent<HTMLTableRowElement>) => void;
|
|
63
|
+
}
|
|
64
|
+
export declare function DataTable<Row>(props: DataTableProps<Row>): import("react").JSX.Element;
|
|
65
|
+
export declare function Pager({ page, has_more, go }: {
|
|
66
|
+
page: number;
|
|
67
|
+
has_more: boolean;
|
|
68
|
+
go: (page: number) => void;
|
|
69
|
+
}): import("react").JSX.Element | null;
|