@x-tech-laboratories/x-libs 0.9.0 → 0.11.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 +48 -8
- package/dist/electron/bridge.d.ts +26 -3
- package/dist/electron/bridge.js +57 -5
- package/dist/electron/fetch.d.ts +27 -0
- package/dist/electron/fetch.js +109 -1
- package/dist/server/config.d.ts +39 -0
- package/dist/server/config.js +109 -0
- package/dist/server/cors.d.ts +8 -0
- package/dist/server/cors.js +13 -0
- package/dist/server/events.d.ts +33 -0
- package/dist/server/events.js +68 -0
- package/dist/server/login_page.d.ts +8 -0
- package/dist/server/login_page.js +79 -0
- package/dist/server/replies.d.ts +12 -0
- package/dist/server/replies.js +41 -0
- package/dist/server/sign_in.d.ts +27 -0
- package/dist/server/sign_in.js +95 -0
- package/dist/server/web_folder.d.ts +11 -0
- package/dist/server/web_folder.js +40 -0
- package/dist/web/dom.d.ts +2 -0
- package/dist/web/dom.js +5 -1
- package/dist/web/errors.d.ts +1 -0
- package/dist/web/errors.js +27 -0
- package/dist/web/react/badges.d.ts +9 -0
- package/dist/web/react/badges.js +4 -0
- package/dist/web/react/controls.d.ts +10 -0
- package/dist/web/react/controls.js +16 -0
- package/dist/web/react/following.d.ts +25 -0
- package/dist/web/react/following.js +34 -0
- package/dist/web/react/layout.d.ts +2 -1
- package/dist/web/react/layout.js +3 -2
- package/dist/web/react/markdown.d.ts +8 -0
- package/dist/web/react/markdown.js +40 -0
- package/dist/web/react/page_view.d.ts +16 -0
- package/dist/web/react/page_view.js +6 -0
- package/dist/web/react/progress.d.ts +10 -0
- package/dist/web/react/progress.js +9 -0
- package/dist/web/react/rating.d.ts +8 -0
- package/dist/web/react/rating.js +23 -0
- package/dist/web/react/states.d.ts +2 -1
- package/dist/web/react/states.js +7 -6
- package/dist/web/react/store.d.ts +4 -0
- package/dist/web/react/store.js +7 -0
- package/dist/web/react/suggestions.d.ts +23 -0
- package/dist/web/react/suggestions.js +57 -0
- package/dist/web/react/table.d.ts +1 -0
- package/dist/web/react/table.js +2 -1
- package/dist/web/react/test_stubs.js +23 -17
- package/dist/web/react/volume.d.ts +8 -0
- package/dist/web/react/volume.js +42 -0
- package/dist/web/store.d.ts +11 -0
- package/dist/web/store.js +17 -0
- package/dist/web/watch.d.ts +5 -0
- package/dist/web/watch.js +20 -0
- package/package.json +24 -2
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Markdown drawn as safe HTML on marked and DOMPurify: raw HTML shows as text, only web links are clickable, and they open in a new tab.
|
|
3
|
+
// marked matches the hand-written parser feature for feature (headings, lists, code, quotes, rules, emphasis, links) and adds tables and nested lists; DOMPurify drops anything a model might slip in.
|
|
4
|
+
import DOMPurify from "dompurify";
|
|
5
|
+
import { Marked } from "marked";
|
|
6
|
+
import { useLayoutEffect, useMemo, useRef } from "react";
|
|
7
|
+
import { cx } from "../dom.js";
|
|
8
|
+
import { at_bottom } from "./following.js";
|
|
9
|
+
const LINK_SCHEMES = ["http://", "https://"];
|
|
10
|
+
const escape_html = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
11
|
+
const renderer = {
|
|
12
|
+
// Raw HTML in the text shows as written.
|
|
13
|
+
html({ text }) {
|
|
14
|
+
return escape_html(text);
|
|
15
|
+
},
|
|
16
|
+
// Only web links become links; another scheme shows as its text and address.
|
|
17
|
+
link({ href, title, tokens }) {
|
|
18
|
+
const text = this.parser.parseInline(tokens);
|
|
19
|
+
if (!LINK_SCHEMES.some((scheme) => href.startsWith(scheme)))
|
|
20
|
+
return `${text} (${escape_html(href)})`;
|
|
21
|
+
const title_part = title ? ` title="${escape_html(title)}"` : "";
|
|
22
|
+
return `<a href="${escape_html(href)}"${title_part} target="_blank" rel="noreferrer">${text}</a>`;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const marked = new Marked({ gfm: true, breaks: true, renderer });
|
|
26
|
+
// Markdown as sanitized HTML, ready for innerHTML.
|
|
27
|
+
export function render_markdown(text) {
|
|
28
|
+
const html = marked.parse(text, { async: false });
|
|
29
|
+
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true }, ADD_ATTR: ["target"] });
|
|
30
|
+
}
|
|
31
|
+
export function Markdown({ text, class_name, follow = false }) {
|
|
32
|
+
const box = useRef(null);
|
|
33
|
+
const html = useMemo(() => render_markdown(text), [text]);
|
|
34
|
+
const following = follow && (box.current === null || at_bottom(box.current));
|
|
35
|
+
useLayoutEffect(() => {
|
|
36
|
+
if (following && box.current !== null)
|
|
37
|
+
box.current.scrollTop = box.current.scrollHeight;
|
|
38
|
+
}, [html]);
|
|
39
|
+
return _jsx("div", { ref: box, className: cx("markdown", class_name), dangerouslySetInnerHTML: { __html: html } });
|
|
40
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type TabItem } from "./layout.tsx";
|
|
3
|
+
export type { TabItem } from "./layout.tsx";
|
|
4
|
+
interface PageViewProps<Section extends string> {
|
|
5
|
+
title: string;
|
|
6
|
+
subtitle?: ReactNode;
|
|
7
|
+
on_close: () => void;
|
|
8
|
+
actions?: ReactNode;
|
|
9
|
+
sections?: TabItem<Section>[];
|
|
10
|
+
section?: Section;
|
|
11
|
+
on_section?: (section: Section) => void;
|
|
12
|
+
close_label?: string;
|
|
13
|
+
class_name?: string;
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
export declare function PageView<Section extends string>({ title, subtitle, on_close, actions, sections, section, on_section, close_label, class_name, children }: PageViewProps<Section>): import("react").JSX.Element;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Dialog } from "./dialog.js";
|
|
3
|
+
import { Tabs } from "./layout.js";
|
|
4
|
+
export function PageView({ title, subtitle, on_close, actions, sections, section, on_section, close_label = "Close", class_name, children }) {
|
|
5
|
+
return (_jsxs(Dialog, { title: title, subtitle: subtitle, on_close: on_close, full: true, class_name: class_name ? `page-view ${class_name}` : "page-view", actions: _jsxs(_Fragment, { children: [actions, _jsx("button", { type: "button", onClick: on_close, title: `${close_label} (Esc)`, children: close_label })] }), children: [sections && section !== undefined && _jsx(Tabs, { label: `${title} sections`, items: sections, active: section, on_select: on_section }), children] }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
interface ProgressBarProps {
|
|
3
|
+
value: number | null;
|
|
4
|
+
max?: number;
|
|
5
|
+
label: string;
|
|
6
|
+
detail?: ReactNode | true;
|
|
7
|
+
class_name?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function ProgressBar({ value, max, label, detail, class_name }: ProgressBarProps): import("react").JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../dom.js";
|
|
3
|
+
// The fill reads its width from --progress, as Halon's seek slider reads --range-progress.
|
|
4
|
+
export function ProgressBar({ value, max = 100, label, detail, class_name }) {
|
|
5
|
+
const known = value !== null && max > 0;
|
|
6
|
+
const percent = known ? Math.max(0, Math.min(100, Math.round((value / max) * 100))) : 0;
|
|
7
|
+
const words = detail === true ? `${percent}%` : detail;
|
|
8
|
+
return (_jsxs("div", { className: cx("progress", class_name), children: [_jsx("div", { className: cx("progress-bar", !known && "indeterminate"), role: "progressbar", "aria-label": label, "aria-valuemin": 0, "aria-valuemax": max, "aria-valuenow": known ? value : undefined, style: { "--progress": `${percent}%` }, children: _jsx("div", { className: "progress-fill" }) }), words !== undefined && _jsx("span", { className: "progress-detail muted", children: words })] }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../dom.js";
|
|
3
|
+
const plural_stars = (count) => `${count} star${count === 1 ? "" : "s"}`;
|
|
4
|
+
// Arrow keys move one star, Home and End to the ends, and Delete or 0 clears the rating.
|
|
5
|
+
export function Rating({ value, max = 5, label = "Rating", on_change }) {
|
|
6
|
+
const lit = value ?? 0;
|
|
7
|
+
const read_only = on_change === undefined;
|
|
8
|
+
function key_down(event) {
|
|
9
|
+
if (read_only)
|
|
10
|
+
return;
|
|
11
|
+
const next = event.key === "ArrowRight" || event.key === "ArrowUp" ? Math.min(max, lit + 1)
|
|
12
|
+
: event.key === "ArrowLeft" || event.key === "ArrowDown" ? Math.max(0, lit - 1)
|
|
13
|
+
: event.key === "Home" ? 0
|
|
14
|
+
: event.key === "End" ? max
|
|
15
|
+
: event.key === "Delete" || event.key === "Backspace" || event.key === "0" ? null
|
|
16
|
+
: undefined;
|
|
17
|
+
if (next === undefined)
|
|
18
|
+
return;
|
|
19
|
+
event.preventDefault();
|
|
20
|
+
on_change(next);
|
|
21
|
+
}
|
|
22
|
+
return (_jsx("div", { className: cx("rating", read_only && "read-only"), role: "slider", tabIndex: read_only ? undefined : 0, "aria-label": label, "aria-readonly": read_only || undefined, "aria-valuemin": 0, "aria-valuemax": max, "aria-valuenow": lit, "aria-valuetext": value === null ? "Unrated" : plural_stars(value), onKeyDown: key_down, children: Array.from({ length: max }, (_, index) => index + 1).map((star) => (_jsx("button", { type: "button", className: cx("star", star <= lit && "on"), tabIndex: -1, disabled: read_only, "aria-label": plural_stars(star), onClick: () => on_change?.(star), children: "\u2605" }, star))) }));
|
|
23
|
+
}
|
|
@@ -29,7 +29,8 @@ interface LoadedProps<Data> {
|
|
|
29
29
|
resource: Resource<Data>;
|
|
30
30
|
lines?: number;
|
|
31
31
|
idle?: ReactNode;
|
|
32
|
+
empty?: ReactNode;
|
|
32
33
|
children: (data: Data) => ReactNode;
|
|
33
34
|
}
|
|
34
|
-
export declare function Loaded<Data>({ resource, lines, idle, children }: LoadedProps<Data>): import("react").JSX.Element;
|
|
35
|
+
export declare function Loaded<Data>({ resource, lines, idle, empty, children }: LoadedProps<Data>): import("react").JSX.Element;
|
|
35
36
|
export {};
|
package/dist/web/react/states.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// What a part of the page shows while it loads, when it has nothing, and when loading failed, and the hook that loads it.
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { describe_error } from "../errors.js";
|
|
4
5
|
import { pass_login_needed } from "../states.js";
|
|
5
6
|
// ── States ───────────────────────────────────────────────────────────────────
|
|
6
7
|
export function Skeleton({ lines = 3 }) {
|
|
@@ -10,7 +11,7 @@ export function Empty({ title, hint }) {
|
|
|
10
11
|
return (_jsxs("div", { className: "empty", children: [_jsx("p", { className: "empty-title", children: title }), hint && _jsx("p", { className: "muted", children: hint })] }));
|
|
11
12
|
}
|
|
12
13
|
export function Failure({ error, retry }) {
|
|
13
|
-
return (_jsxs("div", { className: "failure", role: "alert", children: [_jsxs("p", { children: ["Could not load this: ",
|
|
14
|
+
return (_jsxs("div", { className: "failure", role: "alert", children: [_jsxs("p", { children: ["Could not load this: ", describe_error(error)] }), _jsx("button", { onClick: retry, children: "Retry" })] }));
|
|
14
15
|
}
|
|
15
16
|
// Loads what the work fetches whenever the dependencies change, passing a lapsed login on to the page.
|
|
16
17
|
export function use_resource(work, dependencies, options = {}) {
|
|
@@ -51,13 +52,13 @@ export function use_resource(work, dependencies, options = {}) {
|
|
|
51
52
|
}, [options.watch, enabled]);
|
|
52
53
|
return { ...state, loading: state.status === "loading", reload };
|
|
53
54
|
}
|
|
54
|
-
// Shows a skeleton while the resource first loads, an error with Retry if that fails, and what children build from it after.
|
|
55
|
-
export function Loaded({ resource, lines, idle = null, children }) {
|
|
56
|
-
if (resource.
|
|
57
|
-
return _jsx(_Fragment, { children: children(resource.data) });
|
|
55
|
+
// Shows a skeleton while the resource first loads, an error with Retry if that fails, and what children build from it after, going by its status so a null result still shows.
|
|
56
|
+
export function Loaded({ resource, lines, idle = null, empty, children }) {
|
|
57
|
+
if (resource.status === "loaded")
|
|
58
|
+
return _jsx(_Fragment, { children: resource.data === null && empty !== undefined ? empty : children(resource.data) });
|
|
58
59
|
if (resource.status === "idle")
|
|
59
60
|
return _jsx(_Fragment, { children: idle });
|
|
60
|
-
if (resource.
|
|
61
|
+
if (resource.status === "failed")
|
|
61
62
|
return _jsx(Failure, { error: resource.error, retry: () => void resource.reload() });
|
|
62
63
|
return _jsx(Skeleton, { lines: lines });
|
|
63
64
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type FocusEvent, type KeyboardEvent, type ReactNode } from "react";
|
|
2
|
+
export interface FieldProps<Field extends HTMLElement> {
|
|
3
|
+
"aria-autocomplete": "list";
|
|
4
|
+
"aria-expanded": boolean;
|
|
5
|
+
"aria-controls": string;
|
|
6
|
+
"aria-activedescendant": string | undefined;
|
|
7
|
+
onKeyDown: (event: KeyboardEvent<Field>) => void;
|
|
8
|
+
onBlur: (event: FocusEvent<Field>) => void;
|
|
9
|
+
}
|
|
10
|
+
interface SuggestionsProps<Item, Field extends HTMLElement> {
|
|
11
|
+
value: string;
|
|
12
|
+
source: (value: string) => Item[] | Promise<Item[]>;
|
|
13
|
+
key_of: (item: Item) => string;
|
|
14
|
+
render: (item: Item) => ReactNode;
|
|
15
|
+
detail?: (item: Item) => ReactNode;
|
|
16
|
+
on_take: (item: Item) => void;
|
|
17
|
+
label: string;
|
|
18
|
+
on_key_down?: (event: KeyboardEvent<Field>) => void;
|
|
19
|
+
on_blur?: (event: FocusEvent<Field>) => void;
|
|
20
|
+
children: (field: FieldProps<Field>) => ReactNode;
|
|
21
|
+
}
|
|
22
|
+
export declare function Suggestions<Item, Field extends HTMLElement>({ value, source, key_of, render, detail, on_take, label, on_key_down, on_blur, children }: SuggestionsProps<Item, Field>): import("react").JSX.Element;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Completions listed under a text field as it is typed into, such as slash commands: arrow keys choose, Tab or Enter take, Escape dismisses.
|
|
3
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
export function Suggestions({ value, source, key_of, render, detail, on_take, label, on_key_down, on_blur, children }) {
|
|
5
|
+
const list_id = useId();
|
|
6
|
+
const [items, set_items] = useState([]);
|
|
7
|
+
const [index, set_index] = useState(0);
|
|
8
|
+
const latest_value = useRef(value);
|
|
9
|
+
latest_value.current = value;
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
let stale = false;
|
|
12
|
+
Promise.resolve(source(value)).then((found) => {
|
|
13
|
+
if (stale || latest_value.current !== value)
|
|
14
|
+
return;
|
|
15
|
+
set_items(found);
|
|
16
|
+
set_index(0);
|
|
17
|
+
});
|
|
18
|
+
return () => { stale = true; };
|
|
19
|
+
}, [value]);
|
|
20
|
+
function take(choice) {
|
|
21
|
+
const item = items[choice];
|
|
22
|
+
if (item === undefined)
|
|
23
|
+
return;
|
|
24
|
+
set_items([]);
|
|
25
|
+
on_take(item);
|
|
26
|
+
}
|
|
27
|
+
function key_down(event) {
|
|
28
|
+
if (items.length > 0 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
29
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
set_index((index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (event.key === "Tab" || event.key === "Enter") {
|
|
35
|
+
event.preventDefault();
|
|
36
|
+
take(index);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (event.key === "Escape") {
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
set_items([]);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
on_key_down?.(event);
|
|
46
|
+
}
|
|
47
|
+
const option_id = (position) => `${list_id}-${position}`;
|
|
48
|
+
const field = {
|
|
49
|
+
"aria-autocomplete": "list",
|
|
50
|
+
"aria-expanded": items.length > 0,
|
|
51
|
+
"aria-controls": list_id,
|
|
52
|
+
"aria-activedescendant": items.length > 0 ? option_id(index) : undefined,
|
|
53
|
+
onKeyDown: key_down,
|
|
54
|
+
onBlur: (event) => { set_items([]); on_blur?.(event); },
|
|
55
|
+
};
|
|
56
|
+
return (_jsxs("div", { className: "suggest-wrap", children: [children(field), items.length > 0 && (_jsx("ul", { id: list_id, className: "suggestions menu", role: "listbox", "aria-label": label, children: items.map((item, position) => (_jsxs("li", { id: option_id(position), role: "option", className: "menu-item", "aria-selected": position === index, onMouseDown: (event) => { event.preventDefault(); take(position); }, children: [_jsx("span", { className: "suggestion-main", children: render(item) }), detail && _jsx("span", { className: "suggestion-detail muted small", children: detail(item) })] }, key_of(item)))) }))] }));
|
|
57
|
+
}
|
package/dist/web/react/table.js
CHANGED
|
@@ -4,6 +4,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
4
4
|
// only where they show, and cards on phones.
|
|
5
5
|
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
6
6
|
import { getCoreRowModel, getExpandedRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
|
|
7
|
+
import { cx } from "../dom.js";
|
|
7
8
|
import { clamp_width, column_id, compare_values, move_column, ordered_columns, sort_after_click, sorting_list } from "../tables.js";
|
|
8
9
|
import { placement_at } from "../tree.js";
|
|
9
10
|
import { virtual_window } from "../virtual.js";
|
|
@@ -383,7 +384,7 @@ export function DataTable(props) {
|
|
|
383
384
|
const filtered_column = open_filter ? by_id.get(open_filter.id) : undefined;
|
|
384
385
|
const filter_body = filtered_column?.filter;
|
|
385
386
|
const tools = (props.on_clear_filters && any_filter) || props.on_hidden_columns_change;
|
|
386
|
-
return (_jsxs(_Fragment, { children: [tools && (_jsxs("div", { className: "table-tools", children: [props.on_clear_filters && any_filter && _jsx("button", { type: "button", onClick: props.on_clear_filters, children: "Clear filters" }), props.on_hidden_columns_change && _jsx("button", { type: "button", "aria-haspopup": "menu", onClick: open_columns_menu, children: "Columns" })] })), _jsx("div", { ref: wrap, className:
|
|
387
|
+
return (_jsxs(_Fragment, { children: [tools && (_jsxs("div", { className: "table-tools", children: [props.on_clear_filters && any_filter && _jsx("button", { type: "button", onClick: props.on_clear_filters, children: "Clear filters" }), props.on_hidden_columns_change && _jsx("button", { type: "button", "aria-haspopup": "menu", onClick: open_columns_menu, children: "Columns" })] })), _jsx("div", { ref: wrap, className: cx("table-wrap", props.virtual && "virtual", props.wrap_class), style: props.virtual ? { overflowY: "auto" } : undefined, onScroll: line_height === undefined ? undefined : measure, children: _jsxs("table", { className: fill_last ? "responsive fill-last-column" : "responsive", style: fills_by_fixed ? { tableLayout: "fixed" } : undefined, children: [caption && _jsx("caption", { className: "visually-hidden", children: caption }), _jsx("thead", { ref: head, onContextMenu: props.on_hidden_columns_change && open_columns_menu, children: _jsx("tr", { children: shown.map(header) }) }), _jsxs("tbody", { onFocus: () => { rows_focused.current = true; }, onBlur: (event) => { if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget))
|
|
387
388
|
rows_focused.current = false; }, children: [windowed && spacer(windowed.offset_px, "spacer before"), drawn.map(draw_line), windowed && spacer(windowed.total_px - windowed.end * line_height, "spacer after")] })] }) }), open_filter && filtered_column && filter_body !== undefined && (_jsx(Popover, { anchor: open_filter.anchor, label: `Filter by ${filtered_column.label}`, on_close: close_filter, children: typeof filter_body === "function" ? filter_body(close_filter) : filter_body })), columns_menu && _jsx(ContextMenu, { x: columns_menu.x, y: columns_menu.y, label: "Columns", items: column_choices, on_close: () => set_columns_menu(null) })] }));
|
|
388
389
|
}
|
|
389
390
|
// ── Pages ────────────────────────────────────────────────────────────────────
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
// Stands in for the native popover and modal dialog, which jsdom does not draw; a test imports it for its side effects.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
document.
|
|
2
|
+
// A shared setup file may load in a node environment too, where there is no window to stand in for.
|
|
3
|
+
if (typeof window !== "undefined") {
|
|
4
|
+
HTMLElement.prototype.showPopover = function () {
|
|
5
|
+
this.setAttribute("data-shown", "");
|
|
6
|
+
};
|
|
7
|
+
HTMLElement.prototype.hidePopover = function () {
|
|
8
|
+
this.removeAttribute("data-shown");
|
|
9
|
+
this.dispatchEvent(Object.assign(new Event("toggle"), { newState: "closed" }));
|
|
10
|
+
};
|
|
11
|
+
HTMLDialogElement.prototype.showModal = function () {
|
|
12
|
+
this.setAttribute("open", "");
|
|
13
|
+
};
|
|
14
|
+
HTMLDialogElement.prototype.close = function () {
|
|
15
|
+
this.removeAttribute("open");
|
|
16
|
+
};
|
|
17
|
+
// jsdom hides every popover, since none ever matches :popover-open, so a shown one is drawn again here over any app rule.
|
|
18
|
+
const shown_popovers = document.createElement("style");
|
|
19
|
+
shown_popovers.textContent = "[popover][data-shown] { display: block !important; }";
|
|
20
|
+
document.head.append(shown_popovers);
|
|
21
|
+
// Media queries all say no and scrolling does nothing, since jsdom lays nothing out.
|
|
22
|
+
window.matchMedia ??= (query) => ({ matches: false, media: query, onchange: null, addEventListener: () => { }, removeEventListener: () => { }, addListener: () => { }, removeListener: () => { }, dispatchEvent: () => false });
|
|
23
|
+
window.scrollTo = () => { };
|
|
24
|
+
}
|
|
19
25
|
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A speaker button that opens a volume slider; scrolling over it adjusts the volume and a middle-click mutes.
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Popover } from "./menus.js";
|
|
5
|
+
const speaker_icon = (volume) => volume === 0 ? "🔇" : volume < 34 ? "🔈" : volume < 67 ? "🔉" : "🔊";
|
|
6
|
+
export function VolumeButton({ volume, on_change, step = 5, label = "Volume" }) {
|
|
7
|
+
const [open, set_open] = useState(false);
|
|
8
|
+
const trigger = useRef(null);
|
|
9
|
+
// The level before muting, so unmuting returns to it.
|
|
10
|
+
const before_mute = useRef(volume || 80);
|
|
11
|
+
// The click that closed the popover by landing on its button must not open it again.
|
|
12
|
+
const closed_at = useRef(0);
|
|
13
|
+
const latest_volume = useRef(volume);
|
|
14
|
+
latest_volume.current = volume;
|
|
15
|
+
function set(next) {
|
|
16
|
+
on_change(Math.max(0, Math.min(100, Math.round(next))));
|
|
17
|
+
}
|
|
18
|
+
function toggle_mute() {
|
|
19
|
+
if (latest_volume.current > 0) {
|
|
20
|
+
before_mute.current = latest_volume.current;
|
|
21
|
+
set(0);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
set(before_mute.current || 80);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// React's wheel listener is passive and cannot stop the page scrolling, so this one is attached by hand.
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const on_wheel = (event) => {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
set(latest_volume.current + (event.deltaY < 0 ? step : -step));
|
|
32
|
+
};
|
|
33
|
+
const button = trigger.current;
|
|
34
|
+
button.addEventListener("wheel", on_wheel, { passive: false });
|
|
35
|
+
return () => button.removeEventListener("wheel", on_wheel);
|
|
36
|
+
}, [step]);
|
|
37
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { ref: trigger, type: "button", className: "icon volume-button", "aria-label": `${label} ${volume}%`, "aria-haspopup": "dialog", "aria-expanded": open, title: `${label} ${volume}% — scroll to adjust, middle-click to mute`, onClick: () => { if (Date.now() - closed_at.current > 300)
|
|
38
|
+
set_open(!open); }, onAuxClick: (event) => { if (event.button === 1) {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
toggle_mute();
|
|
41
|
+
} }, children: speaker_icon(volume) }), open && (_jsx(Popover, { anchor: trigger.current, label: label, on_close: () => { closed_at.current = Date.now(); set_open(false); }, children: _jsxs("div", { className: "volume-popover", children: [_jsx("button", { type: "button", className: "icon", onClick: toggle_mute, "aria-label": volume === 0 ? "Unmute" : "Mute", children: volume === 0 ? "🔇" : "🔊" }), _jsx("input", { className: "seek input-sm", style: { "--range-progress": `${volume}%` }, type: "range", min: 0, max: 100, step: step, value: volume, "aria-label": label, onChange: (event) => set(Number(event.currentTarget.value)) }), _jsxs("span", { className: "volume-percent", children: [volume, "%"] })] }) }))] }));
|
|
42
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Watch } from "./watch.ts";
|
|
2
|
+
export interface Store<Value> {
|
|
3
|
+
get(): Value;
|
|
4
|
+
set(next: Value): void;
|
|
5
|
+
update(make: (current: Value) => Value): void;
|
|
6
|
+
watch: Watch;
|
|
7
|
+
}
|
|
8
|
+
export interface StoreOptions {
|
|
9
|
+
coalesce_in_milliseconds?: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function create_store<Value>(initial: Value, options?: StoreOptions): Store<Value>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// A value a page reads and events change, with a watch that redraws only what reads it.
|
|
2
|
+
import { coalesce_bumps, create_watch } from "./watch.js";
|
|
3
|
+
export function create_store(initial, options = {}) {
|
|
4
|
+
let value = initial;
|
|
5
|
+
const watch = create_watch();
|
|
6
|
+
const bump = options.coalesce_in_milliseconds ? coalesce_bumps(watch, options.coalesce_in_milliseconds) : watch.bump;
|
|
7
|
+
const set = (next) => {
|
|
8
|
+
value = next;
|
|
9
|
+
bump();
|
|
10
|
+
};
|
|
11
|
+
return {
|
|
12
|
+
get: () => value,
|
|
13
|
+
set,
|
|
14
|
+
update: (make) => set(make(value)),
|
|
15
|
+
watch,
|
|
16
|
+
};
|
|
17
|
+
}
|
package/dist/web/watch.d.ts
CHANGED
|
@@ -4,3 +4,8 @@ export interface Watch {
|
|
|
4
4
|
subscribe(listener: () => void): () => void;
|
|
5
5
|
}
|
|
6
6
|
export declare function create_watch(): Watch;
|
|
7
|
+
export interface CoalescedBump {
|
|
8
|
+
(): void;
|
|
9
|
+
cancel(): void;
|
|
10
|
+
}
|
|
11
|
+
export declare function coalesce_bumps(watch: Watch, interval_in_milliseconds: number, now?: () => number): CoalescedBump;
|
package/dist/web/watch.js
CHANGED
|
@@ -15,3 +15,23 @@ export function create_watch() {
|
|
|
15
15
|
},
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
|
+
export function coalesce_bumps(watch, interval_in_milliseconds, now = () => performance.now()) {
|
|
19
|
+
let timer = null;
|
|
20
|
+
let last_bump_at = -Infinity;
|
|
21
|
+
const schedule = () => {
|
|
22
|
+
if (timer !== null)
|
|
23
|
+
return;
|
|
24
|
+
const wait = Math.max(0, interval_in_milliseconds - (now() - last_bump_at));
|
|
25
|
+
timer = setTimeout(() => {
|
|
26
|
+
timer = null;
|
|
27
|
+
last_bump_at = now();
|
|
28
|
+
watch.bump();
|
|
29
|
+
}, wait);
|
|
30
|
+
};
|
|
31
|
+
schedule.cancel = () => {
|
|
32
|
+
if (timer !== null)
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
timer = null;
|
|
35
|
+
};
|
|
36
|
+
return schedule;
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@x-tech-laboratories/x-libs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Code the X-apps share: agents and their long runs, passwords, durations, and the web page's helpers and widgets",
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "Code the X-apps share: agents and their long runs, passwords and sign-in, the Fastify server pieces, durations, and the web page's helpers and widgets",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -27,12 +27,28 @@
|
|
|
27
27
|
"prepack": "npm run build"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
+
"@fastify/cookie": "^11.1.5",
|
|
31
|
+
"@fastify/cors": "^11.4.1",
|
|
32
|
+
"@fastify/formbody": "^9.0.1",
|
|
33
|
+
"@fastify/static": "^10.1.10",
|
|
30
34
|
"@fastify/websocket": "^11.3.5",
|
|
31
35
|
"fastify": "^5.12.7",
|
|
32
36
|
"react": "^19",
|
|
33
37
|
"react-dom": "^19"
|
|
34
38
|
},
|
|
35
39
|
"peerDependenciesMeta": {
|
|
40
|
+
"@fastify/cookie": {
|
|
41
|
+
"optional": true
|
|
42
|
+
},
|
|
43
|
+
"@fastify/cors": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"@fastify/formbody": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"@fastify/static": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
36
52
|
"@fastify/websocket": {
|
|
37
53
|
"optional": true
|
|
38
54
|
},
|
|
@@ -47,6 +63,10 @@
|
|
|
47
63
|
}
|
|
48
64
|
},
|
|
49
65
|
"devDependencies": {
|
|
66
|
+
"@fastify/cookie": "^11.1.5",
|
|
67
|
+
"@fastify/cors": "^11.4.1",
|
|
68
|
+
"@fastify/formbody": "^9.0.1",
|
|
69
|
+
"@fastify/static": "^10.1.10",
|
|
50
70
|
"@fastify/websocket": "^11.3.5",
|
|
51
71
|
"@testing-library/dom": "^10.4.2",
|
|
52
72
|
"@testing-library/react": "^16.3.3",
|
|
@@ -65,6 +85,8 @@
|
|
|
65
85
|
"dependencies": {
|
|
66
86
|
"@inquirer/password": "^5.2.5",
|
|
67
87
|
"@tanstack/react-table": "^8.21.3",
|
|
88
|
+
"dompurify": "^3.4.17",
|
|
89
|
+
"marked": "^18.1.3",
|
|
68
90
|
"parse-duration": "^2.1.11",
|
|
69
91
|
"pretty-bytes": "^7.2.0",
|
|
70
92
|
"sshpk": "^1.18.0"
|