@x-tech-laboratories/x-libs 0.8.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.
Files changed (61) hide show
  1. package/README.md +49 -8
  2. package/dist/electron/bridge.d.ts +26 -3
  3. package/dist/electron/bridge.js +57 -5
  4. package/dist/electron/fetch.d.ts +27 -0
  5. package/dist/electron/fetch.js +109 -1
  6. package/dist/server/config.d.ts +39 -0
  7. package/dist/server/config.js +109 -0
  8. package/dist/server/cors.d.ts +8 -0
  9. package/dist/server/cors.js +13 -0
  10. package/dist/server/events.d.ts +33 -0
  11. package/dist/server/events.js +68 -0
  12. package/dist/server/login_page.d.ts +8 -0
  13. package/dist/server/login_page.js +79 -0
  14. package/dist/server/replies.d.ts +12 -0
  15. package/dist/server/replies.js +41 -0
  16. package/dist/server/sign_in.d.ts +27 -0
  17. package/dist/server/sign_in.js +95 -0
  18. package/dist/server/web_folder.d.ts +11 -0
  19. package/dist/server/web_folder.js +40 -0
  20. package/dist/web/dom.d.ts +2 -0
  21. package/dist/web/dom.js +5 -1
  22. package/dist/web/errors.d.ts +1 -0
  23. package/dist/web/errors.js +27 -0
  24. package/dist/web/react/badges.d.ts +9 -0
  25. package/dist/web/react/badges.js +4 -0
  26. package/dist/web/react/controls.d.ts +16 -2
  27. package/dist/web/react/controls.js +75 -10
  28. package/dist/web/react/following.d.ts +25 -0
  29. package/dist/web/react/following.js +34 -0
  30. package/dist/web/react/layout.d.ts +11 -0
  31. package/dist/web/react/layout.js +6 -1
  32. package/dist/web/react/markdown.d.ts +8 -0
  33. package/dist/web/react/markdown.js +40 -0
  34. package/dist/web/react/media.js +1 -1
  35. package/dist/web/react/menu_button.d.ts +1 -1
  36. package/dist/web/react/menu_button.js +7 -3
  37. package/dist/web/react/menus.d.ts +1 -1
  38. package/dist/web/react/menus.js +5 -3
  39. package/dist/web/react/page_view.d.ts +16 -0
  40. package/dist/web/react/page_view.js +6 -0
  41. package/dist/web/react/progress.d.ts +10 -0
  42. package/dist/web/react/progress.js +9 -0
  43. package/dist/web/react/rating.d.ts +8 -0
  44. package/dist/web/react/rating.js +23 -0
  45. package/dist/web/react/states.d.ts +11 -2
  46. package/dist/web/react/states.js +29 -14
  47. package/dist/web/react/store.d.ts +4 -0
  48. package/dist/web/react/store.js +7 -0
  49. package/dist/web/react/suggestions.d.ts +23 -0
  50. package/dist/web/react/suggestions.js +57 -0
  51. package/dist/web/react/table.d.ts +1 -0
  52. package/dist/web/react/table.js +2 -1
  53. package/dist/web/react/test_stubs.d.ts +1 -0
  54. package/dist/web/react/test_stubs.js +25 -0
  55. package/dist/web/react/volume.d.ts +8 -0
  56. package/dist/web/react/volume.js +42 -0
  57. package/dist/web/store.d.ts +11 -0
  58. package/dist/web/store.js +17 -0
  59. package/dist/web/watch.d.ts +5 -0
  60. package/dist/web/watch.js +20 -0
  61. package/package.json +24 -2
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Output that keeps its newest lines in view as they arrive, unless the reader has scrolled up to read, with a Follow switch.
3
+ import { useCallback, useLayoutEffect, useRef, useState } from "react";
4
+ import { cx } from "../dom.js";
5
+ // How far above the bottom still counts as at the bottom.
6
+ const FOLLOW_SLACK_PIXELS = 8;
7
+ export const at_bottom = (element) => element.scrollTop + element.clientHeight >= element.scrollHeight - FOLLOW_SLACK_PIXELS;
8
+ // Scrolls the box to its end whenever the dependency changes while following; active false, such as once a run has ended, leaves the scroll alone.
9
+ export function use_follow_tail(dependency, active = true) {
10
+ const ref = useRef(null);
11
+ const [following, set_following] = useState(true);
12
+ useLayoutEffect(() => {
13
+ const element = ref.current;
14
+ if (following && active && element)
15
+ element.scrollTop = element.scrollHeight;
16
+ }, [dependency, following, active]);
17
+ const on_scroll = useCallback((event) => {
18
+ if (!active)
19
+ return;
20
+ set_following(at_bottom(event.currentTarget));
21
+ }, [active]);
22
+ return { ref, following, set_following, on_scroll };
23
+ }
24
+ export function FollowToggle({ following, on_change, disabled = false, label = "Follow" }) {
25
+ return (_jsxs("label", { className: "follow-toggle", children: [_jsx("input", { type: "checkbox", checked: following, disabled: disabled, onChange: (event) => on_change(event.currentTarget.checked) }), " ", label] }));
26
+ }
27
+ // Streamed text in an output box that follows its tail.
28
+ export function FollowingOutput({ text, active = true, toggle = false, class_name, tools }) {
29
+ const tail = use_follow_tail(text, active);
30
+ const output = _jsx("pre", { ref: tail.ref, className: cx("output", "following-output", class_name), onScroll: tail.on_scroll, children: text });
31
+ if (!toggle && tools === undefined)
32
+ return output;
33
+ return (_jsxs("div", { className: "following", children: [_jsxs("div", { className: "following-tools", children: [tools, toggle && _jsx(FollowToggle, { following: tail.following, on_change: tail.set_following, disabled: !active })] }), output] }));
34
+ }
@@ -1,6 +1,17 @@
1
1
  import { type MouseEvent, type ReactNode } from "react";
2
2
  import type { Tone } from "./badges.tsx";
3
3
  export { moved } from "../tables.ts";
4
+ interface PageHeadingProps {
5
+ title: ReactNode;
6
+ subtitle?: ReactNode;
7
+ icon?: ReactNode;
8
+ banner?: string | ReactNode;
9
+ actions?: ReactNode;
10
+ class_name?: string;
11
+ level?: 1 | 2 | 3;
12
+ children?: ReactNode;
13
+ }
14
+ export declare function PageHeading({ title, subtitle, icon, banner, actions, class_name, level, children }: PageHeadingProps): import("react").JSX.Element;
4
15
  interface SectionProps {
5
16
  title: ReactNode;
6
17
  tools?: ReactNode;
@@ -1,7 +1,12 @@
1
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, settings rows and list row buttons.
2
+ // The pieces a page is laid out with: the page heading, panels, facts, tiles, cards, tabs, breadcrumbs, toolbars, steppers, settings rows and list row buttons.
3
3
  import { Fragment } from "react";
4
4
  export { moved } from "../tables.js";
5
+ // A page's heading: an optional banner, the title with its icon and subtitle, and the page's buttons.
6
+ export function PageHeading({ title, subtitle, icon, banner, actions, class_name, level = 1, children }) {
7
+ const Title = `h${level}`;
8
+ return (_jsxs("header", { className: class_name ? `page-head ${class_name}` : "page-head", children: [typeof banner === "string" ? _jsx("img", { className: "page-banner", src: banner, alt: "", loading: "lazy" }) : banner, _jsxs("div", { className: "page-title spread", children: [_jsxs("div", { className: "page-identity", children: [icon, _jsxs("div", { children: [_jsx(Title, { children: title }), subtitle && _jsx("p", { className: "page-subtitle muted", children: subtitle })] })] }), actions && _jsx("div", { className: "head-actions", children: actions })] }), children] }));
9
+ }
5
10
  export function Section({ title, tools, class_name, on_context_menu, children }) {
6
11
  return (_jsxs("section", { className: class_name ? `panel ${class_name}` : "panel", onContextMenu: on_context_menu, children: [_jsxs("div", { className: "panel-head", children: [_jsx("h2", { children: title }), tools && _jsx("div", { className: "panel-tools", children: tools })] }), children] }));
7
12
  }
@@ -0,0 +1,8 @@
1
+ export declare function render_markdown(text: string): string;
2
+ interface MarkdownProps {
3
+ text: string;
4
+ class_name?: string;
5
+ follow?: boolean;
6
+ }
7
+ export declare function Markdown({ text, class_name, follow }: MarkdownProps): import("react").JSX.Element;
8
+ export {};
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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
+ }
@@ -10,7 +10,7 @@ export function Avatar({ src, name, size = "medium" }) {
10
10
  // One video, album or playlist: its picture, its title and a few lines about it.
11
11
  export function MediaTile({ title, href, image, corner, lines = [], dimmed = false, actions, lead }) {
12
12
  const shown_lines = lines.filter((line) => line !== null && line !== undefined && line !== false && line !== "");
13
- return (_jsxs("div", { role: "listitem", className: dimmed ? "media-tile dimmed" : "media-tile", children: [lead !== undefined && _jsx("span", { className: "media-lead", children: lead }), _jsxs("a", { className: "media-picture", href: href, tabIndex: -1, "aria-hidden": "true", children: [image ? _jsx("img", { src: image, alt: "", loading: "lazy" }) : _jsx("span", { className: "media-blank" }), corner && _jsx("span", { className: "media-corner", children: corner })] }), _jsxs("div", { className: "media-text", children: [_jsx("a", { className: "media-title", href: href, children: title }), shown_lines.map((line, index) => _jsx("div", { className: "media-line", children: line }, index))] }), actions && _jsx("div", { className: "media-actions", children: actions })] }));
13
+ return (_jsxs("div", { role: "listitem", className: dimmed ? "media-tile media-dimmed dimmed" : "media-tile", children: [lead !== undefined && _jsx("span", { className: "media-lead", children: lead }), _jsxs("a", { className: "media-picture", href: href, tabIndex: -1, "aria-hidden": "true", children: [image ? _jsx("img", { src: image, alt: "", loading: "lazy" }) : _jsx("span", { className: "media-blank" }), corner && _jsx("span", { className: "media-corner", children: corner })] }), _jsxs("div", { className: "media-text", children: [_jsx("a", { className: "media-title", href: href, children: title }), shown_lines.map((line, index) => _jsx("div", { className: "media-line", children: line }, index))] }), actions && _jsx("div", { className: "media-actions", children: actions })] }));
14
14
  }
15
15
  // Tiles side by side filling the width, or one under another as rows with the picture on the left.
16
16
  export function MediaGrid({ label, rows = false, children }) {
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
1
+ import { type ReactNode } from "react";
2
2
  import { type MenuItem } from "./menus.tsx";
3
3
  interface MenuButtonProps {
4
4
  label: string;
@@ -1,12 +1,16 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ // A button that opens a menu under itself, such as the ⋮ on a card; the Shell draws the menu.
3
+ import { useState } from "react";
2
4
  import { open_context_menu } from "./menus.js";
3
5
  export function MenuButton({ label, items, glyph = "⋮", class_name }) {
4
- function open(event) {
6
+ const [open, set_open] = useState(false);
7
+ function open_menu(event) {
5
8
  // The button may sit inside a link, such as a whole card that opens its video.
6
9
  event.preventDefault();
7
10
  event.stopPropagation();
8
11
  const box = event.currentTarget.getBoundingClientRect();
9
- open_context_menu({ clientX: box.left, clientY: box.bottom, preventDefault: () => { } }, typeof items === "function" ? items() : items);
12
+ set_open(true);
13
+ open_context_menu({ clientX: box.left, clientY: box.bottom, preventDefault: () => { } }, typeof items === "function" ? items() : items, () => set_open(false));
10
14
  }
11
- return (_jsx("button", { type: "button", className: class_name ? `icon menu-trigger ${class_name}` : "icon menu-trigger", "aria-label": label, title: label, "aria-haspopup": "menu", onClick: open, children: glyph }));
15
+ return (_jsx("button", { type: "button", className: class_name ? `icon menu-trigger ${class_name}` : "icon menu-trigger", "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open, onClick: open_menu, children: glyph }));
12
16
  }
@@ -18,7 +18,7 @@ export declare function open_context_menu(event: {
18
18
  clientX: number;
19
19
  clientY: number;
20
20
  preventDefault(): void;
21
- }, items: MenuItem[]): void;
21
+ }, items: MenuItem[], on_close?: () => void): void;
22
22
  export declare function ContextMenus(): import("react").JSX.Element | null;
23
23
  interface PopoverProps {
24
24
  anchor?: HTMLElement | null;
@@ -139,14 +139,16 @@ let open_menu = null;
139
139
  let next_menu_id = 1;
140
140
  const menu_listeners = new Set();
141
141
  function publish_menu(menu) {
142
+ const before = open_menu;
142
143
  open_menu = menu;
143
144
  for (const listener of menu_listeners)
144
145
  listener(open_menu);
146
+ before?.on_close?.();
145
147
  }
146
- // Opens a menu where the event happened, such as a right-click, replacing any menu already open.
147
- export function open_context_menu(event, items) {
148
+ // Opens a menu where the event happened, such as a right-click, replacing any menu already open; on_close hears when it closes for any reason.
149
+ export function open_context_menu(event, items, on_close) {
148
150
  event.preventDefault();
149
- publish_menu({ id: next_menu_id++, x: event.clientX, y: event.clientY, items });
151
+ publish_menu({ id: next_menu_id++, x: event.clientX, y: event.clientY, items, on_close });
150
152
  }
151
153
  // Where menus opened from anywhere are drawn; the Shell holds one.
152
154
  export function ContextMenus() {
@@ -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,8 @@
1
+ interface RatingProps {
2
+ value: number | null;
3
+ max?: number;
4
+ label?: string;
5
+ on_change?: (value: number | null) => void;
6
+ }
7
+ export declare function Rating({ value, max, label, on_change }: RatingProps): import("react").JSX.Element;
8
+ export {};
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
+ import type { Watch } from "../watch.ts";
2
3
  export declare function Skeleton({ lines }: {
3
4
  lines?: number;
4
5
  }): import("react").JSX.Element;
@@ -10,18 +11,26 @@ export declare function Failure({ error, retry }: {
10
11
  error: unknown;
11
12
  retry: () => void;
12
13
  }): import("react").JSX.Element;
14
+ export type ResourceStatus = "idle" | "loading" | "loaded" | "failed";
13
15
  export interface Resource<Data> {
14
16
  data: Data | null;
15
17
  error: unknown;
16
18
  loading: boolean;
19
+ status: ResourceStatus;
17
20
  reload: (quiet?: boolean) => Promise<void>;
18
21
  }
19
22
  export interface ResourceOptions {
20
23
  refresh_in_milliseconds?: number;
24
+ enabled?: boolean;
25
+ watch?: Watch;
21
26
  }
22
27
  export declare function use_resource<Data>(work: () => Promise<Data>, dependencies: unknown[], options?: ResourceOptions): Resource<Data>;
23
- export declare function Loaded<Data>({ resource, lines, children }: {
28
+ interface LoadedProps<Data> {
24
29
  resource: Resource<Data>;
25
30
  lines?: number;
31
+ idle?: ReactNode;
32
+ empty?: ReactNode;
26
33
  children: (data: Data) => ReactNode;
27
- }): import("react").JSX.Element;
34
+ }
35
+ export declare function Loaded<Data>({ resource, lines, idle, empty, children }: LoadedProps<Data>): import("react").JSX.Element;
36
+ export {};
@@ -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,40 +11,54 @@ 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: ", error instanceof Error ? error.message : String(error)] }), _jsx("button", { onClick: retry, children: "Retry" })] }));
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 = {}) {
17
- const [state, set_state] = useState({ data: null, error: null, loading: true });
18
+ const enabled = options.enabled ?? true;
19
+ const [state, set_state] = useState({ data: null, error: null, status: enabled ? "loading" : "idle" });
18
20
  const latest_work = useRef(work);
19
21
  latest_work.current = work;
20
22
  const generation = useRef(0);
21
23
  const reload = useCallback((quiet = false) => {
22
24
  const mine = ++generation.current;
23
25
  if (!quiet)
24
- set_state({ data: null, error: null, loading: true });
26
+ set_state({ data: null, error: null, status: "loading" });
25
27
  return latest_work.current().then((data) => { if (mine === generation.current)
26
- set_state({ data, error: null, loading: false }); }, (error) => {
28
+ set_state({ data, error: null, status: "loaded" }); }, (error) => {
27
29
  if (pass_login_needed(error) || mine !== generation.current)
28
30
  return;
29
- set_state((before) => quiet && before.data !== null ? { ...before, loading: false } : { data: null, error, loading: false });
31
+ set_state((before) => quiet && before.data !== null ? before : { data: null, error, status: "failed" });
30
32
  });
31
33
  }, []);
32
- useEffect(() => void reload(), dependencies);
33
34
  useEffect(() => {
34
- if (!options.refresh_in_milliseconds)
35
+ if (enabled)
36
+ return void reload();
37
+ // Turning off drops the load under way and what it showed.
38
+ generation.current += 1;
39
+ set_state((before) => before.status === "idle" ? before : { data: null, error: null, status: "idle" });
40
+ }, [enabled, ...dependencies]);
41
+ useEffect(() => {
42
+ if (!options.refresh_in_milliseconds || !enabled)
35
43
  return;
36
44
  const timer = setInterval(() => { if (document.visibilityState === "visible")
37
45
  void reload(true); }, options.refresh_in_milliseconds);
38
46
  return () => clearInterval(timer);
39
- }, [options.refresh_in_milliseconds]);
40
- return { ...state, reload };
47
+ }, [options.refresh_in_milliseconds, enabled]);
48
+ useEffect(() => {
49
+ if (!options.watch || !enabled)
50
+ return;
51
+ return options.watch.subscribe(() => void reload(true));
52
+ }, [options.watch, enabled]);
53
+ return { ...state, loading: state.status === "loading", reload };
41
54
  }
42
- // Shows a skeleton while the resource first loads, an error with Retry if that fails, and what children build from it after.
43
- export function Loaded({ resource, lines, children }) {
44
- if (resource.data !== null)
45
- return _jsx(_Fragment, { children: children(resource.data) });
46
- if (resource.error !== null)
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) });
59
+ if (resource.status === "idle")
60
+ return _jsx(_Fragment, { children: idle });
61
+ if (resource.status === "failed")
47
62
  return _jsx(Failure, { error: resource.error, retry: () => void resource.reload() });
48
63
  return _jsx(Skeleton, { lines: lines });
49
64
  }
@@ -0,0 +1,4 @@
1
+ import type { Store } from "../store.ts";
2
+ export type { Store, StoreOptions } from "../store.ts";
3
+ export { create_store } from "../store.ts";
4
+ export declare function use_store<Value>(store: Store<Value>): Value;
@@ -0,0 +1,7 @@
1
+ // Reads a store's value and redraws the component when it changes.
2
+ import { use_watch } from "./watch.js";
3
+ export { create_store } from "../store.js";
4
+ export function use_store(store) {
5
+ use_watch(store.watch);
6
+ return store.get();
7
+ }
@@ -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
+ }
@@ -37,6 +37,7 @@ interface DataTableProps<Row> {
37
37
  caption?: string;
38
38
  virtual?: TableVirtual;
39
39
  fill_last_column?: boolean;
40
+ wrap_class?: string;
40
41
  sorting?: Sorting | Sorting[];
41
42
  manual_sorting?: boolean;
42
43
  on_sort?: (sorting: Sorting) => void;
@@ -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: props.virtual ? "table-wrap virtual" : "table-wrap", 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
+ 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 ────────────────────────────────────────────────────────────────────
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ // Stands in for the native popover and modal dialog, which jsdom does not draw; a test imports it for its side effects.
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
+ }
25
+ export {};
@@ -0,0 +1,8 @@
1
+ interface VolumeButtonProps {
2
+ volume: number;
3
+ on_change: (volume: number) => void;
4
+ step?: number;
5
+ label?: string;
6
+ }
7
+ export declare function VolumeButton({ volume, on_change, step, label }: VolumeButtonProps): import("react").JSX.Element;
8
+ 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>;