@x-tech-laboratories/x-libs 0.7.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
@@ -28,18 +28,22 @@ Code the X-apps share, published as one npm package, `@x-tech-laboratories/x-lib
28
28
  | `server/backup_files` | `file_backup_store`: the `backups` store as files in a folder on disk. |
29
29
  | `web/dom`, `web/format`, `web/router`, `web/theme` | The page's element builder, formatters (including `compact_count`, such as `"1.4M"`), hash router and theme choice, kept in this browser or the app's settings. |
30
30
  | `web/states`, `web/tables`, `web/menus`, `web/split` | `LoginNeeded`; sorting by several columns, trees and column order; menu items, placement and keys; pane sizing. |
31
- | `web/tree` | Rows with `parent_id` and `position`: nesting, subtrees, tree order, paths, and where a dropped or keyed row lands. |
31
+ | `web/tree` | Rows with `parent_id` and `position`: nesting, subtrees, tree order, paths, and where a dropped or keyed row lands, its siblings ordered by position or, with `by_list_order`, as the list gives them. |
32
32
  | `web/watch`, `web/react/watch` | `create_watch`: a change counter a store bumps; `use_watch` redraws only the components that read that store. |
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
- | `web/react/shell` | `Shell`: top bar, section links or the app's own sidebar, resizable, theme button, toasts, dialogs and menus, with or without logins, 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. |
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` | `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
- | `web/react/reveal` | `use_reveal`: a long list shown a page at a time, the next page added as its end scrolls into view. |
39
- | `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, and cards on phones; a pager. |
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
+ | `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, 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. |
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. |
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. |
40
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. |
41
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. |
42
- | `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, confirm and text dialogs openable from anywhere; context menus and popovers worked by keyboard, openable from anywhere; resize handles and splits. |
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. |
43
47
 
44
48
  ```ts
45
49
  import { AgentHub } from "@x-tech-laboratories/x-libs/server/agents/hub";
@@ -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,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,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
  }
@@ -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 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
+ }
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 {};
@@ -0,0 +1,16 @@
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";
4
+ import { open_context_menu } from "./menus.js";
5
+ export function MenuButton({ label, items, glyph = "⋮", class_name }) {
6
+ const [open, set_open] = useState(false);
7
+ function open_menu(event) {
8
+ // The button may sit inside a link, such as a whole card that opens its video.
9
+ event.preventDefault();
10
+ event.stopPropagation();
11
+ const box = event.currentTarget.getBoundingClientRect();
12
+ set_open(true);
13
+ open_context_menu({ clientX: box.left, clientY: box.bottom, preventDefault: () => { } }, typeof items === "function" ? items() : items, () => set_open(false));
14
+ }
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 }));
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;
@@ -92,13 +92,37 @@ function use_menu_keys(container, dismiss, whole_menu) {
92
92
  }
93
93
  };
94
94
  }
95
- // A menu's buttons, also for use inside a Popover, such as a column filter's choices.
95
+ // Which radio run each item is in: radio items side by side, broken by a separator or another item.
96
+ function radio_runs(items) {
97
+ let run = 0;
98
+ return items.map((item, index) => {
99
+ const previous = items[index - 1];
100
+ if (!item.radio || item.separator_before || !previous?.radio)
101
+ run += 1;
102
+ return run;
103
+ });
104
+ }
105
+ // A menu's buttons, also for use inside a Popover, such as a column filter's choices; an item that keeps the menu open ticks at once, until new items arrive.
96
106
  export function MenuList({ items, on_close }) {
97
- return (_jsx(_Fragment, { children: items.map((item) => (_jsxs(Fragment, { children: [item.separator_before && _jsx("div", { className: "menu-separator", role: "separator" }), _jsxs("button", { type: "button", className: item.danger ? "menu-item danger" : "menu-item", role: item.checked === undefined ? "menuitem" : item.radio ? "menuitemradio" : "menuitemcheckbox", "aria-checked": item.checked, "aria-disabled": item.disabled || undefined, tabIndex: -1, onClick: () => {
107
+ const [ticks, set_ticks] = useState({ items, checked: {} });
108
+ const checked_now = ticks.items === items ? ticks.checked : {};
109
+ const runs = radio_runs(items);
110
+ const is_checked = (item) => item.checked === undefined ? undefined : checked_now[item.label] ?? item.checked;
111
+ function tick(item, index) {
112
+ if (item.checked === undefined)
113
+ return;
114
+ const changes = item.radio
115
+ ? Object.fromEntries(items.filter((other, other_index) => other.radio && runs[other_index] === runs[index]).map((other) => [other.label, other === item]))
116
+ : { [item.label]: !is_checked(item) };
117
+ set_ticks({ items, checked: { ...checked_now, ...changes } });
118
+ }
119
+ return (_jsx(_Fragment, { children: items.map((item, index) => (_jsxs(Fragment, { children: [item.separator_before && _jsx("div", { className: "menu-separator", role: "separator" }), _jsxs("button", { type: "button", className: item.danger ? "menu-item danger" : "menu-item", role: item.checked === undefined ? "menuitem" : item.radio ? "menuitemradio" : "menuitemcheckbox", "aria-checked": is_checked(item), "aria-disabled": item.disabled || undefined, tabIndex: -1, onClick: () => {
98
120
  if (item.disabled)
99
121
  return;
100
122
  if (!item.keep_open)
101
123
  on_close();
124
+ else
125
+ tick(item, index);
102
126
  item.run();
103
127
  }, children: [_jsx("span", { className: "menu-label", children: item.label }), item.hint && _jsx("span", { className: "menu-hint", children: item.hint })] })] }, item.label))) }));
104
128
  }
@@ -115,14 +139,16 @@ let open_menu = null;
115
139
  let next_menu_id = 1;
116
140
  const menu_listeners = new Set();
117
141
  function publish_menu(menu) {
142
+ const before = open_menu;
118
143
  open_menu = menu;
119
144
  for (const listener of menu_listeners)
120
145
  listener(open_menu);
146
+ before?.on_close?.();
121
147
  }
122
- // Opens a menu where the event happened, such as a right-click, replacing any menu already open.
123
- export function open_context_menu(event, items) {
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) {
124
150
  event.preventDefault();
125
- 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 });
126
152
  }
127
153
  // Where menus opened from anywhere are drawn; the Shell holds one.
128
154
  export function ContextMenus() {
@@ -1,5 +1,6 @@
1
1
  export interface RevealOptions {
2
2
  root_margin?: string;
3
+ initial_count?: number;
3
4
  }
4
5
  export interface Reveal {
5
6
  shown_count: number;
@@ -2,7 +2,7 @@
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
  export function use_reveal(page_size, options = {}) {
4
4
  const root_margin = options.root_margin ?? "200px";
5
- const [shown_count, set_shown_count] = useState(page_size);
5
+ const [shown_count, set_shown_count] = useState(() => Math.max(page_size, options.initial_count ?? page_size));
6
6
  const observer = useRef(null);
7
7
  const marker = useRef(null);
8
8
  const more_ref = useCallback((element) => {
@@ -26,6 +26,7 @@ interface ShellProps {
26
26
  sidebar_width?: SidebarWidth;
27
27
  theme: ThemeChoice;
28
28
  user?: string | null;
29
+ logged_out?: boolean;
29
30
  user_href?: string;
30
31
  on_logout?: () => void;
31
32
  brand_href?: string;
@@ -34,8 +35,9 @@ interface ShellProps {
34
35
  main_class?: string;
35
36
  tools?: ReactNode;
36
37
  sidebar_foot?: ReactNode;
38
+ status_bar?: ReactNode;
37
39
  view_key?: string;
38
40
  children: ReactNode;
39
41
  }
40
- export declare function Shell({ brand, sections, active, sidebar: sidebar_content, sidebar_width, theme, user, user_href, on_logout, brand_href, tools, sidebar_foot, view_key, menu_open: held_menu_open, on_menu_open_change, main_class, children, }: ShellProps): import("react").JSX.Element;
42
+ export declare function Shell({ brand, sections, active, sidebar: sidebar_content, sidebar_width, theme, user, logged_out: held_logged_out, user_href, on_logout, brand_href, tools, sidebar_foot, status_bar, view_key, menu_open: held_menu_open, on_menu_open_change, main_class, children, }: ShellProps): import("react").JSX.Element;
41
43
  export {};
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // The frame around every view: the top bar, the sidebar that slides in on phones and resizes on wide screens, the theme button, and the toasts, dialogs and menus.
2
+ // The frame around every view: the top bar, the sidebar that slides in on phones and resizes on wide screens, the theme button, a status bar, and the toasts, dialogs and menus.
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import { current_route } from "../router.js";
5
5
  import { store_sizes, stored_sizes } from "../split.js";
@@ -41,14 +41,15 @@ function use_sidebar_width(spec) {
41
41
  };
42
42
  return [width, change];
43
43
  }
44
- export function Shell({ brand, sections = [], active, sidebar: sidebar_content, sidebar_width, theme, user, user_href, on_logout, brand_href = "#/", tools, sidebar_foot, view_key, menu_open: held_menu_open, on_menu_open_change, main_class, children, }) {
44
+ export function Shell({ brand, sections = [], active, sidebar: sidebar_content, sidebar_width, theme, user, logged_out: held_logged_out, user_href, on_logout, brand_href = "#/", tools, sidebar_foot, status_bar, view_key, menu_open: held_menu_open, on_menu_open_change, main_class, children, }) {
45
45
  const [own_menu_open, set_own_menu_open] = useState(false);
46
46
  const menu_open = held_menu_open ?? own_menu_open;
47
47
  const main = useRef(null);
48
48
  const menu_button = useRef(null);
49
49
  const sidebar = useRef(null);
50
50
  const first_view = useRef(true);
51
- const logged_out = user === null;
51
+ const logged_out = held_logged_out ?? user === null;
52
+ const user_name = typeof user === "string" && user !== "" ? user : undefined;
52
53
  const [width, change_width] = use_sidebar_width(sidebar_width);
53
54
  // Every opening and closing of the phone menu goes through here, so an app that holds it hears each one.
54
55
  function set_menu_open(open) {
@@ -63,7 +64,8 @@ export function Shell({ brand, sections = [], active, sidebar: sidebar_content,
63
64
  useEffect(() => {
64
65
  document.body.classList.toggle("logged-out", logged_out);
65
66
  document.body.classList.toggle("menu-open", menu_open);
66
- }, [logged_out, menu_open]);
67
+ document.body.classList.toggle("has-status-bar", status_bar !== undefined);
68
+ }, [logged_out, menu_open, status_bar !== undefined]);
67
69
  useEffect(() => {
68
70
  if (menu_open)
69
71
  sidebar.current?.querySelector("a[href], button:not(:disabled), input, select, textarea, [tabindex='0']")?.focus();
@@ -86,5 +88,5 @@ export function Shell({ brand, sections = [], active, sidebar: sidebar_content,
86
88
  first_view.current = false;
87
89
  window.scrollTo(0, 0);
88
90
  }, [view_key]);
89
- return (_jsxs(_Fragment, { children: [_jsx("a", { className: "skip", href: "#main", children: "Skip to content" }), _jsxs("header", { className: "topbar", children: [_jsx("button", { ref: menu_button, className: "icon menu-button", "aria-label": "Open the menu", "aria-expanded": menu_open, "aria-controls": "sidebar", onClick: () => set_menu_open(!menu_open), children: "\u2630" }), _jsx("a", { href: brand_href, className: "brand", children: brand }), !logged_out && tools, !logged_out && _jsx(ThemeButton, { choice: theme }), typeof user === "string" && (user_href ? _jsx("a", { className: "account-link", href: user_href, children: user }) : _jsx("span", { className: "user-name", children: user })), typeof user === "string" && on_logout && _jsx("button", { onClick: on_logout, children: "Log out" })] }), _jsxs("div", { className: "layout", style: width === undefined ? undefined : { "--sidebar-width": `${width}px` }, children: [_jsxs("nav", { ref: sidebar, id: "sidebar", className: "sidebar", "aria-label": "Main", children: [sidebar_content ?? sections.map((section) => (_jsx("a", { href: section.href, "data-section": section.id, className: section.id === active ? "active" : undefined, "aria-current": section.id === active ? "page" : undefined, children: section.label }, section.id))), sidebar_foot, sidebar_width && width !== undefined && (_jsx(ResizeHandle, { side: "left", size: width, min: sidebar_width.min, max: sidebar_width.max, default_size: sidebar_width.default_width, on_resize: (next) => change_width(next, false), on_resize_end: (next) => change_width(next, true), label: "Resize the sidebar", class_name: "sidebar-resize" }))] }), _jsx("div", { className: "scrim", hidden: !menu_open, onClick: () => set_menu_open(false) }), _jsx("main", { ref: main, id: "main", className: main_class, tabIndex: -1, children: children })] }), _jsx(Toasts, {}), _jsx(Dialogs, {}), _jsx(ContextMenus, {})] }));
91
+ return (_jsxs(_Fragment, { children: [_jsx("a", { className: "skip", href: "#main", children: "Skip to content" }), _jsxs("header", { className: "topbar", children: [_jsx("button", { ref: menu_button, className: "icon menu-button", "aria-label": "Open the menu", "aria-expanded": menu_open, "aria-controls": "sidebar", onClick: () => set_menu_open(!menu_open), children: "\u2630" }), _jsx("a", { href: brand_href, className: "brand", children: brand }), !logged_out && tools, !logged_out && _jsx(ThemeButton, { choice: theme }), user_name !== undefined && (user_href ? _jsx("a", { className: "account-link", href: user_href, children: user_name }) : _jsx("span", { className: "user-name", children: user_name })), user_name !== undefined && on_logout && _jsx("button", { onClick: on_logout, children: "Log out" })] }), _jsxs("div", { className: "layout", style: width === undefined ? undefined : { "--sidebar-width": `${width}px` }, children: [_jsxs("nav", { ref: sidebar, id: "sidebar", className: "sidebar", "aria-label": "Main", children: [sidebar_content !== undefined && sidebar_content !== null ? _jsx("div", { className: "sidebar-content", children: sidebar_content }) : sections.map((section) => (_jsx("a", { href: section.href, "data-section": section.id, className: section.id === active ? "active" : undefined, "aria-current": section.id === active ? "page" : undefined, children: section.label }, section.id))), sidebar_foot, sidebar_width && width !== undefined && (_jsx(ResizeHandle, { side: "left", size: width, min: sidebar_width.min, max: sidebar_width.max, default_size: sidebar_width.default_width, on_resize: (next) => change_width(next, false), on_resize_end: (next) => change_width(next, true), label: "Resize the sidebar", class_name: "sidebar-resize" }))] }), _jsx("div", { className: "scrim", hidden: !menu_open, onClick: () => set_menu_open(false) }), _jsx("main", { ref: main, id: "main", className: main_class, tabIndex: -1, children: children })] }), status_bar !== undefined && _jsx("footer", { className: "status-bar", children: status_bar }), _jsx(Toasts, {}), _jsx(Dialogs, {}), _jsx(ContextMenus, {})] }));
90
92
  }
@@ -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 });
@@ -24,12 +24,19 @@ export interface RowGroup<Row> {
24
24
  rows: Row[];
25
25
  class_name?: string;
26
26
  }
27
+ export interface TableVirtual {
28
+ row_height: number;
29
+ overscan?: number;
30
+ card_height?: number;
31
+ }
27
32
  interface DataTableProps<Row> {
28
33
  columns: TableColumn<Row>[];
29
34
  rows: Row[];
30
35
  groups?: RowGroup<Row>[];
31
36
  row_key?: (row: Row) => string | number;
32
37
  caption?: string;
38
+ virtual?: TableVirtual;
39
+ fill_last_column?: boolean;
33
40
  sorting?: Sorting | Sorting[];
34
41
  manual_sorting?: boolean;
35
42
  on_sort?: (sorting: Sorting) => void;
@@ -1,10 +1,12 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // A React table drawn like the page's own on TanStack Table: sorting by one column or several, columns the user moves, resizes,
3
- // hides and filters, rows nested under rows, rows in groups under headings, rows dragged by the app's rules, and cards on phones.
4
- import { Fragment, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ // hides and filters, rows nested under rows, rows in groups under headings, rows dragged by the app's rules, long lists drawn
4
+ // only where they show, and cards on phones.
5
+ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
5
6
  import { getCoreRowModel, getExpandedRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
6
7
  import { clamp_width, column_id, compare_values, move_column, ordered_columns, sort_after_click, sorting_list } from "../tables.js";
7
8
  import { placement_at } from "../tree.js";
9
+ import { virtual_window } from "../virtual.js";
8
10
  import { ContextMenu, Popover } from "./menus.js";
9
11
  import { ResizeHandle } from "./split.js";
10
12
  const is_empty = (value) => value === null || value === undefined || value === "";
@@ -16,9 +18,47 @@ function filtered_tree(rows, passes, get_children, kept_children) {
16
18
  return passes(row) || children.length > 0;
17
19
  });
18
20
  }
21
+ function TableRowView(props) {
22
+ const { original, id, columns, events } = props;
23
+ const after = props.after;
24
+ return (_jsxs(_Fragment, { children: [_jsx("tr", { className: props.class_name, title: props.title, style: props.style, "data-row-key": props.keyed ? props.row_key : undefined, tabIndex: props.tab_index, "aria-selected": props.selected, draggable: props.draggable || undefined, onDragStart: props.draggable ? (event) => events.current.drag_start(original, id, event) : undefined, onDragEnd: props.draggable ? () => events.current.drag_end() : undefined, onDragOver: (event) => events.current.drag_over(original, id, event), onDragLeave: () => events.current.drag_leave(id), onDrop: (event) => events.current.drop(original, id, event), onClick: (event) => events.current.click(original, event), onDoubleClick: (event) => events.current.double_click(original, event), onKeyDown: (event) => events.current.key_down(original, event), onContextMenu: (event) => events.current.context_menu(original, event), children: columns.map((column, index) => {
25
+ const label = column.label;
26
+ if (!props.tree || index > 0)
27
+ return _jsx("td", { className: column.class, "data-label": label, children: column.cell(original) }, column_id(column));
28
+ const count = props.child_count;
29
+ return (_jsxs("td", { className: [column.class, "tree-cell"].filter(Boolean).join(" "), "data-label": label, style: { "--tree-depth": props.depth }, children: [count > 0 ? (_jsx("button", { type: "button", className: "icon tree-toggle", "aria-expanded": props.open, "aria-label": props.open ? "Collapse" : "Expand", onClick: (event) => { event.stopPropagation(); events.current.toggle(id, props.open); }, children: props.open ? "▾" : "▸" })) : _jsx("span", { className: "tree-spacer" }), column.cell(original), count > 0 && !props.open && _jsx("span", { className: "tree-count", title: `${count} inside`, children: count })] }, column_id(column)));
30
+ }) }), after !== undefined && after !== null && typeof after !== "boolean" && _jsx("tr", { className: "row-extra", children: _jsx("td", { colSpan: columns.length, children: after }) })] }));
31
+ }
32
+ function same_style(first, second) {
33
+ if (first === second)
34
+ return true;
35
+ if (!first || !second)
36
+ return false;
37
+ const keys = Object.keys(first);
38
+ return keys.length === Object.keys(second).length && keys.every((key) => first[key] === second[key]);
39
+ }
40
+ function same_row(before, after) {
41
+ return Object.keys(after).every((key) => key === "style" ? same_style(before.style, after.style) : before[key] === after[key]);
42
+ }
43
+ // A row draws again only when something it shows changes, so picking one row leaves the others alone.
44
+ const TableRow = memo(TableRowView, same_row);
45
+ // Whether the page is phone-sized, where the rows are cards.
46
+ function use_phone() {
47
+ const query = "(max-width: 640px)";
48
+ const [phone, set_phone] = useState(() => typeof window.matchMedia === "function" && window.matchMedia(query).matches);
49
+ useEffect(() => {
50
+ if (typeof window.matchMedia !== "function")
51
+ return;
52
+ const list = window.matchMedia(query);
53
+ const changed = () => set_phone(list.matches);
54
+ list.addEventListener("change", changed);
55
+ return () => list.removeEventListener("change", changed);
56
+ }, []);
57
+ return phone;
58
+ }
19
59
  // ── Table ────────────────────────────────────────────────────────────────────
20
60
  export function DataTable(props) {
21
- const { columns, row_key, caption, get_children, row_passes, groups, allow_inside_drop = true } = props;
61
+ const { columns, row_key, caption, get_children, row_passes, groups, allow_inside_drop = true, fill_last_column: fill_last = false } = props;
22
62
  const rows = useMemo(() => groups ? groups.flatMap((group) => group.rows) : props.rows, [groups, props.rows]);
23
63
  const [own_expanded, set_own_expanded] = useState(true);
24
64
  const [open_filter, set_open_filter] = useState(null);
@@ -79,12 +119,13 @@ export function DataTable(props) {
79
119
  getRowCanExpand: (row) => row.subRows.length > 0,
80
120
  manualSorting: manual,
81
121
  });
82
- const shown = table.getVisibleLeafColumns().map((column) => by_id.get(column.id));
83
- function toggle_row(row) {
122
+ const shown_ids = table.getVisibleLeafColumns().map((column) => column.id).join("\n");
123
+ const shown = useMemo(() => shown_ids.split("\n").filter(Boolean).map((id) => by_id.get(id)), [shown_ids, by_id]);
124
+ function toggle_row(id, is_open) {
84
125
  const open = expanded === true
85
126
  ? table.getCoreRowModel().flatRows.filter((each) => each.subRows.length > 0).map((each) => each.id)
86
127
  : expanded;
87
- const next = row.getIsExpanded() ? open.filter((id) => id !== row.id) : [...open, row.id];
128
+ const next = is_open ? open.filter((each) => each !== id) : [...open, id];
88
129
  if (props.expanded === undefined)
89
130
  set_own_expanded(next);
90
131
  props.on_expanded_change?.(next);
@@ -104,7 +145,7 @@ export function DataTable(props) {
104
145
  return;
105
146
  const sizes = Object.fromEntries([...header_cells.current].map(([id, cell]) => [id, cell.offsetWidth]));
106
147
  set_measured((before) => JSON.stringify(before) === JSON.stringify(sizes) ? before : sizes);
107
- }, [shown.map(column_id).join("\n"), props.on_column_widths_change]);
148
+ }, [shown_ids, props.on_column_widths_change]);
108
149
  function resize(column, width, done) {
109
150
  props.on_column_widths_change?.({ ...widths, [column_id(column)]: width }, done);
110
151
  }
@@ -177,26 +218,26 @@ export function DataTable(props) {
177
218
  return null;
178
219
  const box = event.currentTarget.getBoundingClientRect();
179
220
  const placement = placement_at(box.height > 0 ? (event.clientY - box.top) / box.height : 0.5, allow_inside_drop);
180
- const allowed = props.can_drop_on_row ? props.can_drop_on_row(row.original, placement) : row_dragging;
221
+ const allowed = props.can_drop_on_row ? props.can_drop_on_row(row, placement) : row_dragging;
181
222
  return allowed ? placement : null;
182
223
  }
183
- function row_drag_start(row, event) {
224
+ function row_drag_start(row, id, event) {
184
225
  set_row_dragging(true);
185
- props.on_row_drag_start(row.original, event);
226
+ props.on_row_drag_start(row, event);
186
227
  // Some browsers start a drag only once it carries data.
187
228
  if (event.dataTransfer && event.dataTransfer.types.length === 0)
188
- event.dataTransfer.setData("text/plain", row.id);
229
+ event.dataTransfer.setData("text/plain", id);
189
230
  }
190
- function row_drag_over(row, event) {
231
+ function row_drag_over(row, id, event) {
191
232
  const placement = row_placement(row, event);
192
233
  if (!placement) {
193
- if (row_drop?.id === row.id)
234
+ if (row_drop?.id === id)
194
235
  set_row_drop(null);
195
236
  return;
196
237
  }
197
238
  event.preventDefault();
198
- if (row_drop?.id !== row.id || row_drop.placement !== placement)
199
- set_row_drop({ id: row.id, placement });
239
+ if (row_drop?.id !== id || row_drop.placement !== placement)
240
+ set_row_drop({ id, placement });
200
241
  }
201
242
  function row_drag_drop(row, event) {
202
243
  const placement = row_placement(row, event);
@@ -204,20 +245,38 @@ export function DataTable(props) {
204
245
  if (!placement)
205
246
  return;
206
247
  event.preventDefault();
207
- props.on_row_drop(row.original, placement, event);
248
+ props.on_row_drop(row, placement, event);
208
249
  }
209
250
  function row_drag_end() {
210
251
  set_row_dragging(false);
211
252
  set_row_drop(null);
212
253
  props.on_row_drag_end?.();
213
254
  }
255
+ // The rows read these through a ref, so a new handler from the app never draws every row again.
256
+ const events = useRef(null);
257
+ events.current = {
258
+ click: (row, event) => props.on_row_click?.(row, event),
259
+ double_click: (row, event) => props.on_row_double_click?.(row, event),
260
+ context_menu: (row, event) => props.on_row_context_menu?.(row, event),
261
+ key_down: (row, event) => props.on_row_key_down?.(row, event),
262
+ toggle: toggle_row,
263
+ drag_start: row_drag_start,
264
+ drag_end: row_drag_end,
265
+ drag_over: (row, id, event) => { if (props.on_row_drop)
266
+ row_drag_over(row, id, event); },
267
+ drag_leave: (id) => { if (props.on_row_drop && row_drop?.id === id)
268
+ set_row_drop(null); },
269
+ drop: (row, _id, event) => { if (props.on_row_drop)
270
+ row_drag_drop(row, event); },
271
+ };
214
272
  // ── Drawing ──
215
273
  function header(column) {
216
274
  const id = column_id(column);
217
275
  const index = sorting.findIndex((key) => key.key === id);
218
276
  const key = sorting[index];
219
277
  const sortable = is_sortable(column) && Boolean(props.on_sort || props.on_sorting_change);
220
- const width = width_of(column);
278
+ const filling = fill_last && column === shown[shown.length - 1];
279
+ const width = filling ? undefined : width_of(column);
221
280
  const movable = can_move(column);
222
281
  const class_name = [
223
282
  column.class, column.filter_active && "filtered", dragged === id && "dragged", drop_target?.id === id && (drop_target.after ? "drop-after" : "drop-before"),
@@ -226,32 +285,21 @@ export function DataTable(props) {
226
285
  return (_jsxs("th", { ref: (cell) => { if (cell)
227
286
  header_cells.current.set(id, cell);
228
287
  else
229
- header_cells.current.delete(id); }, className: class_name, scope: "col", style: width === undefined ? undefined : { width, minWidth: width, maxWidth: width }, "aria-sort": sortable ? (key ? (key.descending ? "descending" : "ascending") : "none") : undefined, draggable: movable || undefined, title: movable ? "Drag to move this column" : undefined, onPointerDownCapture: (event) => { resizing.current = event.target.classList.contains("resize-handle"); }, onDragStart: (event) => {
288
+ header_cells.current.delete(id); }, className: class_name, scope: "col", style: width !== undefined ? { width, minWidth: width, maxWidth: width } : filling && column.min_width !== undefined ? { minWidth: column.min_width } : undefined, "aria-sort": sortable ? (key ? (key.descending ? "descending" : "ascending") : "none") : undefined, draggable: movable || undefined, title: movable ? "Drag to move this column" : undefined, onPointerDownCapture: (event) => { resizing.current = event.target.classList.contains("resize-handle"); }, onDragStart: (event) => {
230
289
  if (resizing.current)
231
290
  return event.preventDefault();
232
291
  event.dataTransfer.setData("text/plain", id);
233
292
  set_dragged(id);
234
- }, onDragEnd: () => { set_dragged(null); set_drop_target(null); }, onDragOver: (event) => drag_over(column, event), onDragLeave: () => set_drop_target(null), onDrop: drop, children: [sortable ? (_jsxs("button", { type: "button", className: "sort", title: props.on_sorting_change ? "Click to sort by this column, Shift+click to add it to the sort" : undefined, onClick: (event) => sort_by(id, event.shiftKey), onKeyDown: (event) => move_by_key(column, event), children: [column.label, marker] })) : movable ? (_jsx("span", { tabIndex: 0, onKeyDown: (event) => move_by_key(column, event), children: column.label })) : column.label, column.filter !== undefined && (_jsx("button", { type: "button", className: column.filter_active ? "icon filter-button active" : "icon filter-button", "aria-label": `Filter by ${column.label}`, "aria-expanded": open_filter?.id === id, title: column.filter_active ? `Filtered by ${column.label}` : `Filter by ${column.label}`, onClick: (event) => toggle_filter(id, event.currentTarget), children: _jsx("svg", { viewBox: "0 0 16 16", width: "12", height: "12", "aria-hidden": "true", children: _jsx("path", { d: "M2 3h12l-4.5 5.5V13l-3 1.5v-6z", fill: "currentColor" }) }) })), props.on_column_widths_change && (_jsx(ResizeHandle, { side: "left", size: width ?? measured[id] ?? column.min_width ?? 48, min: column.min_width ?? 48, max: column.max_width ?? 960, default_size: clamp_width(column.width ?? measured[id] ?? 120, column.min_width, column.max_width), on_resize: (size) => resize(column, size, false), on_resize_end: (size) => resize(column, size, true), label: `Resize the ${column.label} column`, class_name: "column-resize" }))] }, id));
293
+ }, onDragEnd: () => { set_dragged(null); set_drop_target(null); }, onDragOver: (event) => drag_over(column, event), onDragLeave: () => set_drop_target(null), onDrop: drop, children: [sortable ? (_jsxs("button", { type: "button", className: "sort", title: props.on_sorting_change ? "Click to sort by this column, Shift+click to add it to the sort" : undefined, onClick: (event) => sort_by(id, event.shiftKey), onKeyDown: (event) => move_by_key(column, event), children: [column.label, marker] })) : movable ? (_jsx("span", { tabIndex: 0, onKeyDown: (event) => move_by_key(column, event), children: column.label })) : column.label, column.filter !== undefined && (_jsx("button", { type: "button", className: column.filter_active ? "icon filter-button active" : "icon filter-button", "aria-label": `Filter by ${column.label}`, "aria-expanded": open_filter?.id === id, title: column.filter_active ? `Filtered by ${column.label}` : `Filter by ${column.label}`, onClick: (event) => toggle_filter(id, event.currentTarget), children: _jsx("svg", { viewBox: "0 0 16 16", width: "12", height: "12", "aria-hidden": "true", children: _jsx("path", { d: "M2 3h12l-4.5 5.5V13l-3 1.5v-6z", fill: "currentColor" }) }) })), props.on_column_widths_change && !filling && (_jsx(ResizeHandle, { side: "left", size: width ?? measured[id] ?? column.min_width ?? 48, min: column.min_width ?? 48, max: column.max_width ?? 960, default_size: clamp_width(column.width ?? measured[id] ?? 120, column.min_width, column.max_width), on_resize: (size) => resize(column, size, false), on_resize_end: (size) => resize(column, size, true), label: `Resize the ${column.label} column`, class_name: "column-resize" }))] }, id));
235
294
  }
236
- function body_row(row) {
295
+ function body_row(row, key) {
237
296
  const original = row.original;
238
- const row_class = [props.row_class?.(original), row_drop?.id === row.id && `drop-${row_drop.placement}`].filter(Boolean).join(" ") || undefined;
239
- const after = props.render_after_row?.(original);
240
- const draggable = Boolean(props.on_row_drag_start);
241
297
  const keyed = props.on_row_key_down !== undefined;
242
- const key = row_key ? String(row_key(original)) : row.id;
243
- return (_jsxs(Fragment, { children: [_jsx("tr", { className: row_class, title: props.row_title?.(original), style: props.row_style?.(original), "data-row-key": keyed ? key : undefined, tabIndex: keyed ? (props.active_row !== undefined && String(props.active_row) === key ? 0 : -1) : undefined, "aria-selected": props.row_selected ? props.row_selected(original) : undefined, draggable: draggable || undefined, onDragStart: draggable ? (event) => row_drag_start(row, event) : undefined, onDragEnd: draggable ? row_drag_end : undefined, onDragOver: props.on_row_drop && ((event) => row_drag_over(row, event)), onDragLeave: props.on_row_drop && (() => { if (row_drop?.id === row.id)
244
- set_row_drop(null); }), onDrop: props.on_row_drop && ((event) => row_drag_drop(row, event)), onClick: props.on_row_click && ((event) => props.on_row_click(original, event)), onDoubleClick: props.on_row_double_click && ((event) => props.on_row_double_click(original, event)), onKeyDown: props.on_row_key_down && ((event) => props.on_row_key_down(original, event)), onContextMenu: props.on_row_context_menu && ((event) => props.on_row_context_menu(original, event)), children: shown.map((column, index) => {
245
- const label = column.label;
246
- if (!tree || index > 0)
247
- return _jsx("td", { className: column.class, "data-label": label, children: column.cell(original) }, column_id(column));
248
- const open = row.getIsExpanded();
249
- const count = row.subRows.length;
250
- return (_jsxs("td", { className: [column.class, "tree-cell"].filter(Boolean).join(" "), "data-label": label, style: { "--tree-depth": row.depth }, children: [count > 0 ? (_jsx("button", { type: "button", className: "icon tree-toggle", "aria-expanded": open, "aria-label": open ? "Collapse" : "Expand", onClick: (event) => { event.stopPropagation(); toggle_row(row); }, children: open ? "▾" : "▸" })) : _jsx("span", { className: "tree-spacer" }), column.cell(original), count > 0 && !open && _jsx("span", { className: "tree-count", title: `${count} inside`, children: count })] }, column_id(column)));
251
- }) }), after !== undefined && after !== null && typeof after !== "boolean" && _jsx("tr", { className: "row-extra", children: _jsx("td", { colSpan: shown.length, children: after }) })] }, row.id));
298
+ return (_jsx(TableRow, { original: original, id: row.id, row_key: key, depth: row.depth, open: row.getIsExpanded(), child_count: row.subRows.length, columns: shown, tree: tree, class_name: [props.row_class?.(original), row_drop?.id === row.id && `drop-${row_drop.placement}`].filter(Boolean).join(" ") || undefined, title: props.row_title?.(original), style: props.row_style?.(original), selected: props.row_selected?.(original), tab_index: keyed ? (props.active_row !== undefined && String(props.active_row) === key ? 0 : -1) : undefined, keyed: keyed, draggable: Boolean(props.on_row_drag_start), after: props.render_after_row?.(original), events: events }, row.id));
252
299
  }
300
+ const row_line = (row) => ({ kind: "row", row, key: row_key ? String(row_key(row.original)) : row.id });
253
301
  // Each group's heading and then its rows, which keep the order of the sorted whole, so each group is sorted on its own.
254
- function grouped_body(groups) {
302
+ function grouped_lines(groups) {
255
303
  const group_of = new Map();
256
304
  groups.forEach((group, index) => group.rows.forEach((row) => { if (!group_of.has(row))
257
305
  group_of.set(row, index); }));
@@ -262,12 +310,81 @@ export function DataTable(props) {
262
310
  current = group_of.get(row.original) ?? current;
263
311
  rows_by_group[current].push(row);
264
312
  }
265
- return groups.map((group, index) => (_jsxs(Fragment, { children: [_jsx("tr", { className: group.class_name ? `group-row ${group.class_name}` : "group-row", children: _jsx("th", { scope: "colgroup", colSpan: shown.length, children: group.label }) }), rows_by_group[index].map(body_row)] }, group.key)));
313
+ // A group the filter emptied loses its heading, while one that was empty all along keeps it.
314
+ return groups.flatMap((group, index) => rows_by_group[index].length === 0 && group.rows.length > 0
315
+ ? []
316
+ : [{ kind: "group", group }, ...rows_by_group[index].map(row_line)]);
317
+ }
318
+ const lines = groups ? grouped_lines(groups) : table.getRowModel().rows.map(row_line);
319
+ // ── Virtual rows ──
320
+ const phone = use_phone();
321
+ const line_height = props.virtual ? (phone ? props.virtual.card_height : props.virtual.row_height) : undefined;
322
+ const wrap = useRef(null);
323
+ const head = useRef(null);
324
+ const [view, set_view] = useState({ top: 0, height: 0 });
325
+ const rows_focused = useRef(false);
326
+ const measure = () => {
327
+ const element = wrap.current;
328
+ if (element)
329
+ set_view({ top: element.scrollTop, height: element.clientHeight });
330
+ };
331
+ useEffect(() => {
332
+ if (line_height === undefined || typeof ResizeObserver === "undefined")
333
+ return;
334
+ const observer = new ResizeObserver(measure);
335
+ observer.observe(wrap.current);
336
+ return () => observer.disconnect();
337
+ }, [line_height === undefined]);
338
+ const head_height = head.current?.offsetHeight ?? 0;
339
+ const viewport = view.height || window.innerHeight;
340
+ const windowed = line_height === undefined ? undefined : virtual_window(lines.length, line_height, view.top - head_height, Math.max(line_height, viewport - head_height), props.virtual.overscan);
341
+ const active_index = props.active_row === undefined ? -1 : lines.findIndex((line) => line.kind === "row" && line.key === String(props.active_row));
342
+ // A row that moves the active row by key hands focus on to it once it is drawn.
343
+ const focus_pending = useRef(false);
344
+ useLayoutEffect(() => { focus_pending.current = line_height !== undefined && rows_focused.current; }, [props.active_row]);
345
+ // The active row scrolls into view as the keys move it.
346
+ useLayoutEffect(() => {
347
+ if (line_height === undefined || active_index < 0)
348
+ return;
349
+ const element = wrap.current;
350
+ const row_top = head_height + active_index * line_height;
351
+ const top = view.top;
352
+ const next = row_top < top + head_height ? row_top - head_height : row_top + line_height > top + viewport ? row_top + line_height - viewport : top;
353
+ if (next !== top) {
354
+ element.scrollTop = next;
355
+ set_view({ top: next, height: view.height });
356
+ }
357
+ }, [props.active_row, active_index, line_height]);
358
+ useLayoutEffect(() => {
359
+ if (!focus_pending.current || props.active_row === undefined)
360
+ return;
361
+ const active = String(props.active_row);
362
+ const row = [...wrap.current.querySelectorAll("tr[data-row-key]")].find((each) => each.dataset.rowKey === active);
363
+ if (!row)
364
+ return;
365
+ focus_pending.current = false;
366
+ const focused = document.activeElement;
367
+ if (focused !== row && (focused === null || focused === document.body || wrap.current.contains(focused)))
368
+ row.focus({ preventScroll: true });
369
+ });
370
+ function spacer(height, key) {
371
+ if (height <= 0)
372
+ return null;
373
+ return _jsx("tr", { className: "virtual-spacer", "aria-hidden": "true", style: { height }, children: _jsx("td", { colSpan: shown.length, style: { padding: 0, border: "none" } }) }, key);
374
+ }
375
+ function draw_line(line) {
376
+ if (line.kind === "row")
377
+ return body_row(line.row, line.key);
378
+ const group = line.group;
379
+ return (_jsx("tr", { className: group.class_name ? `group-row ${group.class_name}` : "group-row", children: _jsx("th", { scope: "colgroup", colSpan: shown.length, children: group.label }) }, `group ${group.key}`));
266
380
  }
381
+ const drawn = windowed ? lines.slice(windowed.start, windowed.end) : lines;
382
+ const fills_by_fixed = fill_last && shown.slice(0, -1).every((column) => width_of(column) !== undefined);
267
383
  const filtered_column = open_filter ? by_id.get(open_filter.id) : undefined;
268
384
  const filter_body = filtered_column?.filter;
269
385
  const tools = (props.on_clear_filters && any_filter) || props.on_hidden_columns_change;
270
- 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", { className: "table-wrap", children: _jsxs("table", { className: "responsive", children: [caption && _jsx("caption", { className: "visually-hidden", children: caption }), _jsx("thead", { onContextMenu: props.on_hidden_columns_change && open_columns_menu, children: _jsx("tr", { children: shown.map(header) }) }), _jsx("tbody", { children: groups ? grouped_body(groups) : table.getRowModel().rows.map(body_row) })] }) }), 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) })] }));
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
+ 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) })] }));
271
388
  }
272
389
  // ── Pages ────────────────────────────────────────────────────────────────────
273
390
  // Newer and older buttons around the page number, or nothing when everything fits on one page.
@@ -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 {};
@@ -13,6 +13,9 @@ export interface TreeMove {
13
13
  parent_id: string | null;
14
14
  position: number;
15
15
  }
16
+ export interface DropOptions {
17
+ by_list_order?: boolean;
18
+ }
16
19
  export declare function by_position<Item extends {
17
20
  position: number;
18
21
  }>(first: Item, second: Item): number;
@@ -26,9 +29,9 @@ export declare function can_drop(dragged_id: string, target_id: string, items: T
26
29
  export declare function position_for_drop<Item extends {
27
30
  id: string;
28
31
  position: number;
29
- }>(dragged: Item, target: Item, placement: SidePlacement, items: Item[]): number;
30
- export declare function move_for_drop<Item extends TreeItem>(dragged: Item, target: Item, placement: DropPlacement, items: Item[]): TreeMove;
31
- export declare function moves_for_drop<Item extends TreeItem>(dragged: Item[], target: Item, placement: DropPlacement, items: Item[]): TreeMove[];
32
+ }>(dragged: Item, target: Item, placement: SidePlacement, items: Item[], options?: DropOptions): number;
33
+ export declare function move_for_drop<Item extends TreeItem>(dragged: Item, target: Item, placement: DropPlacement, items: Item[], options?: DropOptions): TreeMove;
34
+ export declare function moves_for_drop<Item extends TreeItem>(dragged: Item[], target: Item, placement: DropPlacement, items: Item[], options?: DropOptions): TreeMove[];
32
35
  export type KeyMove = "up" | "down" | "indent" | "outdent";
33
36
  export declare function key_drop<Item extends TreeItem>(item: Item, direction: KeyMove, items: Item[]): {
34
37
  target: Item;
package/dist/web/tree.js CHANGED
@@ -71,8 +71,8 @@ export function can_drop(dragged_id, target_id, items) {
71
71
  return !subtree_ids(dragged_id, items).has(target_id);
72
72
  }
73
73
  // Picks a position between the neighbours at the drop point, so only the moved item changes.
74
- function position_beside(siblings, target, placement) {
75
- const sorted = [...siblings].sort(by_position);
74
+ function position_beside(siblings, target, placement, options = {}) {
75
+ const sorted = options.by_list_order ? siblings : [...siblings].sort(by_position);
76
76
  const index = sorted.findIndex((sibling) => sibling.id === target.id);
77
77
  const neighbour = sorted[placement === "before" ? index - 1 : index + 1];
78
78
  if (!neighbour)
@@ -80,27 +80,29 @@ function position_beside(siblings, target, placement) {
80
80
  return (target.position + neighbour.position) / 2;
81
81
  }
82
82
  // For a flat list, where items only move before or after each other.
83
- export function position_for_drop(dragged, target, placement, items) {
84
- return position_beside(items.filter((item) => item.id !== dragged.id), target, placement);
83
+ export function position_for_drop(dragged, target, placement, items, options = {}) {
84
+ return position_beside(items.filter((item) => item.id !== dragged.id), target, placement, options);
85
85
  }
86
86
  // Inside drops after the target's last child; before and after drop among the target's siblings.
87
- export function move_for_drop(dragged, target, placement, items) {
87
+ export function move_for_drop(dragged, target, placement, items, options = {}) {
88
88
  const others = items.filter((item) => item.id !== dragged.id);
89
89
  if (placement === "inside") {
90
90
  const last_child_position = Math.max(target.position, ...children_of(target.id, others).map((item) => item.position));
91
91
  return { parent_id: target.id, position: last_child_position + 1 };
92
92
  }
93
- return { parent_id: target.parent_id, position: position_beside(children_of(target.parent_id, others), target, placement) };
93
+ return { parent_id: target.parent_id, position: position_beside(children_of(target.parent_id, others), target, placement, options) };
94
94
  }
95
95
  // Moves several items together, keeping their order: inside the target after its last child, or in a run between it and its neighbour.
96
- export function moves_for_drop(dragged, target, placement, items) {
96
+ export function moves_for_drop(dragged, target, placement, items, options = {}) {
97
97
  const dragged_ids = new Set(dragged.map((item) => item.id));
98
98
  const others = items.filter((item) => !dragged_ids.has(item.id));
99
99
  if (placement === "inside") {
100
100
  const last_child_position = Math.max(target.position, ...children_of(target.id, others).map((item) => item.position));
101
101
  return dragged.map((_, index) => ({ parent_id: target.id, position: last_child_position + index + 1 }));
102
102
  }
103
- const siblings = children_of(target.parent_id, others).sort(by_position);
103
+ const siblings = children_of(target.parent_id, others);
104
+ if (!options.by_list_order)
105
+ siblings.sort(by_position);
104
106
  const index = siblings.findIndex((sibling) => sibling.id === target.id);
105
107
  const neighbour = siblings[placement === "before" ? index - 1 : index + 1];
106
108
  const low = placement === "before" ? (neighbour?.position ?? target.position - 1) : target.position;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x-tech-laboratories/x-libs",
3
- "version": "0.7.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": {