@x-tech-laboratories/x-libs 0.3.0 → 0.4.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 +11 -8
- package/dist/web/menus.d.ts +0 -7
- package/dist/web/menus.js +2 -87
- package/dist/web/preact/badges.d.ts +26 -0
- package/dist/web/preact/badges.js +28 -0
- package/dist/web/preact/controls.d.ts +14 -1
- package/dist/web/preact/controls.js +14 -7
- package/dist/web/preact/dialog.d.ts +25 -1
- package/dist/web/preact/dialog.js +56 -3
- package/dist/web/preact/layout.d.ts +37 -37
- package/dist/web/preact/layout.js +27 -25
- package/dist/web/preact/menus.d.ts +4 -3
- package/dist/web/preact/menus.js +56 -15
- package/dist/web/preact/shell.d.ts +27 -0
- package/dist/web/preact/shell.js +63 -0
- package/dist/web/preact/states.d.ts +27 -0
- package/dist/web/preact/states.js +48 -0
- package/dist/web/preact/table.d.ts +6 -1
- package/dist/web/preact/table.js +8 -1
- package/dist/web/preact/time.d.ts +4 -0
- package/dist/web/preact/time.js +9 -0
- package/dist/web/{toasts.d.ts → preact/toasts.d.ts} +2 -1
- package/dist/web/preact/toasts.js +34 -0
- package/dist/web/states.d.ts +1 -8
- package/dist/web/states.js +6 -43
- package/dist/web/tables.d.ts +4 -14
- package/dist/web/tables.js +1 -27
- package/dist/web/theme.d.ts +1 -1
- package/dist/web/theme.js +4 -1
- package/package.json +1 -1
- package/dist/web/badges.d.ts +0 -10
- package/dist/web/badges.js +0 -30
- package/dist/web/controls.d.ts +0 -6
- package/dist/web/controls.js +0 -55
- package/dist/web/dialogs.d.ts +0 -18
- package/dist/web/dialogs.js +0 -49
- package/dist/web/layout.d.ts +0 -28
- package/dist/web/layout.js +0 -53
- package/dist/web/shell.d.ts +0 -7
- package/dist/web/shell.js +0 -51
- package/dist/web/toasts.js +0 -22
package/dist/web/preact/menus.js
CHANGED
|
@@ -1,29 +1,70 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
-
//
|
|
2
|
+
// Menus and popovers on the native popover: closed by Escape or a click elsewhere, and here also by scrolling or leaving the window.
|
|
3
3
|
import { Fragment } from "preact";
|
|
4
|
-
import { useEffect, useLayoutEffect, useRef } from "preact/hooks";
|
|
5
|
-
import {
|
|
6
|
-
// ──
|
|
7
|
-
//
|
|
8
|
-
function
|
|
4
|
+
import { useEffect, useId, useLayoutEffect, useRef } from "preact/hooks";
|
|
5
|
+
import { on_screen_position } from "../menus.js";
|
|
6
|
+
// ── Opening ──────────────────────────────────────────────────────────────────
|
|
7
|
+
// Shows the element as a popover while it is rendered, calling the latest on_close once it closes for any reason.
|
|
8
|
+
function use_popover(on_close) {
|
|
9
9
|
const element = useRef(null);
|
|
10
10
|
const close = useRef(on_close);
|
|
11
11
|
close.current = on_close;
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const popover = element.current;
|
|
14
|
+
const hide = () => { if (popover.matches(":popover-open"))
|
|
15
|
+
popover.hidePopover(); };
|
|
16
|
+
const toggled = (event) => { if (event.newState === "closed")
|
|
17
|
+
close.current(); };
|
|
18
|
+
const outside_scroll = (event) => { if (!popover.contains(event.target))
|
|
19
|
+
hide(); };
|
|
20
|
+
popover.addEventListener("toggle", toggled);
|
|
21
|
+
window.addEventListener("blur", hide);
|
|
22
|
+
window.addEventListener("scroll", outside_scroll, true);
|
|
23
|
+
return () => {
|
|
24
|
+
popover.removeEventListener("toggle", toggled);
|
|
25
|
+
window.removeEventListener("blur", hide);
|
|
26
|
+
window.removeEventListener("scroll", outside_scroll, true);
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
14
29
|
return element;
|
|
15
30
|
}
|
|
31
|
+
// Shows the popover at the point, flipped or pushed back so it stays on screen.
|
|
32
|
+
function show_at(popover, x, y) {
|
|
33
|
+
if (!popover.matches(":popover-open"))
|
|
34
|
+
popover.showPopover();
|
|
35
|
+
const size = popover.getBoundingClientRect();
|
|
36
|
+
const position = on_screen_position(x, y, size.width, size.height);
|
|
37
|
+
popover.style.left = `${position.left}px`;
|
|
38
|
+
popover.style.top = `${position.top}px`;
|
|
39
|
+
}
|
|
16
40
|
export function ContextMenu({ x, y, items, on_close }) {
|
|
17
|
-
const menu =
|
|
18
|
-
|
|
19
|
-
|
|
41
|
+
const menu = use_popover(on_close);
|
|
42
|
+
useLayoutEffect(() => {
|
|
43
|
+
show_at(menu.current, x, y);
|
|
44
|
+
menu.current.querySelector(".menu-item:not(:disabled)")?.focus();
|
|
45
|
+
}, [x, y]);
|
|
46
|
+
return (_jsx("div", { ref: menu, popover: "auto", class: "menu", role: "menu", tabIndex: -1, children: items.map((item) => (_jsxs(Fragment, { children: [item.separator_before && _jsx("div", { class: "menu-separator", role: "separator" }), _jsxs("button", { type: "button", class: item.danger ? "menu-item danger" : "menu-item", role: item.checked === undefined ? "menuitem" : "menuitemcheckbox", "aria-checked": item.checked, disabled: item.disabled, onClick: () => {
|
|
20
47
|
on_close();
|
|
21
48
|
item.run();
|
|
22
49
|
}, children: [_jsx("span", { class: "menu-label", children: item.label }), item.hint && _jsx("span", { class: "menu-hint", children: item.hint })] })] }, item.label))) }));
|
|
23
50
|
}
|
|
24
51
|
// Holds any content, such as a column's filter, with its first field ready to type into.
|
|
25
|
-
export function Popover({ x, y, label, on_close, children }) {
|
|
26
|
-
const popover =
|
|
27
|
-
|
|
28
|
-
|
|
52
|
+
export function Popover({ anchor, x = 0, y = 0, label, on_close, children }) {
|
|
53
|
+
const popover = use_popover(on_close);
|
|
54
|
+
const anchor_name = `--popover-${useId().replace(/[^\w-]/g, "")}`;
|
|
55
|
+
useLayoutEffect(() => {
|
|
56
|
+
const element = popover.current;
|
|
57
|
+
if (anchor) {
|
|
58
|
+
// The anchor's name ties it to this popover, which CSS then hangs below it, flipping where there is no room.
|
|
59
|
+
anchor.style.setProperty("anchor-name", anchor_name);
|
|
60
|
+
element.style.setProperty("position-anchor", anchor_name);
|
|
61
|
+
element.showPopover();
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
show_at(element, x, y);
|
|
65
|
+
}
|
|
66
|
+
element.querySelector("input, select, textarea, button")?.focus();
|
|
67
|
+
return () => anchor?.style.removeProperty("anchor-name");
|
|
68
|
+
}, [anchor, x, y]);
|
|
69
|
+
return (_jsx("div", { ref: popover, popover: "auto", class: anchor ? "popover anchored" : "popover", role: "dialog", "aria-label": label, children: children }));
|
|
29
70
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ComponentChildren } from "preact";
|
|
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("preact").JSX.Element;
|
|
8
|
+
export interface ShellSection {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
href: string;
|
|
12
|
+
}
|
|
13
|
+
interface ShellProps {
|
|
14
|
+
brand: string;
|
|
15
|
+
sections: ShellSection[];
|
|
16
|
+
active: string;
|
|
17
|
+
theme: ThemeChoice;
|
|
18
|
+
user: string | null;
|
|
19
|
+
user_href?: string;
|
|
20
|
+
on_logout?: () => void;
|
|
21
|
+
tools?: ComponentChildren;
|
|
22
|
+
sidebar_foot?: ComponentChildren;
|
|
23
|
+
view_key?: string;
|
|
24
|
+
children: ComponentChildren;
|
|
25
|
+
}
|
|
26
|
+
export declare function Shell({ brand, sections, active, theme, user, user_href, on_logout, tools, sidebar_foot, view_key, children }: ShellProps): import("preact").JSX.Element;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "preact/hooks";
|
|
3
|
+
import { current_route } from "../router.js";
|
|
4
|
+
import { next_theme } from "../theme.js";
|
|
5
|
+
import { Dialogs } from "./dialog.js";
|
|
6
|
+
import { Toasts } from "./toasts.js";
|
|
7
|
+
// ── Route ────────────────────────────────────────────────────────────────────
|
|
8
|
+
// The hash route, read again whenever it changes.
|
|
9
|
+
export function use_route() {
|
|
10
|
+
const [route, set_route] = useState(current_route);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const changed = () => set_route(current_route());
|
|
13
|
+
window.addEventListener("hashchange", changed);
|
|
14
|
+
return () => window.removeEventListener("hashchange", changed);
|
|
15
|
+
}, []);
|
|
16
|
+
return route;
|
|
17
|
+
}
|
|
18
|
+
// ── Theme ────────────────────────────────────────────────────────────────────
|
|
19
|
+
const theme_labels = { system: ["◐", "Theme: system"], light: ["☀", "Theme: light"], dark: ["☾", "Theme: dark"] };
|
|
20
|
+
// Steps through system, light and dark, its glyph and label showing the current one.
|
|
21
|
+
export function ThemeButton({ choice }) {
|
|
22
|
+
const [theme, set_theme] = useState(choice.get);
|
|
23
|
+
useEffect(() => choice.on_change(set_theme), []);
|
|
24
|
+
const [icon, text] = theme_labels[theme];
|
|
25
|
+
return (_jsx("button", { id: "theme", class: "icon", title: `${text} (click to change)`, "aria-label": `${text}, click to change`, onClick: () => choice.set(next_theme(choice.get())), children: icon }));
|
|
26
|
+
}
|
|
27
|
+
export function Shell({ brand, sections, active, theme, user, user_href, on_logout, tools, sidebar_foot, view_key, children }) {
|
|
28
|
+
const [menu_open, set_menu_open] = useState(false);
|
|
29
|
+
const main = useRef(null);
|
|
30
|
+
const menu_button = useRef(null);
|
|
31
|
+
const sidebar = useRef(null);
|
|
32
|
+
const first_view = useRef(true);
|
|
33
|
+
const logged_out = user === null;
|
|
34
|
+
// The login page has no theme button, so the saved theme is set here.
|
|
35
|
+
useEffect(() => theme.apply(), []);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
document.body.classList.toggle("logged-out", logged_out);
|
|
38
|
+
document.body.classList.toggle("menu-open", menu_open);
|
|
39
|
+
}, [logged_out, menu_open]);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (menu_open)
|
|
42
|
+
sidebar.current?.querySelector("a")?.focus();
|
|
43
|
+
if (!menu_open)
|
|
44
|
+
return;
|
|
45
|
+
const escape = (event) => {
|
|
46
|
+
if (event.key !== "Escape")
|
|
47
|
+
return;
|
|
48
|
+
set_menu_open(false);
|
|
49
|
+
menu_button.current?.focus();
|
|
50
|
+
};
|
|
51
|
+
document.addEventListener("keydown", escape);
|
|
52
|
+
return () => document.removeEventListener("keydown", escape);
|
|
53
|
+
}, [menu_open]);
|
|
54
|
+
// A new view closes the menu and takes focus, which tells a screen reader the page changed, but not on the first load.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
set_menu_open(false);
|
|
57
|
+
if (!first_view.current)
|
|
58
|
+
main.current?.focus({ preventScroll: true });
|
|
59
|
+
first_view.current = false;
|
|
60
|
+
window.scrollTo(0, 0);
|
|
61
|
+
}, [view_key]);
|
|
62
|
+
return (_jsxs(_Fragment, { children: [_jsx("a", { class: "skip", href: "#main", children: "Skip to content" }), _jsxs("header", { class: "topbar", children: [_jsx("button", { ref: menu_button, class: "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: "#/", class: "brand", children: brand }), !logged_out && tools, !logged_out && _jsx(ThemeButton, { choice: theme }), !logged_out && (user_href ? _jsx("a", { class: "account-link", href: user_href, children: user }) : _jsx("span", { class: "user-name", children: user })), !logged_out && on_logout && _jsx("button", { onClick: on_logout, children: "Log out" })] }), _jsxs("div", { class: "layout", children: [_jsxs("nav", { ref: sidebar, id: "sidebar", class: "sidebar", "aria-label": "Main", children: [sections.map((section) => (_jsx("a", { href: section.href, "data-section": section.id, class: section.id === active ? "active" : undefined, "aria-current": section.id === active ? "page" : undefined, children: section.label }, section.id))), sidebar_foot] }), _jsx("div", { class: "scrim", hidden: !menu_open, onClick: () => set_menu_open(false) }), _jsx("main", { ref: main, id: "main", tabIndex: -1, children: children })] }), _jsx(Toasts, {}), _jsx(Dialogs, {})] }));
|
|
63
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ComponentChildren } from "preact";
|
|
2
|
+
export declare function Skeleton({ lines }: {
|
|
3
|
+
lines?: number;
|
|
4
|
+
}): import("preact").JSX.Element;
|
|
5
|
+
export declare function Empty({ title, hint }: {
|
|
6
|
+
title: string;
|
|
7
|
+
hint?: ComponentChildren;
|
|
8
|
+
}): import("preact").JSX.Element;
|
|
9
|
+
export declare function Failure({ error, retry }: {
|
|
10
|
+
error: unknown;
|
|
11
|
+
retry: () => void;
|
|
12
|
+
}): import("preact").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) => ComponentChildren;
|
|
27
|
+
}): import("preact").JSX.Element;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
|
3
|
+
import { pass_login_needed } from "../states.js";
|
|
4
|
+
// ── States ───────────────────────────────────────────────────────────────────
|
|
5
|
+
export function Skeleton({ lines = 3 }) {
|
|
6
|
+
return (_jsx("div", { class: "skeleton", "aria-busy": "true", "aria-label": "Loading", children: Array.from({ length: lines }, (_, index) => _jsx("div", { class: "skeleton-line", style: { width: `${90 - index * 15}%` } }, index)) }));
|
|
7
|
+
}
|
|
8
|
+
export function Empty({ title, hint }) {
|
|
9
|
+
return (_jsxs("div", { class: "empty", children: [_jsx("p", { class: "empty-title", children: title }), hint && _jsx("p", { class: "muted", children: hint })] }));
|
|
10
|
+
}
|
|
11
|
+
export function Failure({ error, retry }) {
|
|
12
|
+
return (_jsxs("div", { class: "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
|
+
// Loads what the work fetches whenever the dependencies change, passing a lapsed login on to the page.
|
|
15
|
+
export function use_resource(work, dependencies, options = {}) {
|
|
16
|
+
const [state, set_state] = useState({ data: null, error: null, loading: true });
|
|
17
|
+
const latest_work = useRef(work);
|
|
18
|
+
latest_work.current = work;
|
|
19
|
+
const generation = useRef(0);
|
|
20
|
+
const reload = useCallback((quiet = false) => {
|
|
21
|
+
const mine = ++generation.current;
|
|
22
|
+
if (!quiet)
|
|
23
|
+
set_state({ data: null, error: null, loading: true });
|
|
24
|
+
return latest_work.current().then((data) => { if (mine === generation.current)
|
|
25
|
+
set_state({ data, error: null, loading: false }); }, (error) => {
|
|
26
|
+
if (pass_login_needed(error) || mine !== generation.current)
|
|
27
|
+
return;
|
|
28
|
+
set_state((before) => quiet && before.data !== null ? { ...before, loading: false } : { data: null, error, loading: false });
|
|
29
|
+
});
|
|
30
|
+
}, []);
|
|
31
|
+
useEffect(() => void reload(), dependencies);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!options.refresh_in_milliseconds)
|
|
34
|
+
return;
|
|
35
|
+
const timer = setInterval(() => { if (document.visibilityState === "visible")
|
|
36
|
+
void reload(true); }, options.refresh_in_milliseconds);
|
|
37
|
+
return () => clearInterval(timer);
|
|
38
|
+
}, [options.refresh_in_milliseconds]);
|
|
39
|
+
return { ...state, reload };
|
|
40
|
+
}
|
|
41
|
+
// Shows a skeleton while the resource first loads, an error with Retry if that fails, and what children build from it after.
|
|
42
|
+
export function Loaded({ resource, lines, children }) {
|
|
43
|
+
if (resource.data !== null)
|
|
44
|
+
return _jsx(_Fragment, { children: children(resource.data) });
|
|
45
|
+
if (resource.error !== null)
|
|
46
|
+
return _jsx(Failure, { error: resource.error, retry: () => void resource.reload() });
|
|
47
|
+
return _jsx(Skeleton, { lines: lines });
|
|
48
|
+
}
|
|
@@ -10,9 +10,14 @@ export interface TableColumn<Row> {
|
|
|
10
10
|
interface DataTableProps<Row> {
|
|
11
11
|
columns: TableColumn<Row>[];
|
|
12
12
|
rows: Row[];
|
|
13
|
-
row_key
|
|
13
|
+
row_key?: (row: Row) => string | number;
|
|
14
14
|
sorting?: Sorting;
|
|
15
15
|
on_sort?: (sorting: Sorting) => void;
|
|
16
16
|
caption?: string;
|
|
17
17
|
}
|
|
18
18
|
export declare function DataTable<Row>({ columns, rows, row_key, sorting, on_sort, caption }: DataTableProps<Row>): import("preact").JSX.Element;
|
|
19
|
+
export declare function Pager({ page, has_more, go }: {
|
|
20
|
+
page: number;
|
|
21
|
+
has_more: boolean;
|
|
22
|
+
go: (page: number) => void;
|
|
23
|
+
}): import("preact").JSX.Element | null;
|
package/dist/web/preact/table.js
CHANGED
|
@@ -9,5 +9,12 @@ export function DataTable({ columns, rows, row_key, sorting, on_sort, caption })
|
|
|
9
9
|
const descending = active && sorting.descending;
|
|
10
10
|
return (_jsx("th", { class: column.class, scope: "col", "aria-sort": active ? (descending ? "descending" : "ascending") : "none", children: _jsxs("button", { class: "sort", onClick: () => on_sort({ key: column.label, descending: active ? !descending : false }), children: [column.label, active ? (descending ? " ↓" : " ↑") : ""] }) }, column.label));
|
|
11
11
|
}
|
|
12
|
-
return (_jsx("div", { class: "table-wrap", children: _jsxs("table", { class: "responsive", children: [caption && _jsx("caption", { class: "visually-hidden", children: caption }), _jsx("thead", { children: _jsx("tr", { children: columns.map(header) }) }), _jsx("tbody", { children: sorted_rows(columns, rows, sorting).map((row) => (_jsx("tr", { children: columns.map((column) => _jsx("td", { class: column.class, "data-label": column.label, children: column.cell(row) }, column.label)) }, row_key(row)))) })] }) }));
|
|
12
|
+
return (_jsx("div", { class: "table-wrap", children: _jsxs("table", { class: "responsive", children: [caption && _jsx("caption", { class: "visually-hidden", children: caption }), _jsx("thead", { children: _jsx("tr", { children: columns.map(header) }) }), _jsx("tbody", { children: sorted_rows(columns, rows, sorting).map((row) => (_jsx("tr", { children: columns.map((column) => _jsx("td", { class: column.class, "data-label": column.label, children: column.cell(row) }, column.label)) }, row_key ? row_key(row) : undefined))) })] }) }));
|
|
13
|
+
}
|
|
14
|
+
// ── Pages ────────────────────────────────────────────────────────────────────
|
|
15
|
+
// Newer and older buttons around the page number, or nothing when everything fits on one page.
|
|
16
|
+
export function Pager({ page, has_more, go }) {
|
|
17
|
+
if (page === 1 && !has_more)
|
|
18
|
+
return null;
|
|
19
|
+
return (_jsxs("nav", { class: "pager", "aria-label": "Pages", children: [_jsx("button", { disabled: page <= 1, onClick: () => go(page - 1), children: "\u2190 Newer" }), _jsxs("span", { class: "muted", children: ["Page ", page] }), _jsx("button", { disabled: !has_more, onClick: () => go(page + 1), children: "Older \u2192" })] }));
|
|
13
20
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "preact/jsx-runtime";
|
|
2
|
+
// Times as the page shows them: how long ago, with the full date and time on hover.
|
|
3
|
+
import { age, stamp } from "../format.js";
|
|
4
|
+
// A time as its age, such as "3h ago", or the missing text, a dash unless told otherwise, when there is none.
|
|
5
|
+
export function Age({ epoch, missing = "—" }) {
|
|
6
|
+
if (!epoch)
|
|
7
|
+
return _jsx(_Fragment, { children: missing });
|
|
8
|
+
return _jsx("span", { title: stamp(epoch), children: age(epoch) });
|
|
9
|
+
}
|
|
@@ -3,7 +3,7 @@ export interface ToastOptions {
|
|
|
3
3
|
link?: {
|
|
4
4
|
href: string;
|
|
5
5
|
text: string;
|
|
6
|
-
};
|
|
6
|
+
} | null;
|
|
7
7
|
action?: {
|
|
8
8
|
text: string;
|
|
9
9
|
run: () => void;
|
|
@@ -12,3 +12,4 @@ export interface ToastOptions {
|
|
|
12
12
|
duration_in_milliseconds?: number;
|
|
13
13
|
}
|
|
14
14
|
export declare function toast(message: string, options?: ToastOptions): () => void;
|
|
15
|
+
export declare function Toasts(): import("preact").JSX.Element;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
// Short notes in the page's corner after something happened, with an optional link or action such as Undo.
|
|
3
|
+
import { useEffect, useState } from "preact/hooks";
|
|
4
|
+
let shown = [];
|
|
5
|
+
let next_id = 1;
|
|
6
|
+
const listeners = new Set();
|
|
7
|
+
function publish(toasts) {
|
|
8
|
+
shown = toasts;
|
|
9
|
+
for (const listener of listeners)
|
|
10
|
+
listener(shown);
|
|
11
|
+
}
|
|
12
|
+
// Shows the message and returns a function that closes it early.
|
|
13
|
+
export function toast(message, options = {}) {
|
|
14
|
+
const id = next_id++;
|
|
15
|
+
const close = () => publish(shown.filter((item) => item.id !== id));
|
|
16
|
+
publish([...shown, { ...options, id, message }]);
|
|
17
|
+
if (!(options.sticky ?? options.tone === "bad"))
|
|
18
|
+
setTimeout(close, options.duration_in_milliseconds ?? 6000);
|
|
19
|
+
return close;
|
|
20
|
+
}
|
|
21
|
+
// ── Host ─────────────────────────────────────────────────────────────────────
|
|
22
|
+
// Where toasts stack; the Shell holds one, and a page without the Shell renders its own.
|
|
23
|
+
export function Toasts() {
|
|
24
|
+
const [toasts, set_toasts] = useState(shown);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
listeners.add(set_toasts);
|
|
27
|
+
return () => void listeners.delete(set_toasts);
|
|
28
|
+
}, []);
|
|
29
|
+
return (_jsx("div", { id: "toasts", class: "toasts", children: toasts.map((item) => {
|
|
30
|
+
const tone = item.tone ?? "info";
|
|
31
|
+
const close = () => publish(shown.filter((other) => other.id !== item.id));
|
|
32
|
+
return (_jsxs("div", { class: `toast tone-${tone}`, role: tone === "bad" ? "alert" : "status", children: [_jsx("span", { children: item.message }), item.link && _jsx("a", { href: item.link.href, onClick: close, children: item.link.text }), item.action && _jsx("button", { class: "toast-action", onClick: () => { close(); item.action.run(); }, children: item.action.text }), _jsx("button", { class: "icon", "aria-label": "Dismiss", onClick: close, children: "\u00D7" })] }, item.id));
|
|
33
|
+
}) }));
|
|
34
|
+
}
|
package/dist/web/states.d.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
import { type Child } from "./dom.ts";
|
|
2
1
|
export declare class LoginNeeded extends Error {
|
|
3
2
|
}
|
|
4
|
-
export declare function
|
|
5
|
-
export declare function empty(title: string, hint?: Child): HTMLElement;
|
|
6
|
-
export declare function failure(error: unknown, retry: () => void): HTMLElement;
|
|
7
|
-
export declare function load(target: HTMLElement, work: () => Promise<Child>, options?: {
|
|
8
|
-
quiet?: boolean;
|
|
9
|
-
}): Promise<boolean>;
|
|
10
|
-
export declare function attempt<Answer>(target: HTMLElement, quiet: boolean, work: () => Promise<Answer>, retry: () => void): Promise<Answer | null>;
|
|
3
|
+
export declare function pass_login_needed(error: unknown): boolean;
|
package/dist/web/states.js
CHANGED
|
@@ -1,47 +1,10 @@
|
|
|
1
|
-
// What a
|
|
2
|
-
import { h, nodes } from "./dom.js";
|
|
3
|
-
// ── Login ────────────────────────────────────────────────────────────────────
|
|
4
|
-
// Thrown by a request whose session has lapsed; loading passes it on so the page can ask for a login.
|
|
1
|
+
// What a request throws when the session has lapsed, so a page can tell it from any other failure and ask for a login.
|
|
5
2
|
export class LoginNeeded extends Error {
|
|
6
3
|
}
|
|
7
|
-
//
|
|
8
|
-
export function
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
export function empty(title, hint = null) {
|
|
12
|
-
return h("div", { class: "empty" }, h("p", { class: "empty-title" }, title), hint && h("p", { class: "muted" }, hint));
|
|
13
|
-
}
|
|
14
|
-
export function failure(error, retry) {
|
|
15
|
-
return h("div", { class: "failure", role: "alert" }, h("p", {}, `Could not load this: ${error instanceof Error ? error.message : String(error)}`), h("button", { onclick: retry }, "Retry"));
|
|
16
|
-
}
|
|
17
|
-
// ── Loading ──────────────────────────────────────────────────────────────────
|
|
18
|
-
// Fills the target with what the work builds, showing a skeleton first and an error with Retry if it fails.
|
|
19
|
-
export async function load(target, work, options = {}) {
|
|
20
|
-
if (!options.quiet)
|
|
21
|
-
target.replaceChildren(skeleton());
|
|
22
|
-
try {
|
|
23
|
-
target.replaceChildren(...nodes(await work()));
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
catch (error) {
|
|
27
|
-
// A login is the page's to ask for; a quiet refresh keeps what is already shown.
|
|
28
|
-
if (error instanceof LoginNeeded)
|
|
29
|
-
throw error;
|
|
30
|
-
if (!options.quiet)
|
|
31
|
-
target.replaceChildren(failure(error, () => void load(target, work)));
|
|
4
|
+
// Hands a LoginNeeded on as an unhandled rejection, which the page's login listens for.
|
|
5
|
+
export function pass_login_needed(error) {
|
|
6
|
+
if (!(error instanceof LoginNeeded))
|
|
32
7
|
return false;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// Fetches what a view needs, putting an error with Retry in the target when a first load fails; null when it failed.
|
|
36
|
-
export async function attempt(target, quiet, work, retry) {
|
|
37
|
-
try {
|
|
38
|
-
return await work();
|
|
39
|
-
}
|
|
40
|
-
catch (error) {
|
|
41
|
-
if (error instanceof LoginNeeded)
|
|
42
|
-
throw error;
|
|
43
|
-
if (!quiet)
|
|
44
|
-
target.replaceChildren(failure(error, retry));
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
8
|
+
void Promise.reject(error);
|
|
9
|
+
return true;
|
|
47
10
|
}
|
package/dist/web/tables.d.ts
CHANGED
|
@@ -1,20 +1,10 @@
|
|
|
1
|
-
import { type Child } from "./dom.ts";
|
|
2
|
-
export interface Column<Row> {
|
|
3
|
-
label: string;
|
|
4
|
-
cell: (row: Row) => Child;
|
|
5
|
-
sort?: (row: Row) => string | number | null;
|
|
6
|
-
class?: string;
|
|
7
|
-
}
|
|
8
1
|
export interface Sorting {
|
|
9
2
|
key: string;
|
|
10
3
|
descending: boolean;
|
|
11
4
|
}
|
|
12
|
-
export interface
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
caption?: string;
|
|
5
|
+
export interface SortableColumn<Row> {
|
|
6
|
+
label: string;
|
|
7
|
+
sort?: (row: Row) => string | number | null;
|
|
16
8
|
}
|
|
17
|
-
export declare function sorted_rows<Row>(columns:
|
|
9
|
+
export declare function sorted_rows<Row>(columns: SortableColumn<Row>[], rows: Row[], sorting: Sorting | undefined): Row[];
|
|
18
10
|
export declare function sorting_from(query: URLSearchParams, fallback: Sorting): Sorting;
|
|
19
|
-
export declare function data_table<Row>(columns: Column<Row>[], rows: Row[], options?: TableOptions): HTMLElement;
|
|
20
|
-
export declare function pager(page: number, has_more: boolean, go: (page: number) => void): HTMLElement | null;
|
package/dist/web/tables.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
import { h } from "./dom.js";
|
|
3
|
-
// ── Sorting ──────────────────────────────────────────────────────────────────
|
|
1
|
+
// The order a sorted table shows its rows in, and the sorting a view's query asks for.
|
|
4
2
|
// The rows in the order the sorting asks for, with rows missing a value last.
|
|
5
3
|
export function sorted_rows(columns, rows, sorting) {
|
|
6
4
|
const column = sorting ? columns.find((candidate) => candidate.label === sorting.key && candidate.sort) : undefined;
|
|
@@ -18,27 +16,3 @@ export function sorting_from(query, fallback) {
|
|
|
18
16
|
const key = query.get("sort");
|
|
19
17
|
return key ? { key, descending: query.get("dir") === "desc" } : fallback;
|
|
20
18
|
}
|
|
21
|
-
// ── Tables ───────────────────────────────────────────────────────────────────
|
|
22
|
-
// A table whose cells carry their column's label, so phones can show each row as a card.
|
|
23
|
-
export function data_table(columns, rows, options = {}) {
|
|
24
|
-
const sorting = options.sorting;
|
|
25
|
-
const sort_column = sorting ? columns.find((column) => column.label === sorting.key && column.sort) : undefined;
|
|
26
|
-
const header = (column) => {
|
|
27
|
-
if (!column.sort || !options.on_sort)
|
|
28
|
-
return h("th", { class: column.class ?? null, scope: "col" }, column.label);
|
|
29
|
-
const active = sort_column === column;
|
|
30
|
-
const descending = active && sorting.descending;
|
|
31
|
-
return h("th", { class: column.class ?? null, scope: "col", "aria-sort": active ? (descending ? "descending" : "ascending") : "none" }, h("button", {
|
|
32
|
-
class: "sort",
|
|
33
|
-
onclick: () => options.on_sort({ key: column.label, descending: active ? !descending : false }),
|
|
34
|
-
}, column.label, active ? (descending ? " ↓" : " ↑") : ""));
|
|
35
|
-
};
|
|
36
|
-
return h("div", { class: "table-wrap" }, h("table", { class: "responsive" }, options.caption ? h("caption", { class: "visually-hidden" }, options.caption) : null, h("thead", {}, h("tr", {}, ...columns.map(header))), h("tbody", {}, ...sorted_rows(columns, rows, sorting).map((row) => h("tr", {}, ...columns.map((column) => h("td", { class: column.class ?? null, "data-label": column.label }, column.cell(row))))))));
|
|
37
|
-
}
|
|
38
|
-
// ── Pages ────────────────────────────────────────────────────────────────────
|
|
39
|
-
// Newer and older buttons around the page number, or nothing when everything fits on one page.
|
|
40
|
-
export function pager(page, has_more, go) {
|
|
41
|
-
if (page === 1 && !has_more)
|
|
42
|
-
return null;
|
|
43
|
-
return h("nav", { class: "pager", "aria-label": "Pages" }, h("button", { disabled: page <= 1, onclick: () => go(page - 1) }, "← Newer"), h("span", { class: "muted" }, `Page ${page}`), h("button", { disabled: !has_more, onclick: () => go(page + 1) }, "Older →"));
|
|
44
|
-
}
|
package/dist/web/theme.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type Theme = "system" | "light" | "dark";
|
|
|
2
2
|
export interface ThemeChoice {
|
|
3
3
|
get(): Theme;
|
|
4
4
|
set(theme: Theme): void;
|
|
5
|
-
on_change(listener: (theme: Theme) => void): void;
|
|
5
|
+
on_change(listener: (theme: Theme) => void): () => void;
|
|
6
6
|
apply(theme?: Theme): void;
|
|
7
7
|
}
|
|
8
8
|
export declare function theme_choice(storage_key: string): ThemeChoice;
|
package/dist/web/theme.js
CHANGED
|
@@ -31,7 +31,10 @@ export function theme_choice(storage_key) {
|
|
|
31
31
|
}
|
|
32
32
|
apply(theme);
|
|
33
33
|
};
|
|
34
|
-
return { get, set, apply, on_change: (listener) =>
|
|
34
|
+
return { get, set, apply, on_change: (listener) => {
|
|
35
|
+
listeners.add(listener);
|
|
36
|
+
return () => void listeners.delete(listener);
|
|
37
|
+
} };
|
|
35
38
|
}
|
|
36
39
|
// The header's button steps through system, light and dark.
|
|
37
40
|
export function next_theme(theme) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@x-tech-laboratories/x-libs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Code the X-apps share: agents and their long runs, passwords, durations, and the web page's helpers and widgets",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
package/dist/web/badges.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { type Child } from "./dom.ts";
|
|
2
|
-
export type Tone = "good" | "warn" | "bad" | "info" | "none";
|
|
3
|
-
export declare function badge(text: Child, tone?: Tone, title?: string): HTMLElement;
|
|
4
|
-
export declare function state_badge<State extends string>(labels: Record<State, [text: string, tone: Tone]>, state: State): HTMLElement;
|
|
5
|
-
export declare function chip(text: Child, title?: string): HTMLElement;
|
|
6
|
-
export declare function chip_button(text: Child, on_click: () => void, title?: string): HTMLElement;
|
|
7
|
-
export declare function chips(items: Child[], options?: {
|
|
8
|
-
inline?: boolean;
|
|
9
|
-
}): HTMLElement;
|
|
10
|
-
export declare function verdict(ok: boolean | null | undefined, label: string): HTMLElement;
|
package/dist/web/badges.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
// Badges, chips and verdicts: short labels whose tone is written out, so meaning never rests on color alone.
|
|
2
|
-
import { h } from "./dom.js";
|
|
3
|
-
// ── Badges ───────────────────────────────────────────────────────────────────
|
|
4
|
-
export function badge(text, tone = "none", title = "") {
|
|
5
|
-
return h("span", { class: `badge tone-${tone}`, title: title || null }, text);
|
|
6
|
-
}
|
|
7
|
-
// A badge for one of a fixed set of states, each named by its text and tone.
|
|
8
|
-
export function state_badge(labels, state) {
|
|
9
|
-
const [text, tone] = labels[state];
|
|
10
|
-
return badge(text, tone);
|
|
11
|
-
}
|
|
12
|
-
// ── Chips ────────────────────────────────────────────────────────────────────
|
|
13
|
-
export function chip(text, title = "") {
|
|
14
|
-
return h("span", { class: "chip", title: title || null }, text);
|
|
15
|
-
}
|
|
16
|
-
// A chip that picks what it names, such as a tag to filter by.
|
|
17
|
-
export function chip_button(text, on_click, title = "") {
|
|
18
|
-
return h("button", { type: "button", class: "chip", title: title || null, onclick: on_click }, text);
|
|
19
|
-
}
|
|
20
|
-
// A row of chips or badges, with less space between them when they sit inside a line of text.
|
|
21
|
-
export function chips(items, options = {}) {
|
|
22
|
-
return h("span", { class: options.inline ? "chips inline-chips" : "chips" }, ...items);
|
|
23
|
-
}
|
|
24
|
-
// ── Verdicts ─────────────────────────────────────────────────────────────────
|
|
25
|
-
// A pass, fail or not-yet verdict, such as "checks: pass".
|
|
26
|
-
export function verdict(ok, label) {
|
|
27
|
-
const text = ok === null || ok === undefined ? "no verdict" : ok ? "pass" : "fail";
|
|
28
|
-
const tone = ok === null || ok === undefined ? "none" : ok ? "good" : "bad";
|
|
29
|
-
return h("span", { class: `verdict tone-${tone}` }, `${label}: ${text}`);
|
|
30
|
-
}
|
package/dist/web/controls.d.ts
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import { type Child } from "./dom.ts";
|
|
2
|
-
export declare function search_box(label: string, value: string, on_input: (value: string) => void): HTMLInputElement;
|
|
3
|
-
export declare function select(label: string, options: [value: string, text: string][], value: string, on_change: (value: string) => void): HTMLElement;
|
|
4
|
-
export declare function segmented<Value extends string>(label: string, choices: [value: Value, text: string][], value: Value, on_change: (value: Value) => void): HTMLElement;
|
|
5
|
-
export declare function button_group(label: string, ...buttons: Child[]): HTMLElement;
|
|
6
|
-
export declare function switch_toggle(label: string, checked: boolean, on_change: (on: boolean) => Promise<boolean | void> | boolean | void): HTMLInputElement;
|