@x-tech-laboratories/x-libs 0.3.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -10
- package/dist/dates.d.ts +9 -0
- package/dist/dates.js +60 -0
- package/dist/durations.d.ts +8 -1
- package/dist/durations.js +9 -5
- package/dist/electron/bridge.d.ts +30 -0
- package/dist/electron/bridge.js +32 -0
- package/dist/electron/window.d.ts +85 -0
- package/dist/electron/window.js +85 -0
- package/dist/web/menus.d.ts +4 -7
- package/dist/web/menus.js +28 -86
- package/dist/web/react/badges.d.ts +26 -0
- package/dist/web/react/badges.js +28 -0
- package/dist/web/react/controls.d.ts +37 -0
- package/dist/web/react/controls.js +44 -0
- package/dist/web/react/dialog.d.ts +35 -0
- package/dist/web/react/dialog.js +78 -0
- package/dist/web/react/layout.d.ts +80 -0
- package/dist/web/react/layout.js +71 -0
- package/dist/web/react/menus.d.ts +31 -0
- package/dist/web/react/menus.js +159 -0
- package/dist/web/react/shell.d.ts +37 -0
- package/dist/web/react/shell.js +81 -0
- package/dist/web/react/shortcuts.d.ts +23 -0
- package/dist/web/react/shortcuts.js +95 -0
- package/dist/web/{preact → react}/split.d.ts +4 -4
- package/dist/web/{preact → react}/split.js +9 -10
- package/dist/web/react/states.d.ts +27 -0
- package/dist/web/react/states.js +49 -0
- package/dist/web/react/table.d.ts +46 -0
- package/dist/web/react/table.js +210 -0
- package/dist/web/react/time.d.ts +4 -0
- package/dist/web/react/time.js +9 -0
- package/dist/web/{toasts.d.ts → react/toasts.d.ts} +4 -1
- package/dist/web/react/toasts.js +41 -0
- package/dist/web/react/tree.d.ts +23 -0
- package/dist/web/react/tree.js +168 -0
- package/dist/web/states.d.ts +1 -8
- package/dist/web/states.js +6 -43
- package/dist/web/tables.d.ts +21 -14
- package/dist/web/tables.js +69 -33
- package/dist/web/theme.d.ts +7 -2
- package/dist/web/theme.js +35 -22
- package/dist/web/tree.d.ts +35 -0
- package/dist/web/tree.js +107 -0
- package/package.json +15 -4
- 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/preact/controls.d.ts +0 -24
- package/dist/web/preact/controls.js +0 -36
- package/dist/web/preact/dialog.d.ts +0 -11
- package/dist/web/preact/dialog.js +0 -20
- package/dist/web/preact/layout.d.ts +0 -80
- package/dist/web/preact/layout.js +0 -69
- package/dist/web/preact/menus.d.ts +0 -18
- package/dist/web/preact/menus.js +0 -29
- package/dist/web/preact/table.d.ts +0 -18
- package/dist/web/preact/table.js +0 -13
- package/dist/web/shell.d.ts +0 -7
- package/dist/web/shell.js +0 -51
- package/dist/web/toasts.js +0 -22
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// ── Badges ───────────────────────────────────────────────────────────────────
|
|
3
|
+
export function Badge({ tone = "none", title, children }) {
|
|
4
|
+
return _jsx("span", { className: `badge tone-${tone}`, title: title, children: children });
|
|
5
|
+
}
|
|
6
|
+
// A badge for one of a fixed set of states, each named by its text and tone.
|
|
7
|
+
export function StateBadge({ labels, state, title }) {
|
|
8
|
+
const [text, tone] = labels[state];
|
|
9
|
+
return _jsx(Badge, { tone: tone, title: title, children: text });
|
|
10
|
+
}
|
|
11
|
+
// ── Chips ────────────────────────────────────────────────────────────────────
|
|
12
|
+
// A chip, or a button chip that picks what it names when it has on_click, such as a tag to filter by.
|
|
13
|
+
export function Chip({ title, on_click, class_name, children }) {
|
|
14
|
+
const chip_class = class_name ? `chip ${class_name}` : "chip";
|
|
15
|
+
return on_click
|
|
16
|
+
? _jsx("button", { type: "button", className: chip_class, title: title, onClick: on_click, children: children })
|
|
17
|
+
: _jsx("span", { className: chip_class, title: title, children: children });
|
|
18
|
+
}
|
|
19
|
+
// A row of chips or badges, with less space between them when they sit inside a line of text.
|
|
20
|
+
export function Chips({ inline = false, children }) {
|
|
21
|
+
return _jsx("span", { className: inline ? "chips inline-chips" : "chips", children: children });
|
|
22
|
+
}
|
|
23
|
+
// ── Verdicts ─────────────────────────────────────────────────────────────────
|
|
24
|
+
// A pass, fail or not-yet verdict, such as "checks: pass".
|
|
25
|
+
export function Verdict({ ok, label }) {
|
|
26
|
+
const known = ok !== null && ok !== undefined;
|
|
27
|
+
return _jsxs("span", { className: `verdict tone-${known ? (ok ? "good" : "bad") : "none"}`, children: [label, ": ", known ? (ok ? "pass" : "fail") : "no verdict"] });
|
|
28
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
interface SegmentedProps<Value extends string> {
|
|
3
|
+
label: string;
|
|
4
|
+
choices: {
|
|
5
|
+
value: Value;
|
|
6
|
+
label: string;
|
|
7
|
+
}[];
|
|
8
|
+
value: Value;
|
|
9
|
+
on_change: (value: Value) => void;
|
|
10
|
+
class_name?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function Segmented<Value extends string>({ label, choices, value, on_change, class_name }: SegmentedProps<Value>): import("react").JSX.Element;
|
|
13
|
+
export declare function ButtonGroup({ label, children }: {
|
|
14
|
+
label: string;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
17
|
+
interface SwitchProps {
|
|
18
|
+
label: string;
|
|
19
|
+
checked: boolean;
|
|
20
|
+
on_change: (on: boolean) => Promise<boolean | void> | boolean | void;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function Switch({ label, checked, on_change, disabled }: SwitchProps): import("react").JSX.Element;
|
|
24
|
+
interface SearchBoxProps {
|
|
25
|
+
label: string;
|
|
26
|
+
value: string;
|
|
27
|
+
on_input: (value: string) => void;
|
|
28
|
+
}
|
|
29
|
+
export declare function SearchBox({ label, value, on_input }: SearchBoxProps): import("react").JSX.Element;
|
|
30
|
+
interface SelectProps<Value extends string> {
|
|
31
|
+
label: string;
|
|
32
|
+
options: [value: Value, text: string][];
|
|
33
|
+
value: Value;
|
|
34
|
+
on_change: (value: Value) => void;
|
|
35
|
+
}
|
|
36
|
+
export declare function Select<Value extends string>({ label, options, value, on_change }: SelectProps<Value>): import("react").JSX.Element;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// React controls drawn like the page's own: segmented choices, switches and search fields.
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
// One of a few choices side by side; the chosen one takes the accent.
|
|
5
|
+
export function Segmented({ label, choices, value, on_change, class_name }) {
|
|
6
|
+
return (_jsx("div", { className: class_name ? `segmented ${class_name}` : "segmented", role: "radiogroup", "aria-label": label, children: choices.map((choice) => (_jsx("button", { type: "button", role: "radio", "aria-checked": value === choice.value, onClick: () => on_change(choice.value), children: choice.label }, choice.value))) }));
|
|
7
|
+
}
|
|
8
|
+
// Buttons joined into one control, such as zoom out, zoom in and fit.
|
|
9
|
+
export function ButtonGroup({ label, children }) {
|
|
10
|
+
return _jsx("div", { className: "segmented", role: "group", "aria-label": label, children: children });
|
|
11
|
+
}
|
|
12
|
+
export function Switch({ label, checked, on_change, disabled = false }) {
|
|
13
|
+
const [busy, set_busy] = useState(false);
|
|
14
|
+
async function change(event) {
|
|
15
|
+
const box = event.currentTarget;
|
|
16
|
+
const on = box.checked;
|
|
17
|
+
set_busy(true);
|
|
18
|
+
try {
|
|
19
|
+
if ((await on_change(on)) === false)
|
|
20
|
+
box.checked = !on;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
box.checked = !on;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
set_busy(false);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return _jsx("input", { type: "checkbox", role: "switch", "aria-label": label, checked: checked, disabled: disabled || busy, onChange: change });
|
|
31
|
+
}
|
|
32
|
+
// A search field that hands on what was typed once typing pauses; the field keeps its own text, so a redraw never wipes typing.
|
|
33
|
+
export function SearchBox({ label, value, on_input }) {
|
|
34
|
+
const timer = useRef(undefined);
|
|
35
|
+
useEffect(() => () => clearTimeout(timer.current), []);
|
|
36
|
+
return (_jsx("input", { type: "search", className: "filter", placeholder: label, "aria-label": label, defaultValue: value, onChange: (event) => {
|
|
37
|
+
clearTimeout(timer.current);
|
|
38
|
+
const text = event.currentTarget.value;
|
|
39
|
+
timer.current = window.setTimeout(() => on_input(text), 200);
|
|
40
|
+
} }));
|
|
41
|
+
}
|
|
42
|
+
export function Select({ label, options, value, on_change }) {
|
|
43
|
+
return (_jsxs("label", { className: "field", children: [_jsx("span", { className: "field-label", children: label }), _jsx("select", { value: value, onChange: (event) => on_change(event.currentTarget.value), children: options.map(([option, text]) => _jsx("option", { value: option, children: text }, option)) })] }));
|
|
44
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
interface DialogProps {
|
|
3
|
+
title: ReactNode;
|
|
4
|
+
on_close: () => void;
|
|
5
|
+
buttons?: ReactNode;
|
|
6
|
+
wide?: boolean;
|
|
7
|
+
class_name?: string;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
export declare function Dialog({ title, on_close, buttons, wide, class_name, children }: DialogProps): import("react").JSX.Element;
|
|
11
|
+
export interface ConfirmOptions {
|
|
12
|
+
title: string;
|
|
13
|
+
message: ReactNode;
|
|
14
|
+
confirm_label: string;
|
|
15
|
+
busy_label?: string;
|
|
16
|
+
danger?: boolean;
|
|
17
|
+
wide?: boolean;
|
|
18
|
+
type_to_confirm?: string;
|
|
19
|
+
run: () => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export declare function ConfirmDialog({ options, on_close }: {
|
|
22
|
+
options: ConfirmOptions;
|
|
23
|
+
on_close: () => void;
|
|
24
|
+
}): import("react").JSX.Element;
|
|
25
|
+
export declare function TextDialog({ title, text, on_close }: {
|
|
26
|
+
title: string;
|
|
27
|
+
text: string;
|
|
28
|
+
on_close: () => void;
|
|
29
|
+
}): import("react").JSX.Element;
|
|
30
|
+
type Builder = (close: () => void) => ReactNode;
|
|
31
|
+
export declare function open_dialog(build: Builder): () => void;
|
|
32
|
+
export declare function confirm_dialog(options: ConfirmOptions): void;
|
|
33
|
+
export declare function text_dialog(title: string, text: string): void;
|
|
34
|
+
export declare function Dialogs(): import("react").JSX.Element;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Modal dialogs on the native one: any body and buttons, one that asks before an action, and one that shows a long text.
|
|
3
|
+
import { Fragment, useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { toast } from "./toasts.js";
|
|
5
|
+
// Shown while it is rendered: the page behind it is inert, focus stays inside, Escape calls on_close, and focus goes back to the opener after.
|
|
6
|
+
export function Dialog({ title, on_close, buttons, wide = false, class_name, children }) {
|
|
7
|
+
const dialog = useRef(null);
|
|
8
|
+
const title_id = useId();
|
|
9
|
+
const close = useRef(on_close);
|
|
10
|
+
close.current = on_close;
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const element = dialog.current;
|
|
13
|
+
const opener = document.activeElement;
|
|
14
|
+
element.showModal();
|
|
15
|
+
return () => {
|
|
16
|
+
element.close();
|
|
17
|
+
// The dialog is already gone from the page here, so the browser cannot hand focus back by itself.
|
|
18
|
+
if (opener?.isConnected && opener !== document.body)
|
|
19
|
+
opener.focus();
|
|
20
|
+
};
|
|
21
|
+
}, []);
|
|
22
|
+
return (_jsxs("dialog", { ref: dialog, className: ["dialog", wide && "wide", class_name].filter(Boolean).join(" "), "aria-labelledby": title_id, onCancel: (event) => {
|
|
23
|
+
// Escape asks the owner to close, so the dialog never closes behind its owner's back.
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
event.stopPropagation();
|
|
26
|
+
close.current();
|
|
27
|
+
}, children: [_jsx("h2", { id: title_id, children: title }), _jsx("div", { className: "dialog-body", children: children }), buttons && _jsx("div", { className: "dialog-buttons", children: buttons })] }));
|
|
28
|
+
}
|
|
29
|
+
export function ConfirmDialog({ options, on_close }) {
|
|
30
|
+
const [typed, set_typed] = useState("");
|
|
31
|
+
const [busy, set_busy] = useState(false);
|
|
32
|
+
const ready = options.type_to_confirm === undefined || typed === options.type_to_confirm;
|
|
33
|
+
async function confirm() {
|
|
34
|
+
set_busy(true);
|
|
35
|
+
try {
|
|
36
|
+
await options.run();
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
on_close();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return (_jsxs(Dialog, { title: options.title, on_close: () => { if (!busy)
|
|
43
|
+
on_close(); }, wide: options.wide, buttons: _jsxs(_Fragment, { children: [_jsx("button", { disabled: busy, onClick: on_close, children: "Cancel" }), _jsx("button", { className: options.danger ? "danger solid" : "accent solid", disabled: !ready || busy, onClick: confirm, autoFocus: options.type_to_confirm === undefined, children: busy ? options.busy_label ?? "Working…" : options.confirm_label })] }), children: [typeof options.message === "string" ? _jsx("p", { children: options.message }) : options.message, options.type_to_confirm !== undefined && (_jsxs("label", { className: "type-confirm", children: [_jsxs("span", { children: ["Type ", _jsx("strong", { children: options.type_to_confirm }), " to confirm:"] }), _jsx("input", { type: "text", className: "filter", autoComplete: "off", autoFocus: true, "aria-label": `Type ${options.type_to_confirm} to confirm`, value: typed, onChange: (event) => set_typed(event.currentTarget.value) })] }))] }));
|
|
44
|
+
}
|
|
45
|
+
// Shows a long text result, such as a preview, with a button to copy it.
|
|
46
|
+
export function TextDialog({ title, text, on_close }) {
|
|
47
|
+
return (_jsx(Dialog, { title: title, on_close: on_close, wide: true, buttons: _jsxs(_Fragment, { children: [_jsx("button", { onClick: () => void navigator.clipboard?.writeText(text).then(() => toast("Copied.")), children: "Copy" }), _jsx("button", { className: "accent solid", onClick: on_close, children: "Close" })] }), children: _jsx("pre", { className: "output", children: text }) }));
|
|
48
|
+
}
|
|
49
|
+
let open = [];
|
|
50
|
+
let next_id = 1;
|
|
51
|
+
const listeners = new Set();
|
|
52
|
+
function publish(dialogs) {
|
|
53
|
+
open = dialogs;
|
|
54
|
+
for (const listener of listeners)
|
|
55
|
+
listener(open);
|
|
56
|
+
}
|
|
57
|
+
// Opens what the builder makes, such as a Dialog, from an event handler; returns the function that closes it.
|
|
58
|
+
export function open_dialog(build) {
|
|
59
|
+
const id = next_id++;
|
|
60
|
+
const close = () => publish(open.filter((dialog) => dialog.id !== id));
|
|
61
|
+
publish([...open, { id, build }]);
|
|
62
|
+
return close;
|
|
63
|
+
}
|
|
64
|
+
export function confirm_dialog(options) {
|
|
65
|
+
open_dialog((close) => _jsx(ConfirmDialog, { options: options, on_close: close }));
|
|
66
|
+
}
|
|
67
|
+
export function text_dialog(title, text) {
|
|
68
|
+
open_dialog((close) => _jsx(TextDialog, { title: title, text: text, on_close: close }));
|
|
69
|
+
}
|
|
70
|
+
// Where dialogs opened from anywhere are drawn; the Shell holds one.
|
|
71
|
+
export function Dialogs() {
|
|
72
|
+
const [dialogs, set_dialogs] = useState(open);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
listeners.add(set_dialogs);
|
|
75
|
+
return () => void listeners.delete(set_dialogs);
|
|
76
|
+
}, []);
|
|
77
|
+
return _jsx(_Fragment, { children: dialogs.map((dialog) => _jsx(Fragment, { children: dialog.build(() => publish(open.filter((other) => other.id !== dialog.id))) }, dialog.id)) });
|
|
78
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { Tone } from "./badges.tsx";
|
|
3
|
+
export declare function Section({ title, tools, children }: {
|
|
4
|
+
title: ReactNode;
|
|
5
|
+
tools?: ReactNode;
|
|
6
|
+
children?: ReactNode;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
8
|
+
export type Fact = [label: string, value: ReactNode, class_name?: string];
|
|
9
|
+
export declare function Facts({ rows, layout }: {
|
|
10
|
+
rows: (Fact | null | false)[];
|
|
11
|
+
layout?: "grid" | "rows";
|
|
12
|
+
}): import("react").JSX.Element;
|
|
13
|
+
export declare function Stack({ tight, children }: {
|
|
14
|
+
tight?: boolean;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
17
|
+
interface TileProps {
|
|
18
|
+
label: string;
|
|
19
|
+
value: ReactNode;
|
|
20
|
+
tone?: Tone;
|
|
21
|
+
class_name?: string | null;
|
|
22
|
+
href?: string;
|
|
23
|
+
value_first?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare function Tile({ label, value, tone, class_name, href, value_first }: TileProps): import("react").JSX.Element;
|
|
26
|
+
export declare function Tiles({ children }: {
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
}): import("react").JSX.Element;
|
|
29
|
+
interface CardProps {
|
|
30
|
+
title: ReactNode;
|
|
31
|
+
subtitle?: ReactNode;
|
|
32
|
+
badge?: ReactNode;
|
|
33
|
+
class_name?: string;
|
|
34
|
+
href?: string;
|
|
35
|
+
on_click?: () => void;
|
|
36
|
+
children?: ReactNode;
|
|
37
|
+
}
|
|
38
|
+
export declare function Card({ title, subtitle, badge, class_name, href, on_click, children }: CardProps): import("react").JSX.Element;
|
|
39
|
+
export declare function Cards({ children }: {
|
|
40
|
+
children: ReactNode;
|
|
41
|
+
}): import("react").JSX.Element;
|
|
42
|
+
export interface TabItem<Id extends string> {
|
|
43
|
+
id: Id;
|
|
44
|
+
label: string;
|
|
45
|
+
href?: string;
|
|
46
|
+
}
|
|
47
|
+
export declare function Tabs<Id extends string>({ items, active, label, on_select }: {
|
|
48
|
+
items: TabItem<Id>[];
|
|
49
|
+
active: Id;
|
|
50
|
+
label?: string;
|
|
51
|
+
on_select?: (id: Id) => void;
|
|
52
|
+
}): import("react").JSX.Element;
|
|
53
|
+
export declare function Crumbs({ items }: {
|
|
54
|
+
items: [label: string, href: string | null][];
|
|
55
|
+
}): import("react").JSX.Element;
|
|
56
|
+
export declare function Toolbar({ children }: {
|
|
57
|
+
children: ReactNode;
|
|
58
|
+
}): import("react").JSX.Element;
|
|
59
|
+
interface Step {
|
|
60
|
+
text: string;
|
|
61
|
+
href: string | null;
|
|
62
|
+
}
|
|
63
|
+
export declare function Stepper({ label, previous, position, next }: {
|
|
64
|
+
label: string;
|
|
65
|
+
previous: Step;
|
|
66
|
+
position: ReactNode;
|
|
67
|
+
next: Step;
|
|
68
|
+
}): import("react").JSX.Element;
|
|
69
|
+
export declare function SettingsGroup({ heading, hint, children }: {
|
|
70
|
+
heading: string;
|
|
71
|
+
hint?: ReactNode;
|
|
72
|
+
children: ReactNode;
|
|
73
|
+
}): import("react").JSX.Element;
|
|
74
|
+
export declare function SettingRow({ label, hint, as_label, children }: {
|
|
75
|
+
label: ReactNode;
|
|
76
|
+
hint?: ReactNode;
|
|
77
|
+
as_label?: boolean;
|
|
78
|
+
children: ReactNode;
|
|
79
|
+
}): import("react").JSX.Element;
|
|
80
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The pieces a page is laid out with: panels, facts, tiles, cards, tabs, breadcrumbs, toolbars, steppers and settings rows.
|
|
3
|
+
import { Fragment } from "react";
|
|
4
|
+
// ── Panels ───────────────────────────────────────────────────────────────────
|
|
5
|
+
export function Section({ title, tools, children }) {
|
|
6
|
+
return (_jsxs("section", { className: "panel", children: [_jsxs("div", { className: "panel-head", children: [_jsx("h2", { children: title }), tools && _jsx("div", { className: "panel-tools", children: tools })] }), children] }));
|
|
7
|
+
}
|
|
8
|
+
// Labelled values in a grid of tiles, or as rows of label and value for long values such as paths.
|
|
9
|
+
export function Facts({ rows, layout = "grid" }) {
|
|
10
|
+
return (_jsx("dl", { className: layout === "rows" ? "facts facts-rows" : "facts", children: rows.filter((row) => Boolean(row)).map(([label, value, class_name]) => (_jsxs("div", { children: [_jsx("dt", { children: label }), _jsx("dd", { className: class_name, children: value ?? "—" })] }, label))) }));
|
|
11
|
+
}
|
|
12
|
+
// A stack of panels or blocks spaced by its gap, tighter when tight.
|
|
13
|
+
export function Stack({ tight = false, children }) {
|
|
14
|
+
return _jsx("div", { className: tight ? "stack tight" : "stack", children: children });
|
|
15
|
+
}
|
|
16
|
+
// One headline figure and its label.
|
|
17
|
+
export function Tile({ label, value, tone, class_name, href, value_first = false }) {
|
|
18
|
+
const tile_class = ["tile", tone && tone !== "none" && `tile-${tone}`, class_name].filter(Boolean).join(" ");
|
|
19
|
+
const label_part = _jsx("div", { className: "tile-label", children: label });
|
|
20
|
+
const value_part = _jsx("div", { className: "tile-value", children: value });
|
|
21
|
+
const children = value_first ? _jsxs(_Fragment, { children: [value_part, label_part] }) : _jsxs(_Fragment, { children: [label_part, value_part] });
|
|
22
|
+
return href ? _jsx("a", { className: tile_class, href: href, children: children }) : _jsx("div", { className: tile_class, children: children });
|
|
23
|
+
}
|
|
24
|
+
export function Tiles({ children }) {
|
|
25
|
+
return _jsx("div", { className: "tiles", children: children });
|
|
26
|
+
}
|
|
27
|
+
// A titled card for one thing among many, laid out in a grid of Cards.
|
|
28
|
+
export function Card({ title, subtitle, badge, class_name, href, on_click, children }) {
|
|
29
|
+
const card_class = class_name ? `card ${class_name}` : "card";
|
|
30
|
+
const body = (_jsxs(_Fragment, { children: [_jsxs("div", { className: "card-head", children: [_jsx("strong", { children: title }), badge] }), subtitle && _jsx("div", { className: "muted card-host", children: subtitle }), children] }));
|
|
31
|
+
if (href)
|
|
32
|
+
return _jsx("a", { className: card_class, href: href, children: body });
|
|
33
|
+
if (on_click)
|
|
34
|
+
return _jsx("button", { type: "button", className: card_class, onClick: on_click, children: body });
|
|
35
|
+
return _jsx("div", { className: card_class, children: body });
|
|
36
|
+
}
|
|
37
|
+
export function Cards({ children }) {
|
|
38
|
+
return _jsx("div", { className: "grid", children: children });
|
|
39
|
+
}
|
|
40
|
+
export function Tabs({ items, active, label = "Sections", on_select }) {
|
|
41
|
+
return (_jsx("nav", { className: "tabs", "aria-label": label, children: items.map((item) => {
|
|
42
|
+
const tab_class = item.id === active ? "tab active" : "tab";
|
|
43
|
+
const current = item.id === active ? "page" : undefined;
|
|
44
|
+
return item.href
|
|
45
|
+
? _jsx("a", { className: tab_class, href: item.href, "aria-current": current, children: item.label }, item.id)
|
|
46
|
+
: _jsx("button", { type: "button", className: tab_class, "aria-current": current, onClick: () => on_select?.(item.id), children: item.label }, item.id);
|
|
47
|
+
}) }));
|
|
48
|
+
}
|
|
49
|
+
export function Crumbs({ items }) {
|
|
50
|
+
return (_jsx("nav", { className: "crumbs", "aria-label": "Breadcrumb", children: items.map(([label, href], index) => (_jsxs(Fragment, { children: [index > 0 && _jsx("span", { className: "muted", "aria-hidden": "true", children: " / " }), href ? _jsx("a", { href: href, children: label }) : _jsx("span", { "aria-current": "page", children: label })] }, index))) }));
|
|
51
|
+
}
|
|
52
|
+
// ── Toolbars ─────────────────────────────────────────────────────────────────
|
|
53
|
+
export function Toolbar({ children }) {
|
|
54
|
+
return _jsx("div", { className: "toolbar", children: children });
|
|
55
|
+
}
|
|
56
|
+
// Steps to the item before or after this one, each a link or a dead button when there is none, with where this one stands between.
|
|
57
|
+
export function Stepper({ label, previous, position, next }) {
|
|
58
|
+
const step = (target) => target.href === null
|
|
59
|
+
? _jsx("button", { disabled: true, children: target.text })
|
|
60
|
+
: _jsx("a", { className: "button", href: target.href, children: target.text });
|
|
61
|
+
return _jsxs("div", { className: "stepper", role: "group", "aria-label": label, children: [step(previous), _jsx("span", { className: "muted", children: position }), step(next)] });
|
|
62
|
+
}
|
|
63
|
+
// ── Settings ─────────────────────────────────────────────────────────────────
|
|
64
|
+
export function SettingsGroup({ heading, hint, children }) {
|
|
65
|
+
return (_jsxs("section", { className: "settings-group", children: [_jsx("h3", { className: "settings-heading", children: heading }), hint && _jsx("p", { className: "setting-hint", children: hint }), children] }));
|
|
66
|
+
}
|
|
67
|
+
// A setting's name and hint on one side and its control on the other; as a label, clicking anywhere works its one control.
|
|
68
|
+
export function SettingRow({ label, hint, as_label = false, children }) {
|
|
69
|
+
const text = (_jsxs("span", { className: "setting-text", children: [_jsx("span", { className: "setting-label", children: label }), hint && _jsx("span", { className: "setting-hint", children: hint })] }));
|
|
70
|
+
return as_label ? _jsxs("label", { className: "setting-row", children: [text, children] }) : _jsxs("div", { className: "setting-row", children: [text, children] });
|
|
71
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type MenuItem } from "../menus.ts";
|
|
3
|
+
export type { MenuItem } from "../menus.ts";
|
|
4
|
+
interface MenuListProps {
|
|
5
|
+
items: MenuItem[];
|
|
6
|
+
on_close: () => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function MenuList({ items, on_close }: MenuListProps): import("react").JSX.Element;
|
|
9
|
+
interface ContextMenuProps {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
items: MenuItem[];
|
|
13
|
+
on_close: () => void;
|
|
14
|
+
label?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function ContextMenu({ x, y, items, on_close, label }: ContextMenuProps): import("react").JSX.Element;
|
|
17
|
+
export declare function open_context_menu(event: {
|
|
18
|
+
clientX: number;
|
|
19
|
+
clientY: number;
|
|
20
|
+
preventDefault(): void;
|
|
21
|
+
}, items: MenuItem[]): void;
|
|
22
|
+
export declare function ContextMenus(): import("react").JSX.Element | null;
|
|
23
|
+
interface PopoverProps {
|
|
24
|
+
anchor?: HTMLElement | null;
|
|
25
|
+
x?: number;
|
|
26
|
+
y?: number;
|
|
27
|
+
label: string;
|
|
28
|
+
on_close: () => void;
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
export declare function Popover({ anchor, x, y, label, on_close, children }: PopoverProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
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
|
+
import { Fragment, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { item_after_key, item_starting_with, on_screen_position } from "../menus.js";
|
|
5
|
+
// Shows the element as a popover while it is rendered, calling the latest on_close once it closes for any reason.
|
|
6
|
+
function use_popover(on_close, opener) {
|
|
7
|
+
const element = useRef(null);
|
|
8
|
+
const close = useRef(on_close);
|
|
9
|
+
close.current = on_close;
|
|
10
|
+
const focused_before = useRef(opener ?? document.activeElement);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const popover = element.current;
|
|
13
|
+
const hide = () => { if (popover.matches(":popover-open"))
|
|
14
|
+
popover.hidePopover(); };
|
|
15
|
+
const toggled = (event) => { if (event.newState === "closed")
|
|
16
|
+
close.current(); };
|
|
17
|
+
const outside_scroll = (event) => { if (!popover.contains(event.target))
|
|
18
|
+
hide(); };
|
|
19
|
+
popover.addEventListener("toggle", toggled);
|
|
20
|
+
window.addEventListener("blur", hide);
|
|
21
|
+
window.addEventListener("scroll", outside_scroll, true);
|
|
22
|
+
return () => {
|
|
23
|
+
popover.removeEventListener("toggle", toggled);
|
|
24
|
+
window.removeEventListener("blur", hide);
|
|
25
|
+
window.removeEventListener("scroll", outside_scroll, true);
|
|
26
|
+
};
|
|
27
|
+
}, []);
|
|
28
|
+
const return_focus = () => {
|
|
29
|
+
const before = focused_before.current;
|
|
30
|
+
if (before?.isConnected && before !== document.body)
|
|
31
|
+
before.focus();
|
|
32
|
+
};
|
|
33
|
+
const dismiss = () => {
|
|
34
|
+
const popover = element.current;
|
|
35
|
+
return_focus();
|
|
36
|
+
if (popover?.matches(":popover-open"))
|
|
37
|
+
popover.hidePopover();
|
|
38
|
+
else
|
|
39
|
+
close.current();
|
|
40
|
+
};
|
|
41
|
+
return { element, dismiss, return_focus };
|
|
42
|
+
}
|
|
43
|
+
// Shows the popover at the point, flipped or pushed back so it stays on screen.
|
|
44
|
+
function show_at(popover, x, y) {
|
|
45
|
+
if (!popover.matches(":popover-open"))
|
|
46
|
+
popover.showPopover();
|
|
47
|
+
const size = popover.getBoundingClientRect();
|
|
48
|
+
const position = on_screen_position(x, y, size.width, size.height);
|
|
49
|
+
popover.style.left = `${position.left}px`;
|
|
50
|
+
popover.style.top = `${position.top}px`;
|
|
51
|
+
}
|
|
52
|
+
// ── Keys ─────────────────────────────────────────────────────────────────────
|
|
53
|
+
const item_selector = '[role^="menuitem"]:not([aria-disabled="true"])';
|
|
54
|
+
const typeahead_pause_in_milliseconds = 600;
|
|
55
|
+
// Arrows, Home and End move between items, typing jumps to one by its first letters, Enter or Space runs it, and Escape closes.
|
|
56
|
+
function use_menu_keys(container, dismiss, whole_menu) {
|
|
57
|
+
const typed = useRef({ text: "", at: 0 });
|
|
58
|
+
return (event) => {
|
|
59
|
+
if (event.key === "Escape") {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
event.stopPropagation();
|
|
62
|
+
dismiss();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (whole_menu && event.key === "Tab")
|
|
66
|
+
return dismiss();
|
|
67
|
+
const items = [...container.current.querySelectorAll(item_selector)];
|
|
68
|
+
const current = items.indexOf(document.activeElement);
|
|
69
|
+
// In a popover the keys belong to its fields until focus is on one of its items.
|
|
70
|
+
if (!whole_menu && current < 0)
|
|
71
|
+
return;
|
|
72
|
+
if ((event.key === "Enter" || event.key === " ") && current >= 0) {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
items[current].click();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const moved = item_after_key(event.key, current, items.length);
|
|
78
|
+
if (moved !== null) {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
items[moved].focus();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey)
|
|
84
|
+
return;
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
typed.current = { text: now - typed.current.at > typeahead_pause_in_milliseconds ? event.key : typed.current.text + event.key, at: now };
|
|
87
|
+
const labels = items.map((item) => item.querySelector(".menu-label")?.textContent ?? item.textContent ?? "");
|
|
88
|
+
const found = item_starting_with(labels, typed.current.text, current);
|
|
89
|
+
if (found !== null) {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
items[found].focus();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// A menu's buttons, also for use inside a Popover, such as a column filter's choices.
|
|
96
|
+
export function MenuList({ items, on_close }) {
|
|
97
|
+
return (_jsx(_Fragment, { children: items.map((item) => (_jsxs(Fragment, { children: [item.separator_before && _jsx("div", { className: "menu-separator", role: "separator" }), _jsxs("button", { type: "button", className: item.danger ? "menu-item danger" : "menu-item", role: item.checked === undefined ? "menuitem" : item.radio ? "menuitemradio" : "menuitemcheckbox", "aria-checked": item.checked, "aria-disabled": item.disabled || undefined, tabIndex: -1, onClick: () => {
|
|
98
|
+
if (item.disabled)
|
|
99
|
+
return;
|
|
100
|
+
if (!item.keep_open)
|
|
101
|
+
on_close();
|
|
102
|
+
item.run();
|
|
103
|
+
}, children: [_jsx("span", { className: "menu-label", children: item.label }), item.hint && _jsx("span", { className: "menu-hint", children: item.hint })] })] }, item.label))) }));
|
|
104
|
+
}
|
|
105
|
+
export function ContextMenu({ x, y, items, on_close, label }) {
|
|
106
|
+
const { element: menu, dismiss, return_focus } = use_popover(on_close);
|
|
107
|
+
const keys = use_menu_keys(menu, dismiss, true);
|
|
108
|
+
useLayoutEffect(() => {
|
|
109
|
+
show_at(menu.current, x, y);
|
|
110
|
+
menu.current.querySelector(item_selector)?.focus();
|
|
111
|
+
}, [x, y]);
|
|
112
|
+
return (_jsx("div", { ref: menu, popover: "auto", className: "menu", role: "menu", "aria-label": label, tabIndex: -1, onKeyDown: keys, children: _jsx(MenuList, { items: items, on_close: () => { return_focus(); on_close(); } }) }));
|
|
113
|
+
}
|
|
114
|
+
let open_menu = null;
|
|
115
|
+
let next_menu_id = 1;
|
|
116
|
+
const menu_listeners = new Set();
|
|
117
|
+
function publish_menu(menu) {
|
|
118
|
+
open_menu = menu;
|
|
119
|
+
for (const listener of menu_listeners)
|
|
120
|
+
listener(open_menu);
|
|
121
|
+
}
|
|
122
|
+
// Opens a menu where the event happened, such as a right-click, replacing any menu already open.
|
|
123
|
+
export function open_context_menu(event, items) {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
publish_menu({ id: next_menu_id++, x: event.clientX, y: event.clientY, items });
|
|
126
|
+
}
|
|
127
|
+
// Where menus opened from anywhere are drawn; the Shell holds one.
|
|
128
|
+
export function ContextMenus() {
|
|
129
|
+
const [menu, set_menu] = useState(open_menu);
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
menu_listeners.add(set_menu);
|
|
132
|
+
return () => void menu_listeners.delete(set_menu);
|
|
133
|
+
}, []);
|
|
134
|
+
if (!menu)
|
|
135
|
+
return null;
|
|
136
|
+
return _jsx(ContextMenu, { x: menu.x, y: menu.y, items: menu.items, on_close: () => { if (open_menu?.id === menu.id)
|
|
137
|
+
publish_menu(null); } }, menu.id);
|
|
138
|
+
}
|
|
139
|
+
// Holds any content, such as a column's filter, with its first field ready to type into and its menu items reachable by arrow keys.
|
|
140
|
+
export function Popover({ anchor, x = 0, y = 0, label, on_close, children }) {
|
|
141
|
+
const { element: popover, dismiss } = use_popover(on_close, anchor);
|
|
142
|
+
const keys = use_menu_keys(popover, dismiss, false);
|
|
143
|
+
const anchor_name = `--popover-${useId().replace(/[^\w-]/g, "")}`;
|
|
144
|
+
useLayoutEffect(() => {
|
|
145
|
+
const element = popover.current;
|
|
146
|
+
if (anchor) {
|
|
147
|
+
// The anchor's name ties it to this popover, which CSS then hangs below it, flipping where there is no room.
|
|
148
|
+
anchor.style.setProperty("anchor-name", anchor_name);
|
|
149
|
+
element.style.setProperty("position-anchor", anchor_name);
|
|
150
|
+
element.showPopover();
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
show_at(element, x, y);
|
|
154
|
+
}
|
|
155
|
+
element.querySelector(`input, select, textarea, ${item_selector}, button`)?.focus();
|
|
156
|
+
return () => void anchor?.style.removeProperty("anchor-name");
|
|
157
|
+
}, [anchor, x, y]);
|
|
158
|
+
return (_jsx("div", { ref: popover, popover: "auto", className: anchor ? "popover anchored" : "popover", role: "dialog", "aria-label": label, onKeyDown: keys, children: children }));
|
|
159
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
tools?: ReactNode;
|
|
32
|
+
sidebar_foot?: ReactNode;
|
|
33
|
+
view_key?: string;
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
}
|
|
36
|
+
export declare function Shell({ brand, sections, active, sidebar: sidebar_content, sidebar_width, theme, user, user_href, on_logout, tools, sidebar_foot, view_key, children }: ShellProps): import("react").JSX.Element;
|
|
37
|
+
export {};
|