@x-tech-laboratories/x-libs 0.4.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 -13
- 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 -0
- package/dist/web/menus.js +28 -1
- package/dist/web/{preact → react}/badges.d.ts +9 -9
- package/dist/web/{preact → react}/badges.js +6 -6
- package/dist/web/{preact → react}/controls.d.ts +7 -7
- package/dist/web/{preact → react}/controls.js +7 -6
- package/dist/web/{preact → react}/dialog.d.ts +10 -10
- package/dist/web/{preact → react}/dialog.js +14 -9
- package/dist/web/{preact → react}/layout.d.ts +33 -33
- 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/{preact → react}/states.d.ts +7 -7
- package/dist/web/{preact → react}/states.js +6 -5
- package/dist/web/react/table.d.ts +46 -0
- package/dist/web/react/table.js +210 -0
- package/dist/web/{preact → react}/time.d.ts +1 -1
- package/dist/web/{preact → react}/time.js +1 -1
- package/dist/web/{preact → react}/toasts.d.ts +3 -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/tables.d.ts +18 -1
- package/dist/web/tables.js +70 -8
- package/dist/web/theme.d.ts +6 -1
- package/dist/web/theme.js +31 -21
- package/dist/web/tree.d.ts +35 -0
- package/dist/web/tree.js +107 -0
- package/package.json +15 -4
- 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/toasts.js +0 -34
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "
|
|
2
|
-
|
|
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";
|
|
3
4
|
// One of a few choices side by side; the chosen one takes the accent.
|
|
4
5
|
export function Segmented({ label, choices, value, on_change, class_name }) {
|
|
5
|
-
return (_jsx("div", {
|
|
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))) }));
|
|
6
7
|
}
|
|
7
8
|
// Buttons joined into one control, such as zoom out, zoom in and fit.
|
|
8
9
|
export function ButtonGroup({ label, children }) {
|
|
9
|
-
return _jsx("div", {
|
|
10
|
+
return _jsx("div", { className: "segmented", role: "group", "aria-label": label, children: children });
|
|
10
11
|
}
|
|
11
12
|
export function Switch({ label, checked, on_change, disabled = false }) {
|
|
12
13
|
const [busy, set_busy] = useState(false);
|
|
@@ -32,12 +33,12 @@ export function Switch({ label, checked, on_change, disabled = false }) {
|
|
|
32
33
|
export function SearchBox({ label, value, on_input }) {
|
|
33
34
|
const timer = useRef(undefined);
|
|
34
35
|
useEffect(() => () => clearTimeout(timer.current), []);
|
|
35
|
-
return (_jsx("input", { type: "search",
|
|
36
|
+
return (_jsx("input", { type: "search", className: "filter", placeholder: label, "aria-label": label, defaultValue: value, onChange: (event) => {
|
|
36
37
|
clearTimeout(timer.current);
|
|
37
38
|
const text = event.currentTarget.value;
|
|
38
39
|
timer.current = window.setTimeout(() => on_input(text), 200);
|
|
39
40
|
} }));
|
|
40
41
|
}
|
|
41
42
|
export function Select({ label, options, value, on_change }) {
|
|
42
|
-
return (_jsxs("label", {
|
|
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)) })] }));
|
|
43
44
|
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
2
|
interface DialogProps {
|
|
3
|
-
title:
|
|
3
|
+
title: ReactNode;
|
|
4
4
|
on_close: () => void;
|
|
5
|
-
buttons?:
|
|
5
|
+
buttons?: ReactNode;
|
|
6
6
|
wide?: boolean;
|
|
7
7
|
class_name?: string;
|
|
8
|
-
children:
|
|
8
|
+
children: ReactNode;
|
|
9
9
|
}
|
|
10
|
-
export declare function Dialog({ title, on_close, buttons, wide, class_name, children }: DialogProps): import("
|
|
10
|
+
export declare function Dialog({ title, on_close, buttons, wide, class_name, children }: DialogProps): import("react").JSX.Element;
|
|
11
11
|
export interface ConfirmOptions {
|
|
12
12
|
title: string;
|
|
13
|
-
message:
|
|
13
|
+
message: ReactNode;
|
|
14
14
|
confirm_label: string;
|
|
15
15
|
busy_label?: string;
|
|
16
16
|
danger?: boolean;
|
|
@@ -21,15 +21,15 @@ export interface ConfirmOptions {
|
|
|
21
21
|
export declare function ConfirmDialog({ options, on_close }: {
|
|
22
22
|
options: ConfirmOptions;
|
|
23
23
|
on_close: () => void;
|
|
24
|
-
}): import("
|
|
24
|
+
}): import("react").JSX.Element;
|
|
25
25
|
export declare function TextDialog({ title, text, on_close }: {
|
|
26
26
|
title: string;
|
|
27
27
|
text: string;
|
|
28
28
|
on_close: () => void;
|
|
29
|
-
}): import("
|
|
30
|
-
type Builder = (close: () => void) =>
|
|
29
|
+
}): import("react").JSX.Element;
|
|
30
|
+
type Builder = (close: () => void) => ReactNode;
|
|
31
31
|
export declare function open_dialog(build: Builder): () => void;
|
|
32
32
|
export declare function confirm_dialog(options: ConfirmOptions): void;
|
|
33
33
|
export declare function text_dialog(title: string, text: string): void;
|
|
34
|
-
export declare function Dialogs(): import("
|
|
34
|
+
export declare function Dialogs(): import("react").JSX.Element;
|
|
35
35
|
export {};
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
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 } from "
|
|
4
|
-
import { useEffect, useId, useRef, useState } from "preact/hooks";
|
|
3
|
+
import { Fragment, useEffect, useId, useRef, useState } from "react";
|
|
5
4
|
import { toast } from "./toasts.js";
|
|
6
|
-
// Shown while it is rendered: the page behind it is inert, focus stays inside,
|
|
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.
|
|
7
6
|
export function Dialog({ title, on_close, buttons, wide = false, class_name, children }) {
|
|
8
7
|
const dialog = useRef(null);
|
|
9
8
|
const title_id = useId();
|
|
@@ -11,15 +10,21 @@ export function Dialog({ title, on_close, buttons, wide = false, class_name, chi
|
|
|
11
10
|
close.current = on_close;
|
|
12
11
|
useEffect(() => {
|
|
13
12
|
const element = dialog.current;
|
|
13
|
+
const opener = document.activeElement;
|
|
14
14
|
element.showModal();
|
|
15
|
-
return () =>
|
|
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
|
+
};
|
|
16
21
|
}, []);
|
|
17
|
-
return (_jsxs("dialog", { ref: dialog,
|
|
22
|
+
return (_jsxs("dialog", { ref: dialog, className: ["dialog", wide && "wide", class_name].filter(Boolean).join(" "), "aria-labelledby": title_id, onCancel: (event) => {
|
|
18
23
|
// Escape asks the owner to close, so the dialog never closes behind its owner's back.
|
|
19
24
|
event.preventDefault();
|
|
20
25
|
event.stopPropagation();
|
|
21
26
|
close.current();
|
|
22
|
-
}, children: [_jsx("h2", { id: title_id, children: title }), _jsx("div", {
|
|
27
|
+
}, children: [_jsx("h2", { id: title_id, children: title }), _jsx("div", { className: "dialog-body", children: children }), buttons && _jsx("div", { className: "dialog-buttons", children: buttons })] }));
|
|
23
28
|
}
|
|
24
29
|
export function ConfirmDialog({ options, on_close }) {
|
|
25
30
|
const [typed, set_typed] = useState("");
|
|
@@ -35,11 +40,11 @@ export function ConfirmDialog({ options, on_close }) {
|
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
return (_jsxs(Dialog, { title: options.title, on_close: () => { if (!busy)
|
|
38
|
-
on_close(); }, wide: options.wide, buttons: _jsxs(_Fragment, { children: [_jsx("button", { disabled: busy, onClick: on_close, children: "Cancel" }), _jsx("button", {
|
|
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) })] }))] }));
|
|
39
44
|
}
|
|
40
45
|
// Shows a long text result, such as a preview, with a button to copy it.
|
|
41
46
|
export function TextDialog({ title, text, on_close }) {
|
|
42
|
-
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", {
|
|
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 }) }));
|
|
43
48
|
}
|
|
44
49
|
let open = [];
|
|
45
50
|
let next_id = 1;
|
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
2
|
import type { Tone } from "./badges.tsx";
|
|
3
3
|
export declare function Section({ title, tools, children }: {
|
|
4
|
-
title:
|
|
5
|
-
tools?:
|
|
6
|
-
children?:
|
|
7
|
-
}): import("
|
|
8
|
-
export type Fact = [label: string, value:
|
|
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
9
|
export declare function Facts({ rows, layout }: {
|
|
10
10
|
rows: (Fact | null | false)[];
|
|
11
11
|
layout?: "grid" | "rows";
|
|
12
|
-
}): import("
|
|
12
|
+
}): import("react").JSX.Element;
|
|
13
13
|
export declare function Stack({ tight, children }: {
|
|
14
14
|
tight?: boolean;
|
|
15
|
-
children:
|
|
16
|
-
}): import("
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
17
17
|
interface TileProps {
|
|
18
18
|
label: string;
|
|
19
|
-
value:
|
|
19
|
+
value: ReactNode;
|
|
20
20
|
tone?: Tone;
|
|
21
21
|
class_name?: string | null;
|
|
22
22
|
href?: string;
|
|
23
23
|
value_first?: boolean;
|
|
24
24
|
}
|
|
25
|
-
export declare function Tile({ label, value, tone, class_name, href, value_first }: TileProps): import("
|
|
25
|
+
export declare function Tile({ label, value, tone, class_name, href, value_first }: TileProps): import("react").JSX.Element;
|
|
26
26
|
export declare function Tiles({ children }: {
|
|
27
|
-
children:
|
|
28
|
-
}): import("
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
}): import("react").JSX.Element;
|
|
29
29
|
interface CardProps {
|
|
30
|
-
title:
|
|
31
|
-
subtitle?:
|
|
32
|
-
badge?:
|
|
30
|
+
title: ReactNode;
|
|
31
|
+
subtitle?: ReactNode;
|
|
32
|
+
badge?: ReactNode;
|
|
33
33
|
class_name?: string;
|
|
34
34
|
href?: string;
|
|
35
35
|
on_click?: () => void;
|
|
36
|
-
children?:
|
|
36
|
+
children?: ReactNode;
|
|
37
37
|
}
|
|
38
|
-
export declare function Card({ title, subtitle, badge, class_name, href, on_click, children }: CardProps): import("
|
|
38
|
+
export declare function Card({ title, subtitle, badge, class_name, href, on_click, children }: CardProps): import("react").JSX.Element;
|
|
39
39
|
export declare function Cards({ children }: {
|
|
40
|
-
children:
|
|
41
|
-
}): import("
|
|
40
|
+
children: ReactNode;
|
|
41
|
+
}): import("react").JSX.Element;
|
|
42
42
|
export interface TabItem<Id extends string> {
|
|
43
43
|
id: Id;
|
|
44
44
|
label: string;
|
|
@@ -49,13 +49,13 @@ export declare function Tabs<Id extends string>({ items, active, label, on_selec
|
|
|
49
49
|
active: Id;
|
|
50
50
|
label?: string;
|
|
51
51
|
on_select?: (id: Id) => void;
|
|
52
|
-
}): import("
|
|
52
|
+
}): import("react").JSX.Element;
|
|
53
53
|
export declare function Crumbs({ items }: {
|
|
54
54
|
items: [label: string, href: string | null][];
|
|
55
|
-
}): import("
|
|
55
|
+
}): import("react").JSX.Element;
|
|
56
56
|
export declare function Toolbar({ children }: {
|
|
57
|
-
children:
|
|
58
|
-
}): import("
|
|
57
|
+
children: ReactNode;
|
|
58
|
+
}): import("react").JSX.Element;
|
|
59
59
|
interface Step {
|
|
60
60
|
text: string;
|
|
61
61
|
href: string | null;
|
|
@@ -63,18 +63,18 @@ interface Step {
|
|
|
63
63
|
export declare function Stepper({ label, previous, position, next }: {
|
|
64
64
|
label: string;
|
|
65
65
|
previous: Step;
|
|
66
|
-
position:
|
|
66
|
+
position: ReactNode;
|
|
67
67
|
next: Step;
|
|
68
|
-
}): import("
|
|
68
|
+
}): import("react").JSX.Element;
|
|
69
69
|
export declare function SettingsGroup({ heading, hint, children }: {
|
|
70
70
|
heading: string;
|
|
71
|
-
hint?:
|
|
72
|
-
children:
|
|
73
|
-
}): import("
|
|
71
|
+
hint?: ReactNode;
|
|
72
|
+
children: ReactNode;
|
|
73
|
+
}): import("react").JSX.Element;
|
|
74
74
|
export declare function SettingRow({ label, hint, as_label, children }: {
|
|
75
|
-
label:
|
|
76
|
-
hint?:
|
|
75
|
+
label: ReactNode;
|
|
76
|
+
hint?: ReactNode;
|
|
77
77
|
as_label?: boolean;
|
|
78
|
-
children:
|
|
79
|
-
}): import("
|
|
78
|
+
children: ReactNode;
|
|
79
|
+
}): import("react").JSX.Element;
|
|
80
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 {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The frame around every view: the top bar, the sidebar that slides in on phones and resizes on wide screens, the theme button, and the toasts, dialogs and menus.
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { current_route } from "../router.js";
|
|
5
|
+
import { store_sizes, stored_sizes } from "../split.js";
|
|
6
|
+
import { next_theme } from "../theme.js";
|
|
7
|
+
import { Dialogs } from "./dialog.js";
|
|
8
|
+
import { ContextMenus } from "./menus.js";
|
|
9
|
+
import { ResizeHandle } from "./split.js";
|
|
10
|
+
import { Toasts } from "./toasts.js";
|
|
11
|
+
// ── Route ────────────────────────────────────────────────────────────────────
|
|
12
|
+
// The hash route, read again whenever it changes.
|
|
13
|
+
export function use_route() {
|
|
14
|
+
const [route, set_route] = useState(current_route);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const changed = () => set_route(current_route());
|
|
17
|
+
window.addEventListener("hashchange", changed);
|
|
18
|
+
return () => window.removeEventListener("hashchange", changed);
|
|
19
|
+
}, []);
|
|
20
|
+
return route;
|
|
21
|
+
}
|
|
22
|
+
// ── Theme ────────────────────────────────────────────────────────────────────
|
|
23
|
+
const theme_labels = { system: ["◐", "Theme: system"], light: ["☀", "Theme: light"], dark: ["☾", "Theme: dark"] };
|
|
24
|
+
// Steps through system, light and dark, its glyph and label showing the current one.
|
|
25
|
+
export function ThemeButton({ choice }) {
|
|
26
|
+
const [theme, set_theme] = useState(choice.get);
|
|
27
|
+
useEffect(() => choice.on_change(set_theme), []);
|
|
28
|
+
const [icon, text] = theme_labels[theme];
|
|
29
|
+
return (_jsx("button", { id: "theme", className: "icon", title: `${text} (click to change)`, "aria-label": `${text}, click to change`, onClick: () => choice.set(next_theme(choice.get())), children: icon }));
|
|
30
|
+
}
|
|
31
|
+
// The sidebar's width: the app's own, else the one kept in this browser, else the default.
|
|
32
|
+
function use_sidebar_width(spec) {
|
|
33
|
+
const [kept, set_kept] = useState(() => spec?.storage_key ? stored_sizes(spec.storage_key, 1)?.[0] : undefined);
|
|
34
|
+
const width = spec ? Math.min(spec.max, Math.max(spec.min, spec.width ?? kept ?? spec.default_width)) : undefined;
|
|
35
|
+
const change = (next, done) => {
|
|
36
|
+
if (spec?.width === undefined)
|
|
37
|
+
set_kept(next);
|
|
38
|
+
if (done && spec?.storage_key)
|
|
39
|
+
store_sizes(spec.storage_key, [next]);
|
|
40
|
+
spec?.on_change?.(next, done);
|
|
41
|
+
};
|
|
42
|
+
return [width, change];
|
|
43
|
+
}
|
|
44
|
+
export function Shell({ brand, sections = [], active, sidebar: sidebar_content, sidebar_width, theme, user, user_href, on_logout, tools, sidebar_foot, view_key, children }) {
|
|
45
|
+
const [menu_open, set_menu_open] = useState(false);
|
|
46
|
+
const main = useRef(null);
|
|
47
|
+
const menu_button = useRef(null);
|
|
48
|
+
const sidebar = useRef(null);
|
|
49
|
+
const first_view = useRef(true);
|
|
50
|
+
const logged_out = user === null;
|
|
51
|
+
const [width, change_width] = use_sidebar_width(sidebar_width);
|
|
52
|
+
// The login page has no theme button, so the saved theme is set here.
|
|
53
|
+
useEffect(() => theme.apply(), []);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
document.body.classList.toggle("logged-out", logged_out);
|
|
56
|
+
document.body.classList.toggle("menu-open", menu_open);
|
|
57
|
+
}, [logged_out, menu_open]);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (menu_open)
|
|
60
|
+
sidebar.current?.querySelector("a[href], button:not(:disabled), input, select, textarea, [tabindex='0']")?.focus();
|
|
61
|
+
if (!menu_open)
|
|
62
|
+
return;
|
|
63
|
+
const escape = (event) => {
|
|
64
|
+
if (event.key !== "Escape")
|
|
65
|
+
return;
|
|
66
|
+
set_menu_open(false);
|
|
67
|
+
menu_button.current?.focus();
|
|
68
|
+
};
|
|
69
|
+
document.addEventListener("keydown", escape);
|
|
70
|
+
return () => document.removeEventListener("keydown", escape);
|
|
71
|
+
}, [menu_open]);
|
|
72
|
+
// A new view closes the menu and takes focus, which tells a screen reader the page changed, but not on the first load.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
set_menu_open(false);
|
|
75
|
+
if (!first_view.current)
|
|
76
|
+
main.current?.focus({ preventScroll: true });
|
|
77
|
+
first_view.current = false;
|
|
78
|
+
window.scrollTo(0, 0);
|
|
79
|
+
}, [view_key]);
|
|
80
|
+
return (_jsxs(_Fragment, { children: [_jsx("a", { className: "skip", href: "#main", children: "Skip to content" }), _jsxs("header", { className: "topbar", children: [_jsx("button", { ref: menu_button, className: "icon menu-button", "aria-label": "Open the menu", "aria-expanded": menu_open, "aria-controls": "sidebar", onClick: () => set_menu_open(!menu_open), children: "\u2630" }), _jsx("a", { href: "#/", className: "brand", children: brand }), !logged_out && tools, !logged_out && _jsx(ThemeButton, { choice: theme }), !logged_out && (user_href ? _jsx("a", { className: "account-link", href: user_href, children: user }) : _jsx("span", { className: "user-name", children: user })), !logged_out && on_logout && _jsx("button", { onClick: on_logout, children: "Log out" })] }), _jsxs("div", { className: "layout", style: width === undefined ? undefined : { "--sidebar-width": `${width}px` }, children: [_jsxs("nav", { ref: sidebar, id: "sidebar", className: "sidebar", "aria-label": "Main", children: [sidebar_content ?? sections.map((section) => (_jsx("a", { href: section.href, "data-section": section.id, className: section.id === active ? "active" : undefined, "aria-current": section.id === active ? "page" : undefined, children: section.label }, section.id))), sidebar_foot, sidebar_width && width !== undefined && (_jsx(ResizeHandle, { side: "left", size: width, min: sidebar_width.min, max: sidebar_width.max, default_size: sidebar_width.default_width, on_resize: (next) => change_width(next, false), on_resize_end: (next) => change_width(next, true), label: "Resize the sidebar", class_name: "sidebar-resize" }))] }), _jsx("div", { className: "scrim", hidden: !menu_open, onClick: () => set_menu_open(false) }), _jsx("main", { ref: main, id: "main", tabIndex: -1, children: children })] }), _jsx(Toasts, {}), _jsx(Dialogs, {}), _jsx(ContextMenus, {})] }));
|
|
81
|
+
}
|