@x-tech-laboratories/x-libs 0.6.0 → 0.8.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 (65) hide show
  1. package/README.md +40 -11
  2. package/dist/backups.d.ts +31 -0
  3. package/dist/backups.js +43 -0
  4. package/dist/durations.d.ts +1 -0
  5. package/dist/durations.js +10 -0
  6. package/dist/electron/app.d.ts +13 -0
  7. package/dist/electron/app.js +27 -0
  8. package/dist/electron/bridge.d.ts +3 -1
  9. package/dist/electron/bridge.js +21 -0
  10. package/dist/electron/events.d.ts +21 -0
  11. package/dist/electron/events.js +25 -0
  12. package/dist/electron/fetch.d.ts +25 -0
  13. package/dist/electron/fetch.js +67 -0
  14. package/dist/electron/window.d.ts +11 -0
  15. package/dist/electron/window.js +49 -6
  16. package/dist/log.d.ts +30 -0
  17. package/dist/log.js +79 -0
  18. package/dist/server/backup_files.d.ts +3 -0
  19. package/dist/server/backup_files.js +25 -0
  20. package/dist/web/drag.d.ts +3 -0
  21. package/dist/web/drag.js +13 -0
  22. package/dist/web/format.d.ts +1 -0
  23. package/dist/web/format.js +5 -0
  24. package/dist/web/prefs.d.ts +12 -0
  25. package/dist/web/prefs.js +62 -0
  26. package/dist/web/react/controls.d.ts +39 -4
  27. package/dist/web/react/controls.js +51 -7
  28. package/dist/web/react/dialog.d.ts +12 -2
  29. package/dist/web/react/dialog.js +7 -3
  30. package/dist/web/react/layout.d.ts +26 -6
  31. package/dist/web/react/layout.js +15 -6
  32. package/dist/web/react/list_picker.d.ts +19 -0
  33. package/dist/web/react/list_picker.js +50 -0
  34. package/dist/web/react/media.d.ts +24 -0
  35. package/dist/web/react/media.js +18 -0
  36. package/dist/web/react/menu_button.d.ts +10 -0
  37. package/dist/web/react/menu_button.js +12 -0
  38. package/dist/web/react/menus.js +26 -2
  39. package/dist/web/react/prefs.d.ts +2 -0
  40. package/dist/web/react/prefs.js +11 -0
  41. package/dist/web/react/reveal.d.ts +10 -0
  42. package/dist/web/react/reveal.js +30 -0
  43. package/dist/web/react/shell.d.ts +8 -2
  44. package/dist/web/react/shell.js +17 -6
  45. package/dist/web/react/table.d.ts +31 -1
  46. package/dist/web/react/table.js +209 -24
  47. package/dist/web/react/time.d.ts +1 -0
  48. package/dist/web/react/time.js +11 -1
  49. package/dist/web/react/toasts.d.ts +12 -0
  50. package/dist/web/react/toasts.js +31 -0
  51. package/dist/web/react/tree.d.ts +7 -2
  52. package/dist/web/react/tree.js +22 -7
  53. package/dist/web/react/watch.d.ts +3 -0
  54. package/dist/web/react/watch.js +5 -0
  55. package/dist/web/selection.d.ts +25 -0
  56. package/dist/web/selection.js +106 -0
  57. package/dist/web/tables.d.ts +1 -0
  58. package/dist/web/tables.js +8 -0
  59. package/dist/web/tree.d.ts +8 -3
  60. package/dist/web/tree.js +31 -8
  61. package/dist/web/virtual.d.ts +7 -0
  62. package/dist/web/virtual.js +10 -0
  63. package/dist/web/watch.d.ts +6 -0
  64. package/dist/web/watch.js +17 -0
  65. package/package.json +1 -1
package/dist/log.js ADDED
@@ -0,0 +1,79 @@
1
+ // Logging by scope and level, such as "warn,play:debug", where a mistake in the setting never silences logging.
2
+ const level_order = { critical: 0, error: 1, warn: 2, info: 3, debug: 4 };
3
+ export const levels = Object.keys(level_order);
4
+ const level_aliases = {
5
+ crit: "critical", critical: "critical",
6
+ err: "error", error: "error",
7
+ warn: "warn", warning: "warn",
8
+ info: "info",
9
+ debug: "debug", verbose: "debug", trace: "debug",
10
+ };
11
+ export const default_level = "info";
12
+ // ── Parsing ──────────────────────────────────────────────────────────────────
13
+ // A bare level sets the default and "scope:level" one scope; entries it cannot read are skipped.
14
+ export function parse_level_spec(spec, fallback = default_level) {
15
+ const parsed = { default: fallback, scopes: {} };
16
+ if (!spec)
17
+ return parsed;
18
+ for (const raw_part of spec.split(",")) {
19
+ const part = raw_part.trim().toLowerCase();
20
+ if (part === "")
21
+ continue;
22
+ // Scopes hold colons of their own, so the level follows the last one.
23
+ const colon = part.lastIndexOf(":");
24
+ if (colon === -1) {
25
+ const level = level_aliases[part];
26
+ if (level)
27
+ parsed.default = level;
28
+ continue;
29
+ }
30
+ const scope = part.slice(0, colon).trim();
31
+ const level = level_aliases[part.slice(colon + 1).trim()];
32
+ if (!level || scope === "")
33
+ continue;
34
+ if (scope === "*")
35
+ parsed.default = level;
36
+ else
37
+ parsed.scopes[scope] = level;
38
+ }
39
+ return parsed;
40
+ }
41
+ export function level_for(spec, scope) {
42
+ return spec.scopes[scope] ?? spec.default;
43
+ }
44
+ export function is_enabled(spec, scope, level) {
45
+ return level_order[level] <= level_order[level_for(spec, scope)];
46
+ }
47
+ const two_digits = (count) => String(count).padStart(2, "0");
48
+ // A line in fixed columns, such as "10:33:35.123 WARN play message {"detail":1}".
49
+ export function format_record(record) {
50
+ const date = new Date(record.at);
51
+ const time = `${two_digits(date.getHours())}:${two_digits(date.getMinutes())}:${two_digits(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`;
52
+ let line = `${time} ${record.level.toUpperCase().padEnd(8)} ${record.scope.padEnd(9)} ${record.message}`;
53
+ if (record.data !== undefined) {
54
+ try {
55
+ line += ` ${JSON.stringify(record.data)}`;
56
+ }
57
+ catch {
58
+ line += " [unserializable]";
59
+ }
60
+ }
61
+ return line;
62
+ }
63
+ const resolve_spec = (source) => typeof source === "function" ? source() : source;
64
+ export function create_logger(scope, spec, sink) {
65
+ const emit = (level, message, data) => {
66
+ if (!is_enabled(resolve_spec(spec), scope, level))
67
+ return;
68
+ sink({ at: Date.now(), level, scope, message, data });
69
+ };
70
+ return {
71
+ critical: (message, data) => emit("critical", message, data),
72
+ error: (message, data) => emit("error", message, data),
73
+ warn: (message, data) => emit("warn", message, data),
74
+ info: (message, data) => emit("info", message, data),
75
+ debug: (message, data) => emit("debug", message, data),
76
+ enabled: (level) => is_enabled(resolve_spec(spec), scope, level),
77
+ child: (child_scope) => create_logger(`${scope}:${child_scope}`, spec, sink),
78
+ };
79
+ }
@@ -0,0 +1,3 @@
1
+ import type { BackupNaming, BackupStore } from "../backups.ts";
2
+ export declare function backup_path(folder: string, naming: BackupNaming, name: string): string;
3
+ export declare function file_backup_store(folder: () => string, naming: BackupNaming, copy_to: (path: string) => void): BackupStore;
@@ -0,0 +1,25 @@
1
+ // Backups kept as files in a folder on disk, for an app's main process or server.
2
+ import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ // The path of a backup in the folder, refusing any name the naming did not write.
5
+ export function backup_path(folder, naming, name) {
6
+ if (!naming.pattern.test(name))
7
+ throw new Error(`not a backup name: ${name}`);
8
+ return join(folder, name);
9
+ }
10
+ // A folder of backup files, made when first used; copy_to writes the app's data to the path it is given, such as through SQLite's own backup.
11
+ export function file_backup_store(folder, naming, copy_to) {
12
+ const path_of = (name) => backup_path(folder(), naming, name);
13
+ return {
14
+ list: () => {
15
+ mkdirSync(folder(), { recursive: true });
16
+ return readdirSync(folder()).filter((name) => naming.pattern.test(name)).map((name) => ({ name, size_bytes: statSync(path_of(name)).size }));
17
+ },
18
+ take: (name) => {
19
+ mkdirSync(folder(), { recursive: true });
20
+ copy_to(path_of(name));
21
+ return statSync(path_of(name)).size;
22
+ },
23
+ remove: (name) => rmSync(path_of(name)),
24
+ };
25
+ }
@@ -0,0 +1,3 @@
1
+ export declare function set_drag_badge(event: {
2
+ dataTransfer: DataTransfer | null;
3
+ }, label: string, class_name?: string): void;
@@ -0,0 +1,13 @@
1
+ // What a drag shows under the pointer.
2
+ // Shows a small badge such as "3 tasks" under the pointer as the drag image, in place of a picture of the grabbed row.
3
+ export function set_drag_badge(event, label, class_name = "drag-badge") {
4
+ if (!event.dataTransfer)
5
+ return;
6
+ const badge = document.createElement("div");
7
+ badge.className = class_name;
8
+ badge.textContent = label;
9
+ document.body.append(badge);
10
+ event.dataTransfer.setDragImage(badge, -12, -12);
11
+ // The browser takes its picture of the badge during this event, so it can go once the event is over.
12
+ setTimeout(() => badge.remove(), 0);
13
+ }
@@ -8,3 +8,4 @@ export declare function size(count: number | null | undefined): string;
8
8
  export declare function size_change(count: number | null | undefined): string;
9
9
  export declare function count_change(value: number | null | undefined): string;
10
10
  export declare function plural(count: number, word: string): string;
11
+ export declare function compact_count(count: number): string;
@@ -75,3 +75,8 @@ export function count_change(value) {
75
75
  export function plural(count, word) {
76
76
  return `${count.toLocaleString()} ${word}${plurals.select(count) === "one" ? "" : "s"}`;
77
77
  }
78
+ const compact = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 });
79
+ // A count in a few characters, such as "1.4M", "150K" or "950".
80
+ export function compact_count(count) {
81
+ return compact.format(count);
82
+ }
@@ -0,0 +1,12 @@
1
+ export interface StorageLike {
2
+ getItem(key: string): string | null;
3
+ setItem(key: string, value: string): void;
4
+ removeItem(key: string): void;
5
+ }
6
+ export type Validator<Value> = (value: unknown) => value is Value;
7
+ export declare function browser_storage(): StorageLike | undefined;
8
+ export declare function read_pref<Value>(storage: StorageLike | undefined, key: string, initial: Value, validate?: Validator<Value>): Value;
9
+ export declare function write_pref(storage: StorageLike | undefined, key: string, value: unknown): void;
10
+ export declare function one_of<Value extends string>(...allowed: readonly Value[]): Validator<Value>;
11
+ export declare function is_boolean(value: unknown): value is boolean;
12
+ export declare function is_number_in(min: number, max: number): Validator<number>;
@@ -0,0 +1,62 @@
1
+ // Remembered view settings: a stored value that is corrupt, from an older version or of the wrong shape reads as the default.
2
+ // This browser's localStorage, or nothing where there is none or it may not be touched.
3
+ export function browser_storage() {
4
+ try {
5
+ return typeof localStorage === "undefined" ? undefined : localStorage;
6
+ }
7
+ catch {
8
+ return undefined;
9
+ }
10
+ }
11
+ // ── Reading and writing ──────────────────────────────────────────────────────
12
+ // The stored value, or the initial one whenever the stored one cannot be used.
13
+ export function read_pref(storage, key, initial, validate) {
14
+ if (!storage)
15
+ return initial;
16
+ let raw;
17
+ try {
18
+ raw = storage.getItem(key);
19
+ }
20
+ catch {
21
+ return initial;
22
+ }
23
+ if (raw === null)
24
+ return initial;
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(raw);
28
+ }
29
+ catch {
30
+ return initial;
31
+ }
32
+ if (validate)
33
+ return validate(parsed) ? parsed : initial;
34
+ // Without a validator the stored value must at least have the initial value's shape.
35
+ if (parsed === null || initial === null)
36
+ return parsed ?? initial;
37
+ if (Array.isArray(initial) !== Array.isArray(parsed))
38
+ return initial;
39
+ if (typeof parsed !== typeof initial)
40
+ return initial;
41
+ return parsed;
42
+ }
43
+ export function write_pref(storage, key, value) {
44
+ if (!storage)
45
+ return;
46
+ try {
47
+ storage.setItem(key, JSON.stringify(value));
48
+ }
49
+ catch {
50
+ // A full or disabled storage keeps the setting until the page closes.
51
+ }
52
+ }
53
+ // ── Validators ───────────────────────────────────────────────────────────────
54
+ export function one_of(...allowed) {
55
+ return (value) => typeof value === "string" && allowed.includes(value);
56
+ }
57
+ export function is_boolean(value) {
58
+ return typeof value === "boolean";
59
+ }
60
+ export function is_number_in(min, max) {
61
+ return (value) => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
62
+ }
@@ -1,4 +1,5 @@
1
- import { type ReactNode } from "react";
1
+ import { type InputHTMLAttributes, type ReactNode } from "react";
2
+ import { type TreeItem } from "../tree.ts";
2
3
  interface SegmentedProps<Value extends string> {
3
4
  label: string;
4
5
  choices: {
@@ -25,13 +26,47 @@ interface SearchBoxProps {
25
26
  label: string;
26
27
  value: string;
27
28
  on_input: (value: string) => void;
29
+ auto_focus?: boolean;
28
30
  }
29
- export declare function SearchBox({ label, value, on_input }: SearchBoxProps): import("react").JSX.Element;
30
- interface SelectProps<Value extends string> {
31
+ export declare function SearchBox({ label, value, on_input, auto_focus }: SearchBoxProps): import("react").JSX.Element;
32
+ interface SelectProps<Value extends string | number> {
31
33
  label: string;
32
34
  options: [value: Value, text: string][];
33
35
  value: Value;
34
36
  on_change: (value: Value) => void;
37
+ bare?: boolean;
35
38
  }
36
- export declare function Select<Value extends string>({ label, options, value, on_change }: SelectProps<Value>): import("react").JSX.Element;
39
+ export declare function Select<Value extends string | number>({ label, options, value, on_change, bare }: SelectProps<Value>): import("react").JSX.Element;
40
+ type CommitInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange"> & {
41
+ value: string | number;
42
+ on_commit: (event: {
43
+ currentTarget: HTMLInputElement;
44
+ }) => void;
45
+ };
46
+ export declare function CommitInput({ value, on_commit, onBlur, onKeyDown, ...rest }: CommitInputProps): import("react").JSX.Element;
47
+ interface DateTimeFieldProps {
48
+ label: string;
49
+ value: string | null;
50
+ on_change: (value: string | null) => void;
51
+ allow_time?: boolean;
52
+ }
53
+ export declare function DateTimeField({ label, value, on_change, allow_time }: DateTimeFieldProps): import("react").JSX.Element;
54
+ interface ColorPickerProps {
55
+ label: string;
56
+ value: string | null;
57
+ swatches: string[];
58
+ on_change: (color: string | null) => void;
59
+ allow_none?: boolean;
60
+ }
61
+ export declare function ColorPicker({ label, value, swatches, on_change, allow_none }: ColorPickerProps): import("react").JSX.Element;
62
+ interface TreeSelectProps<Item extends TreeItem> {
63
+ label: string;
64
+ items: Item[];
65
+ name_of: (item: Item) => string;
66
+ value: string;
67
+ blank_label: string;
68
+ disabled_ids?: readonly string[];
69
+ on_change: (id: string) => void;
70
+ }
71
+ export declare function TreeSelect<Item extends TreeItem>({ label, items, name_of, value, blank_label, disabled_ids, on_change }: TreeSelectProps<Item>): import("react").JSX.Element;
37
72
  export {};
@@ -1,6 +1,8 @@
1
+ import { createElement as _createElement } from "react";
1
2
  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
+ // React controls drawn like the page's own: segmented choices, switches, search fields, selects, and date, colour and tree pickers.
3
4
  import { useEffect, useRef, useState } from "react";
5
+ import { in_tree_order, path_names } from "../tree.js";
4
6
  // One of a few choices side by side; the chosen one takes the accent.
5
7
  export function Segmented({ label, choices, value, on_change, class_name }) {
6
8
  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))) }));
@@ -29,16 +31,58 @@ export function Switch({ label, checked, on_change, disabled = false }) {
29
31
  }
30
32
  return _jsx("input", { type: "checkbox", role: "switch", "aria-label": label, checked: checked, disabled: disabled || busy, onChange: change });
31
33
  }
32
- // A search field that hands on what was typed once typing pauses; the field keeps its own text, so a redraw never wipes typing.
33
- export function SearchBox({ label, value, on_input }) {
34
+ // A search field that hands on what was typed once typing pauses; the field keeps its own text, so a redraw never wipes typing, and shows a value changed from outside, such as one cleared.
35
+ export function SearchBox({ label, value, on_input, auto_focus = false }) {
36
+ const field = useRef(null);
34
37
  const timer = useRef(undefined);
38
+ const handed_on = useRef(value);
35
39
  useEffect(() => () => clearTimeout(timer.current), []);
36
- return (_jsx("input", { type: "search", className: "filter", placeholder: label, "aria-label": label, defaultValue: value, onChange: (event) => {
40
+ useEffect(() => {
41
+ if (value === handed_on.current || !field.current)
42
+ return;
43
+ clearTimeout(timer.current);
44
+ handed_on.current = value;
45
+ field.current.value = value;
46
+ }, [value]);
47
+ return (_jsx("input", { ref: field, type: "search", className: "filter", placeholder: label, "aria-label": label, defaultValue: value, autoFocus: auto_focus, onChange: (event) => {
37
48
  clearTimeout(timer.current);
38
49
  const text = event.currentTarget.value;
39
- timer.current = window.setTimeout(() => on_input(text), 200);
50
+ timer.current = window.setTimeout(() => {
51
+ handed_on.current = text;
52
+ on_input(text);
53
+ }, 200);
40
54
  } }));
41
55
  }
42
- export function Select({ label, options, value, on_change }) {
43
- return (_jsxs("label", { className: "field", children: [_jsx("span", { className: "field-label", children: label }), _jsx("select", { value: value, onChange: (event) => on_change(event.currentTarget.value), children: options.map(([option, text]) => _jsx("option", { value: option, children: text }, option)) })] }));
56
+ export function Select({ label, options, value, on_change, bare = false }) {
57
+ const change = (event) => {
58
+ const text = event.currentTarget.value;
59
+ on_change((options.find(([option]) => String(option) === text)?.[0] ?? text));
60
+ };
61
+ const select = (_jsx("select", { value: value, "aria-label": bare ? label : undefined, onChange: change, children: options.map(([option, text]) => _jsx("option", { value: option, children: text }, option)) }));
62
+ if (bare)
63
+ return select;
64
+ return (_jsxs("label", { className: "field", children: [_jsx("span", { className: "field-label", children: label }), select] }));
65
+ }
66
+ // A field that saves once it is left or Enter is pressed, as a native change event does, and shows the saved value again whenever that changes.
67
+ export function CommitInput({ value, on_commit, onBlur, onKeyDown, ...rest }) {
68
+ return (_createElement("input", { ...rest, key: String(value), defaultValue: value, onBlur: (event) => { onBlur?.(event); on_commit(event); }, onKeyDown: (event) => { onKeyDown?.(event); if (event.key === "Enter")
69
+ event.currentTarget.blur(); } }));
70
+ }
71
+ // A local date with an optional time and a clear button; clearing the date clears the time.
72
+ export function DateTimeField({ label, value, on_change, allow_time = true }) {
73
+ const [day = "", time = ""] = (value ?? "").split("T");
74
+ const join = (next_day, next_time) => on_change(next_day ? (next_time && allow_time ? `${next_day}T${next_time}` : next_day) : null);
75
+ return (_jsxs("div", { className: "field date-time-field", children: [_jsx("span", { className: "field-label", children: label }), _jsxs("div", { className: "date-time-inputs", children: [_jsx("input", { type: "date", "aria-label": `${label} date`, value: day, onChange: (event) => join(event.currentTarget.value, time) }), day && allow_time && _jsx("input", { type: "time", "aria-label": `${label} time`, value: time, onChange: (event) => join(day, event.currentTarget.value) }), day && _jsx("button", { type: "button", className: "icon", "aria-label": `Clear ${label.toLowerCase()} date`, onClick: () => on_change(null), children: "\u00D7" })] })] }));
76
+ }
77
+ // Swatches, a None choice, and the system colour chooser for any other colour.
78
+ export function ColorPicker({ label, value, swatches, on_change, allow_none = true }) {
79
+ const is_swatch = value !== null && swatches.some((swatch) => swatch.toLowerCase() === value.toLowerCase());
80
+ const custom = value !== null && !is_swatch;
81
+ return (_jsxs("div", { className: "color-picker", role: "radiogroup", "aria-label": label, children: [allow_none && _jsx("button", { type: "button", role: "radio", className: "color-swatch none", "aria-checked": value === null, "aria-label": "No colour", onClick: () => on_change(null) }), swatches.map((swatch) => (_jsx("button", { type: "button", role: "radio", className: "color-swatch", style: { "--swatch-color": swatch }, "aria-checked": value !== null && swatch.toLowerCase() === value.toLowerCase(), "aria-label": swatch, title: swatch, onClick: () => on_change(swatch) }, swatch))), _jsx("input", { type: "color", className: custom ? "color-custom chosen" : "color-custom", "aria-label": "Other colour", value: value ?? "#888888", onChange: (event) => on_change(event.currentTarget.value) })] }));
82
+ }
83
+ // A select listing a tree indented, each choice's whole path on hover and in the closed select.
84
+ export function TreeSelect({ label, items, name_of, value, blank_label, disabled_ids = [], on_change }) {
85
+ const depth_of = (item) => path_names(item, items, name_of).length - 1;
86
+ const chosen = items.find((item) => item.id === value);
87
+ return (_jsxs("select", { className: "tree-select", "aria-label": label, value: value, title: chosen ? path_names(chosen, items, name_of).join(" › ") : undefined, onChange: (event) => on_change(event.currentTarget.value), children: [_jsx("option", { value: "", children: blank_label }), in_tree_order(items).map((item) => (_jsx("option", { value: item.id, disabled: disabled_ids.includes(item.id), title: path_names(item, items, name_of).join(" › "), children: `${"\u2003".repeat(depth_of(item))}${name_of(item)}` }, item.id)))] }));
44
88
  }
@@ -1,13 +1,23 @@
1
- import { type ReactNode } from "react";
1
+ import { type FormEvent, type ReactNode } from "react";
2
+ export interface DialogForm {
3
+ on_submit: (event: FormEvent<HTMLFormElement>) => void;
4
+ submit_label?: ReactNode;
5
+ submit_disabled?: boolean;
6
+ danger?: boolean;
7
+ }
2
8
  interface DialogProps {
3
9
  title: ReactNode;
10
+ subtitle?: ReactNode;
11
+ actions?: ReactNode;
4
12
  on_close: () => void;
5
13
  buttons?: ReactNode;
14
+ form?: DialogForm;
6
15
  wide?: boolean;
16
+ full?: boolean;
7
17
  class_name?: string;
8
18
  children: ReactNode;
9
19
  }
10
- export declare function Dialog({ title, on_close, buttons, wide, class_name, children }: DialogProps): import("react").JSX.Element;
20
+ export declare function Dialog({ title, subtitle, actions, on_close, buttons, form, wide, full, class_name, children }: DialogProps): import("react").JSX.Element;
11
21
  export interface ConfirmOptions {
12
22
  title: string;
13
23
  message: ReactNode;
@@ -3,7 +3,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
3
3
  import { Fragment, useEffect, useId, useRef, useState } from "react";
4
4
  import { toast } from "./toasts.js";
5
5
  // Shown while it is rendered: the page behind it is inert, focus stays inside, Escape calls on_close, and focus goes back to the opener after.
6
- export function Dialog({ title, on_close, buttons, wide = false, class_name, children }) {
6
+ export function Dialog({ title, subtitle, actions, on_close, buttons, form, wide = false, full = false, class_name, children }) {
7
7
  const dialog = useRef(null);
8
8
  const title_id = useId();
9
9
  const close = useRef(on_close);
@@ -19,12 +19,16 @@ export function Dialog({ title, on_close, buttons, wide = false, class_name, chi
19
19
  opener.focus();
20
20
  };
21
21
  }, []);
22
- return (_jsxs("dialog", { ref: dialog, className: ["dialog", wide && "wide", class_name].filter(Boolean).join(" "), "aria-labelledby": title_id, onCancel: (event) => {
22
+ const heading = _jsx("h2", { id: title_id, children: title });
23
+ const submit = form?.submit_label !== undefined && (_jsx("button", { type: "submit", className: form.danger ? "danger solid" : "accent solid", disabled: form.submit_disabled, children: form.submit_label }));
24
+ const foot = (buttons || submit) && _jsxs("div", { className: "dialog-buttons", children: [buttons, submit] });
25
+ const body = _jsx("div", { className: "dialog-body", children: children });
26
+ return (_jsxs("dialog", { ref: dialog, className: ["dialog", wide && "wide", full && "full", class_name].filter(Boolean).join(" "), "aria-labelledby": title_id, onCancel: (event) => {
23
27
  // Escape asks the owner to close, so the dialog never closes behind its owner's back.
24
28
  event.preventDefault();
25
29
  event.stopPropagation();
26
30
  close.current();
27
- }, children: [_jsx("h2", { id: title_id, children: title }), _jsx("div", { className: "dialog-body", children: children }), buttons && _jsx("div", { className: "dialog-buttons", children: buttons })] }));
31
+ }, children: [subtitle === undefined && actions === undefined ? heading : (_jsxs("header", { className: "dialog-header", children: [_jsxs("div", { className: "dialog-heading", children: [heading, subtitle !== undefined && _jsx("p", { className: "dialog-subtitle", children: subtitle })] }), actions !== undefined && _jsx("div", { className: "dialog-actions", children: actions })] })), form ? (_jsxs("form", { className: "dialog-form", onSubmit: (event) => { event.preventDefault(); form.on_submit(event); }, children: [body, foot] })) : _jsxs(_Fragment, { children: [body, foot] })] }));
28
32
  }
29
33
  export function ConfirmDialog({ options, on_close }) {
30
34
  const [typed, set_typed] = useState("");
@@ -1,10 +1,14 @@
1
- import { type ReactNode } from "react";
1
+ import { type MouseEvent, type ReactNode } from "react";
2
2
  import type { Tone } from "./badges.tsx";
3
- export declare function Section({ title, tools, children }: {
3
+ export { moved } from "../tables.ts";
4
+ interface SectionProps {
4
5
  title: ReactNode;
5
6
  tools?: ReactNode;
7
+ class_name?: string;
8
+ on_context_menu?: (event: MouseEvent<HTMLElement>) => void;
6
9
  children?: ReactNode;
7
- }): import("react").JSX.Element;
10
+ }
11
+ export declare function Section({ title, tools, class_name, on_context_menu, children }: SectionProps): import("react").JSX.Element;
8
12
  export type Fact = [label: string, value: ReactNode, class_name?: string];
9
13
  export declare function Facts({ rows, layout }: {
10
14
  rows: (Fact | null | false)[];
@@ -67,14 +71,30 @@ export declare function Stepper({ label, previous, position, next }: {
67
71
  next: Step;
68
72
  }): import("react").JSX.Element;
69
73
  export declare function SettingsGroup({ heading, hint, children }: {
70
- heading: string;
74
+ heading: ReactNode;
71
75
  hint?: ReactNode;
72
76
  children: ReactNode;
73
77
  }): import("react").JSX.Element;
74
- export declare function SettingRow({ label, hint, as_label, children }: {
78
+ export declare function SettingRow({ label, hint, as_label, class_name, children }: {
75
79
  label: ReactNode;
76
80
  hint?: ReactNode;
77
81
  as_label?: boolean;
82
+ class_name?: string;
78
83
  children: ReactNode;
79
84
  }): import("react").JSX.Element;
80
- export {};
85
+ interface RowActionsProps {
86
+ index: number;
87
+ count: number;
88
+ on_move: (from: number, to: number) => void;
89
+ on_remove?: () => void;
90
+ remove_label: string;
91
+ }
92
+ export declare function RowActions({ index, count, on_move, on_remove, remove_label }: RowActionsProps): import("react").JSX.Element;
93
+ interface SelectionBarProps {
94
+ count: number;
95
+ total: number;
96
+ on_clear: () => void;
97
+ on_select_all: () => void;
98
+ children: ReactNode;
99
+ }
100
+ export declare function SelectionBar({ count, total, on_clear, on_select_all, children }: SelectionBarProps): import("react").JSX.Element;
@@ -1,9 +1,9 @@
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 and settings rows.
2
+ // The pieces a page is laid out with: panels, facts, tiles, cards, tabs, breadcrumbs, toolbars, steppers, settings rows and list row buttons.
3
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] }));
4
+ export { moved } from "../tables.js";
5
+ export function Section({ title, tools, class_name, on_context_menu, children }) {
6
+ 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
7
  }
8
8
  // Labelled values in a grid of tiles, or as rows of label and value for long values such as paths.
9
9
  export function Facts({ rows, layout = "grid" }) {
@@ -65,7 +65,16 @@ export function SettingsGroup({ heading, hint, children }) {
65
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
66
  }
67
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 }) {
68
+ export function SettingRow({ label, hint, as_label = false, class_name, children }) {
69
+ const row_class = class_name ? `setting-row ${class_name}` : "setting-row";
69
70
  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
+ return as_label ? _jsxs("label", { className: row_class, children: [text, children] }) : _jsxs("div", { className: row_class, children: [text, children] });
72
+ }
73
+ // The up, down and remove buttons of a row in an ordered list.
74
+ export function RowActions({ index, count, on_move, on_remove, remove_label }) {
75
+ return (_jsxs("span", { className: "row-actions", children: [_jsx("button", { type: "button", className: "icon", "aria-label": "Move up", disabled: index === 0, onClick: () => on_move(index, index - 1), children: "\u2191" }), _jsx("button", { type: "button", className: "icon", "aria-label": "Move down", disabled: index === count - 1, onClick: () => on_move(index, index + 1), children: "\u2193" }), on_remove && _jsx("button", { type: "button", className: "icon danger", "aria-label": remove_label, onClick: on_remove, children: "\u00D7" })] }));
76
+ }
77
+ // A bar floating at the foot of the page while rows are picked: how many, Select all, the actions, and a button that lets them go.
78
+ export function SelectionBar({ count, total, on_clear, on_select_all, children }) {
79
+ return (_jsxs("div", { className: "selection-bar", role: "toolbar", "aria-label": "Selected rows", children: [_jsx("button", { type: "button", className: "icon", "aria-label": "Clear selection", title: "Clear selection (Esc)", onClick: on_clear, children: "\u2715" }), _jsxs("span", { className: "selection-count", children: [count, " selected"] }), count < total && _jsx("button", { type: "button", onClick: on_select_all, children: "Select all" }), _jsx("span", { className: "selection-divider", "aria-hidden": "true" }), children] }));
71
80
  }
@@ -0,0 +1,19 @@
1
+ export interface PickableList {
2
+ id: string;
3
+ name: string;
4
+ count?: number;
5
+ holds: boolean;
6
+ }
7
+ export interface ListPickerOptions {
8
+ title: string;
9
+ load: () => Promise<PickableList[]>;
10
+ add: (id: string) => Promise<void>;
11
+ create: (name: string) => Promise<PickableList>;
12
+ new_label?: string;
13
+ empty_text?: string;
14
+ }
15
+ export declare function ListPicker({ options, on_close }: {
16
+ options: ListPickerOptions;
17
+ on_close: () => void;
18
+ }): import("react").JSX.Element;
19
+ export declare function pick_list_dialog(options: ListPickerOptions): () => void;
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A dialog that adds one thing to any of the app's lists, such as a video to playlists, and makes a new list for it.
3
+ import { useState } from "react";
4
+ import { Badge } from "./badges.js";
5
+ import { Dialog, open_dialog } from "./dialog.js";
6
+ import { Empty, Loaded, use_resource } from "./states.js";
7
+ export function ListPicker({ options, on_close }) {
8
+ const lists = use_resource(options.load, []);
9
+ const [added, set_added] = useState([]);
10
+ const [new_name, set_new_name] = useState("");
11
+ const [busy, set_busy] = useState(false);
12
+ const new_label = options.new_label ?? "New list";
13
+ // Lists added to or made here show as holding the thing without loading them again.
14
+ const shown = (loaded) => {
15
+ const merged = loaded.map((list) => added.find((other) => other.id === list.id) ?? list);
16
+ return [...merged, ...added.filter((list) => !loaded.some((other) => other.id === list.id))];
17
+ };
18
+ async function add(list) {
19
+ set_busy(true);
20
+ try {
21
+ await options.add(list.id);
22
+ set_added((before) => [...before, { ...list, holds: true, count: list.count === undefined ? undefined : list.count + 1 }]);
23
+ }
24
+ finally {
25
+ set_busy(false);
26
+ }
27
+ }
28
+ async function create(event) {
29
+ event.preventDefault();
30
+ const name = new_name.trim();
31
+ if (!name)
32
+ return;
33
+ set_busy(true);
34
+ try {
35
+ const list = await options.create(name);
36
+ set_added((before) => [...before, { ...list, holds: true }]);
37
+ set_new_name("");
38
+ }
39
+ finally {
40
+ set_busy(false);
41
+ }
42
+ }
43
+ return (_jsxs(Dialog, { title: options.title, on_close: on_close, class_name: "list-picker", buttons: _jsx("button", { className: "accent solid", onClick: on_close, children: "Done" }), children: [_jsx(Loaded, { resource: lists, lines: 3, children: (loaded) => shown(loaded).length === 0
44
+ ? _jsx(Empty, { title: options.empty_text ?? "No lists yet." })
45
+ : (_jsx("ul", { className: "plain-list list-picker-lists", children: shown(loaded).map((list) => (_jsx("li", { children: _jsxs("button", { type: "button", className: "list-picker-item", "aria-pressed": list.holds, disabled: list.holds || busy, onClick: () => void add(list), children: [_jsx("span", { className: "list-picker-tick", "aria-hidden": "true", children: list.holds ? "✓" : "" }), _jsx("span", { className: "list-picker-name", children: list.name }), list.count !== undefined && _jsx(Badge, { children: list.count })] }) }, list.id))) })) }), _jsxs("form", { className: "list-picker-new", onSubmit: (event) => void create(event), children: [_jsx("input", { type: "text", placeholder: new_label, "aria-label": new_label, value: new_name, onChange: (event) => set_new_name(event.currentTarget.value) }), _jsx("button", { type: "submit", disabled: !new_name.trim() || busy, children: "Create" })] })] }));
46
+ }
47
+ // Opens the picker from anywhere, such as a card's menu; the Shell draws it.
48
+ export function pick_list_dialog(options) {
49
+ return open_dialog((close) => _jsx(ListPicker, { options: options, on_close: close }));
50
+ }
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from "react";
2
+ interface AvatarProps {
3
+ src?: string | null;
4
+ name: string;
5
+ size?: "small" | "medium" | "large";
6
+ }
7
+ export declare function Avatar({ src, name, size }: AvatarProps): import("react").JSX.Element;
8
+ interface MediaTileProps {
9
+ title: ReactNode;
10
+ href: string;
11
+ image?: string | null;
12
+ corner?: ReactNode;
13
+ lines?: ReactNode[];
14
+ dimmed?: boolean;
15
+ actions?: ReactNode;
16
+ lead?: ReactNode;
17
+ }
18
+ export declare function MediaTile({ title, href, image, corner, lines, dimmed, actions, lead }: MediaTileProps): import("react").JSX.Element;
19
+ export declare function MediaGrid({ label, rows, children }: {
20
+ label?: string;
21
+ rows?: boolean;
22
+ children: ReactNode;
23
+ }): import("react").JSX.Element;
24
+ export {};
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A round picture of a person or channel, or the first letter of its name when there is none.
3
+ export function Avatar({ src, name, size = "medium" }) {
4
+ const avatar_class = `avatar avatar-${size}`;
5
+ if (src)
6
+ return _jsx("img", { className: avatar_class, src: src, alt: "", loading: "lazy" });
7
+ const first_letter = [...name.trim()][0]?.toLocaleUpperCase() ?? "?";
8
+ return _jsx("span", { className: `${avatar_class} avatar-initial`, "aria-hidden": "true", children: first_letter });
9
+ }
10
+ // One video, album or playlist: its picture, its title and a few lines about it.
11
+ export function MediaTile({ title, href, image, corner, lines = [], dimmed = false, actions, lead }) {
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 })] }));
14
+ }
15
+ // Tiles side by side filling the width, or one under another as rows with the picture on the left.
16
+ export function MediaGrid({ label, rows = false, children }) {
17
+ return _jsx("div", { role: "list", "aria-label": label, className: rows ? "media-grid media-rows" : "media-grid", children: children });
18
+ }
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from "react";
2
+ import { type MenuItem } from "./menus.tsx";
3
+ interface MenuButtonProps {
4
+ label: string;
5
+ items: MenuItem[] | (() => MenuItem[]);
6
+ glyph?: ReactNode;
7
+ class_name?: string;
8
+ }
9
+ export declare function MenuButton({ label, items, glyph, class_name }: MenuButtonProps): import("react").JSX.Element;
10
+ export {};