@x-tech-laboratories/x-libs 0.8.0 → 0.9.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 CHANGED
@@ -33,13 +33,14 @@ Code the X-apps share, published as one npm package, `@x-tech-laboratories/x-lib
33
33
  | `web/prefs`, `web/react/prefs` | Remembered view settings that read as their default when stored corrupt, stale or of the wrong shape; `use_pref` keeps one as component state. |
34
34
  | `web/selection`, `web/virtual`, `web/drag` | Picking rows with plain, Ctrl and Shift clicks from a fixed anchor, or as lists of ids with the anchor kept by id, the row an arrow or page key moves to; which rows of a long list a viewport needs; a badge such as "3 tasks" as the drag image. |
35
35
  | `web/react/shell` | `Shell`: top bar, section links or the app's own sidebar in a `.sidebar-content` box, resizable, theme button, a `status_bar` at the foot, toasts, dialogs and menus, with or without logins (no account UI without a `user`, `logged_out` hiding the sidebar and tools), its phone menu held by the Shell or the app; `use_route`. |
36
- | `web/react/layout`, `badges`, `states` | Panels, facts, tiles, cards, tabs, crumbs, steppers, settings rows, `RowActions` and `moved` for ordered lists, `SelectionBar` for picked rows; badges and chips; `use_resource` and its loading states. |
36
+ | `web/react/layout`, `badges`, `states` | `PageHeading` with a subtitle, icon, banner and actions; panels, facts, tiles, cards, tabs, crumbs, steppers, settings rows, `RowActions` and `moved` for ordered lists, `SelectionBar` for picked rows; badges and chips; `use_resource` with its `status` (idle until `enabled`, loading, loaded, failed), a `watch` that reloads it quietly, and its loading states, `Loaded` taking an `idle` slot. |
37
37
  | `web/react/time` | `Age`: a time as how long ago, with the full stamp on hover; `use_now`, the time read again each minute. |
38
38
  | `web/react/reveal` | `use_reveal`: a long list shown a page at a time, the next page added as its end scrolls into view, starting from the count shown before the page was left if given. |
39
39
  | `web/react/media` | `Avatar`, a round picture or the name's first letter; `MediaTile` and `MediaGrid`: videos, albums or playlists as tiles in a grid or as rows, with text over the picture's corner such as a length, lines under the title, a faded look, and buttons in the corner. |
40
- | `web/react/menu_button` | `MenuButton`: a ⋮ button that opens a menu under itself, even inside a link. |
40
+ | `web/react/menu_button` | `MenuButton`: a ⋮ button that opens a menu under itself, even inside a link, saying through `aria-expanded` whether it is open. |
41
+ | `web/react/test_stubs` | Stands in for `showPopover`, `hidePopover`, `showModal` and `close`, which jsdom lacks; an app's widget tests import it for its side effects. |
41
42
  | `web/react/list_picker` | `pick_list_dialog`: adds one thing to any of the app's lists, such as a video to playlists, ticking the lists that hold it and making a new one. |
42
- | `web/react/controls`, `table` | Segmented choices, button groups, switches, search boxes that can take focus and follow a value cleared from outside, selects, `CommitInput`, `DateTimeField`, `ColorPicker`, `TreeSelect`; `DataTable` on TanStack Table, sorting by several columns, with columns to move, resize, hide and filter, tree rows, groups under headings, rows the app drags and styles, rows that come sorted from a database, rows worked by keyboard, rows that draw again only when they change, `virtual` rows drawn only where they show, a last column that fills the room left, group headings that hide when the filter empties them, and cards on phones; a pager. |
43
+ | `web/react/controls`, `table` | Segmented choices, button groups, switches, search boxes that hand on typing as it pauses, submit on Enter or a button, clear on Escape, can take a paste whole, take focus and follow a value cleared from outside, selects, `CommitInput`, `DateTimeField`, `ColorPicker`, `TreeSelect`; `DataTable` on TanStack Table, sorting by several columns, with columns to move, resize, hide and filter, tree rows, groups under headings, rows the app drags and styles, rows that come sorted from a database, rows worked by keyboard, rows that draw again only when they change, `virtual` rows drawn only where they show, a last column that fills the room left, group headings that hide when the filter empties them, and cards on phones; a pager. |
43
44
  | `web/react/shortcuts` | `use_shortcuts` for keys such as `"mod+z"`, quiet while typing or while a dialog or popover is open; `describe_keys` for help text. |
44
45
  | `web/react/tree` | `Tree`: nested rows that fold, walk with the arrow keys, reorder by dragging or with Alt and the arrow keys, show several picked, and take drops from outside. |
45
46
  | `web/react/toasts`, `dialog`, `menus`, `split` | Toasts with a link or Undo that say how they closed, and `undoable` actions that commit or undo exactly once; modal dialogs with a subtitle, header actions, a full-window size and a `form` whose submit button sends it, and confirm and text dialogs, openable from anywhere; context menus and popovers worked by keyboard, openable from anywhere, their keep-open items ticking at once; resize handles and splits. |
@@ -25,10 +25,14 @@ export declare function Switch({ label, checked, on_change, disabled }: SwitchPr
25
25
  interface SearchBoxProps {
26
26
  label: string;
27
27
  value: string;
28
- on_input: (value: string) => void;
28
+ on_input?: (value: string) => void;
29
+ on_submit?: (value: string) => void;
30
+ on_paste?: (text: string) => boolean;
31
+ submit_label?: string;
32
+ clear_on_submit?: boolean;
29
33
  auto_focus?: boolean;
30
34
  }
31
- export declare function SearchBox({ label, value, on_input, auto_focus }: SearchBoxProps): import("react").JSX.Element;
35
+ export declare function SearchBox({ label, value, on_input, on_submit, on_paste, submit_label, clear_on_submit, auto_focus }: SearchBoxProps): import("react").JSX.Element;
32
36
  interface SelectProps<Value extends string | number> {
33
37
  label: string;
34
38
  options: [value: Value, text: string][];
@@ -31,27 +31,76 @@ export function Switch({ label, checked, on_change, disabled = false }) {
31
31
  }
32
32
  return _jsx("input", { type: "checkbox", role: "switch", "aria-label": label, checked: checked, disabled: disabled || busy, onChange: change });
33
33
  }
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 }) {
34
+ // A search field that hands on what was typed once typing pauses and submits it on Enter; Escape clears it; 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, on_submit, on_paste, submit_label, clear_on_submit = false, auto_focus = false }) {
36
36
  const field = useRef(null);
37
37
  const timer = useRef(undefined);
38
38
  const handed_on = useRef(value);
39
+ const submitted = useRef(value);
39
40
  useEffect(() => () => clearTimeout(timer.current), []);
40
41
  useEffect(() => {
41
42
  if (value === handed_on.current || !field.current)
42
43
  return;
43
44
  clearTimeout(timer.current);
44
45
  handed_on.current = value;
46
+ submitted.current = value;
45
47
  field.current.value = value;
46
48
  }, [value]);
47
- return (_jsx("input", { ref: field, type: "search", className: "filter", placeholder: label, "aria-label": label, defaultValue: value, autoFocus: auto_focus, onChange: (event) => {
48
- clearTimeout(timer.current);
49
- const text = event.currentTarget.value;
50
- timer.current = window.setTimeout(() => {
51
- handed_on.current = text;
52
- on_input(text);
53
- }, 200);
54
- } }));
49
+ function hand_on(text) {
50
+ clearTimeout(timer.current);
51
+ if (text === handed_on.current)
52
+ return;
53
+ handed_on.current = text;
54
+ on_input?.(text);
55
+ }
56
+ function submit() {
57
+ const text = field.current?.value ?? "";
58
+ hand_on(text);
59
+ submitted.current = clear_on_submit ? "" : text;
60
+ on_submit?.(text);
61
+ if (clear_on_submit && field.current) {
62
+ field.current.value = "";
63
+ handed_on.current = "";
64
+ }
65
+ }
66
+ function clear() {
67
+ if (!field.current)
68
+ return;
69
+ field.current.value = "";
70
+ hand_on("");
71
+ if (submitted.current !== "") {
72
+ submitted.current = "";
73
+ on_submit?.("");
74
+ }
75
+ }
76
+ function typed(event) {
77
+ clearTimeout(timer.current);
78
+ const text = event.currentTarget.value;
79
+ // An emptied field goes straight back to everything, taking back what was submitted.
80
+ if (text === "")
81
+ return clear();
82
+ if (!on_input)
83
+ return;
84
+ timer.current = window.setTimeout(() => hand_on(text), 200);
85
+ }
86
+ function keyed(event) {
87
+ if (event.key === "Enter") {
88
+ event.preventDefault();
89
+ submit();
90
+ }
91
+ else if (event.key === "Escape" && event.currentTarget.value !== "") {
92
+ event.preventDefault();
93
+ clear();
94
+ }
95
+ }
96
+ function pasted(event) {
97
+ if (on_paste?.(event.clipboardData.getData("text")))
98
+ event.preventDefault();
99
+ }
100
+ const input = (_jsx("input", { ref: field, type: "search", className: "filter", placeholder: label, "aria-label": label, defaultValue: value, autoFocus: auto_focus, onChange: typed, onKeyDown: keyed, onPaste: on_paste && pasted }));
101
+ if (!submit_label)
102
+ return input;
103
+ return (_jsxs("form", { className: "search-box", role: "search", onSubmit: (event) => { event.preventDefault(); submit(); }, children: [input, _jsx("button", { type: "submit", className: "accent solid", children: submit_label })] }));
55
104
  }
56
105
  export function Select({ label, options, value, on_change, bare = false }) {
57
106
  const change = (event) => {
@@ -1,6 +1,16 @@
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
+ children?: ReactNode;
12
+ }
13
+ export declare function PageHeading({ title, subtitle, icon, banner, actions, class_name, children }: PageHeadingProps): import("react").JSX.Element;
4
14
  interface SectionProps {
5
15
  title: ReactNode;
6
16
  tools?: ReactNode;
@@ -1,7 +1,11 @@
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, children }) {
7
+ 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("h1", { children: title }), subtitle && _jsx("p", { className: "page-subtitle muted", children: subtitle })] })] }), actions && _jsx("div", { className: "head-actions", children: actions })] }), children] }));
8
+ }
5
9
  export function Section({ title, tools, class_name, on_context_menu, children }) {
6
10
  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
11
  }
@@ -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() {
@@ -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,25 @@ 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;
26
32
  children: (data: Data) => ReactNode;
27
- }): import("react").JSX.Element;
33
+ }
34
+ export declare function Loaded<Data>({ resource, lines, idle, children }: LoadedProps<Data>): import("react").JSX.Element;
35
+ export {};
@@ -14,35 +14,49 @@ export function Failure({ error, retry }) {
14
14
  }
15
15
  // Loads what the work fetches whenever the dependencies change, passing a lapsed login on to the page.
16
16
  export function use_resource(work, dependencies, options = {}) {
17
- const [state, set_state] = useState({ data: null, error: null, loading: true });
17
+ const enabled = options.enabled ?? true;
18
+ const [state, set_state] = useState({ data: null, error: null, status: enabled ? "loading" : "idle" });
18
19
  const latest_work = useRef(work);
19
20
  latest_work.current = work;
20
21
  const generation = useRef(0);
21
22
  const reload = useCallback((quiet = false) => {
22
23
  const mine = ++generation.current;
23
24
  if (!quiet)
24
- set_state({ data: null, error: null, loading: true });
25
+ set_state({ data: null, error: null, status: "loading" });
25
26
  return latest_work.current().then((data) => { if (mine === generation.current)
26
- set_state({ data, error: null, loading: false }); }, (error) => {
27
+ set_state({ data, error: null, status: "loaded" }); }, (error) => {
27
28
  if (pass_login_needed(error) || mine !== generation.current)
28
29
  return;
29
- set_state((before) => quiet && before.data !== null ? { ...before, loading: false } : { data: null, error, loading: false });
30
+ set_state((before) => quiet && before.data !== null ? before : { data: null, error, status: "failed" });
30
31
  });
31
32
  }, []);
32
- useEffect(() => void reload(), dependencies);
33
33
  useEffect(() => {
34
- if (!options.refresh_in_milliseconds)
34
+ if (enabled)
35
+ return void reload();
36
+ // Turning off drops the load under way and what it showed.
37
+ generation.current += 1;
38
+ set_state((before) => before.status === "idle" ? before : { data: null, error: null, status: "idle" });
39
+ }, [enabled, ...dependencies]);
40
+ useEffect(() => {
41
+ if (!options.refresh_in_milliseconds || !enabled)
35
42
  return;
36
43
  const timer = setInterval(() => { if (document.visibilityState === "visible")
37
44
  void reload(true); }, options.refresh_in_milliseconds);
38
45
  return () => clearInterval(timer);
39
- }, [options.refresh_in_milliseconds]);
40
- return { ...state, reload };
46
+ }, [options.refresh_in_milliseconds, enabled]);
47
+ useEffect(() => {
48
+ if (!options.watch || !enabled)
49
+ return;
50
+ return options.watch.subscribe(() => void reload(true));
51
+ }, [options.watch, enabled]);
52
+ return { ...state, loading: state.status === "loading", reload };
41
53
  }
42
54
  // 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 }) {
55
+ export function Loaded({ resource, lines, idle = null, children }) {
44
56
  if (resource.data !== null)
45
57
  return _jsx(_Fragment, { children: children(resource.data) });
58
+ if (resource.status === "idle")
59
+ return _jsx(_Fragment, { children: idle });
46
60
  if (resource.error !== null)
47
61
  return _jsx(Failure, { error: resource.error, retry: () => void resource.reload() });
48
62
  return _jsx(Skeleton, { lines: lines });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ // Stands in for the native popover and modal dialog, which jsdom does not draw; a test imports it for its side effects.
2
+ HTMLElement.prototype.showPopover = function () {
3
+ this.setAttribute("data-shown", "");
4
+ };
5
+ HTMLElement.prototype.hidePopover = function () {
6
+ this.removeAttribute("data-shown");
7
+ this.dispatchEvent(Object.assign(new Event("toggle"), { newState: "closed" }));
8
+ };
9
+ HTMLDialogElement.prototype.showModal = function () {
10
+ this.setAttribute("open", "");
11
+ };
12
+ HTMLDialogElement.prototype.close = function () {
13
+ this.removeAttribute("open");
14
+ };
15
+ // jsdom hides every popover, since none ever matches :popover-open, so a shown one is drawn again here.
16
+ const shown_popovers = document.createElement("style");
17
+ shown_popovers.textContent = "[popover][data-shown] { display: block; }";
18
+ document.head.append(shown_popovers);
19
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x-tech-laboratories/x-libs",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Code the X-apps share: agents and their long runs, passwords, durations, and the web page's helpers and widgets",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {