@x-tech-laboratories/x-libs 0.14.1 → 0.15.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 +1 -1
- package/dist/agent/client.d.ts +32 -0
- package/dist/agent/client.js +148 -0
- package/dist/agent/commands.d.ts +23 -0
- package/dist/agent/commands.js +118 -0
- package/dist/agent/keys.d.ts +14 -0
- package/dist/agent/keys.js +81 -0
- package/dist/agent/long_runs.d.ts +32 -0
- package/dist/agent/long_runs.js +316 -0
- package/dist/agent/protocol.d.ts +148 -0
- package/dist/agent/protocol.js +131 -0
- package/dist/agent/session.d.ts +50 -0
- package/dist/agent/session.js +225 -0
- package/dist/agent/tasks.d.ts +17 -0
- package/dist/agent/tasks.js +15 -0
- package/dist/backups.d.ts +31 -0
- package/dist/backups.js +43 -0
- package/dist/dates.d.ts +9 -0
- package/dist/dates.js +60 -0
- package/dist/durations.d.ts +11 -0
- package/dist/durations.js +49 -0
- package/dist/electron/app.d.ts +13 -0
- package/dist/electron/app.js +27 -0
- package/dist/electron/bridge.d.ts +55 -0
- package/dist/electron/bridge.js +105 -0
- package/dist/electron/events.d.ts +21 -0
- package/dist/electron/events.js +25 -0
- package/dist/electron/fetch.d.ts +52 -0
- package/dist/electron/fetch.js +175 -0
- package/dist/electron/window.d.ts +96 -0
- package/dist/electron/window.js +128 -0
- package/dist/log.d.ts +30 -0
- package/dist/log.js +79 -0
- package/dist/server/agents/agent.d.ts +27 -0
- package/dist/server/agents/agent.js +16 -0
- package/dist/server/agents/connection.d.ts +51 -0
- package/dist/server/agents/connection.js +239 -0
- package/dist/server/agents/hub.d.ts +86 -0
- package/dist/server/agents/hub.js +241 -0
- package/dist/server/agents/local.d.ts +24 -0
- package/dist/server/agents/local.js +62 -0
- package/dist/server/agents/route.d.ts +3 -0
- package/dist/server/agents/route.js +24 -0
- package/dist/server/backup_files.d.ts +3 -0
- package/dist/server/backup_files.js +25 -0
- package/dist/server/config.d.ts +39 -0
- package/dist/server/config.js +109 -0
- package/dist/server/cors.d.ts +8 -0
- package/dist/server/cors.js +13 -0
- package/dist/server/events.d.ts +33 -0
- package/dist/server/events.js +68 -0
- package/dist/server/hash_password.d.ts +1 -0
- package/dist/server/hash_password.js +46 -0
- package/dist/server/login_page.d.ts +8 -0
- package/dist/server/login_page.js +79 -0
- package/dist/server/passwords.d.ts +8 -0
- package/dist/server/passwords.js +59 -0
- package/dist/server/replies.d.ts +12 -0
- package/dist/server/replies.js +41 -0
- package/dist/server/sign_in.d.ts +27 -0
- package/dist/server/sign_in.js +95 -0
- package/dist/server/web_folder.d.ts +11 -0
- package/dist/server/web_folder.js +40 -0
- package/dist/web/dom.d.ts +7 -0
- package/dist/web/dom.js +32 -0
- package/dist/web/drag.d.ts +3 -0
- package/dist/web/drag.js +13 -0
- package/dist/web/errors.d.ts +1 -0
- package/dist/web/errors.js +27 -0
- package/dist/web/format.d.ts +11 -0
- package/dist/web/format.js +82 -0
- package/dist/web/menus.d.ts +17 -0
- package/dist/web/menus.js +40 -0
- package/dist/web/prefs.d.ts +12 -0
- package/dist/web/prefs.js +62 -0
- package/dist/web/react/badges.d.ts +35 -0
- package/dist/web/react/badges.js +32 -0
- package/dist/web/react/controls.d.ts +86 -0
- package/dist/web/react/controls.js +154 -0
- package/dist/web/react/dialog.d.ts +45 -0
- package/dist/web/react/dialog.js +82 -0
- package/dist/web/react/following.d.ts +25 -0
- package/dist/web/react/following.js +34 -0
- package/dist/web/react/layout.d.ts +111 -0
- package/dist/web/react/layout.js +85 -0
- package/dist/web/react/list_picker.d.ts +19 -0
- package/dist/web/react/list_picker.js +50 -0
- package/dist/web/react/markdown.d.ts +8 -0
- package/dist/web/react/markdown.js +40 -0
- package/dist/web/react/media.d.ts +24 -0
- package/dist/web/react/media.js +18 -0
- package/dist/web/react/menu_button.d.ts +10 -0
- package/dist/web/react/menu_button.js +16 -0
- package/dist/web/react/menus.d.ts +31 -0
- package/dist/web/react/page_view.d.ts +16 -0
- package/dist/web/react/page_view.js +6 -0
- package/dist/web/react/prefs.d.ts +2 -0
- package/dist/web/react/prefs.js +11 -0
- package/dist/web/react/progress.d.ts +10 -0
- package/dist/web/react/progress.js +9 -0
- package/dist/web/react/rating.d.ts +8 -0
- package/dist/web/react/rating.js +23 -0
- package/dist/web/react/reveal.d.ts +10 -0
- package/dist/web/react/reveal.js +30 -0
- package/dist/web/react/shell.d.ts +46 -0
- package/dist/web/react/shell.js +94 -0
- package/dist/web/react/shortcuts.d.ts +23 -0
- package/dist/web/react/shortcuts.js +95 -0
- package/dist/web/react/split.d.ts +23 -0
- package/dist/web/react/split.js +94 -0
- package/dist/web/react/states.d.ts +36 -0
- package/dist/web/react/states.js +64 -0
- package/dist/web/react/store.d.ts +4 -0
- package/dist/web/react/store.js +7 -0
- package/dist/web/react/suggestions.d.ts +23 -0
- package/dist/web/react/suggestions.js +57 -0
- package/dist/web/react/table.d.ts +78 -0
- package/dist/web/react/table.js +404 -0
- package/dist/web/react/test_stubs.d.ts +1 -0
- package/dist/web/react/test_stubs.js +31 -0
- package/dist/web/react/time.d.ts +5 -0
- package/dist/web/react/time.js +19 -0
- package/dist/web/react/toasts.d.ts +29 -0
- package/dist/web/react/toasts.js +72 -0
- package/dist/web/react/tree.d.ts +28 -0
- package/dist/web/react/tree.js +183 -0
- package/dist/web/react/volume.d.ts +8 -0
- package/dist/web/react/volume.js +42 -0
- package/dist/web/react/watch.d.ts +3 -0
- package/dist/web/react/watch.js +5 -0
- package/dist/web/router.d.ts +7 -0
- package/dist/web/router.js +29 -0
- package/dist/web/selection.d.ts +25 -0
- package/dist/web/selection.js +106 -0
- package/dist/web/split.d.ts +15 -0
- package/dist/web/split.js +104 -0
- package/dist/web/states.d.ts +3 -0
- package/dist/web/states.js +10 -0
- package/dist/web/store.d.ts +11 -0
- package/dist/web/store.js +17 -0
- package/dist/web/tables.d.ts +28 -0
- package/dist/web/tables.js +88 -0
- package/dist/web/theme.d.ts +14 -0
- package/dist/web/theme.js +52 -0
- package/dist/web/tree.d.ts +40 -0
- package/dist/web/tree.js +130 -0
- package/dist/web/virtual.d.ts +7 -0
- package/dist/web/virtual.js +10 -0
- package/dist/web/watch.d.ts +11 -0
- package/dist/web/watch.js +37 -0
- package/package.json +1 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type CSSProperties, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
|
|
2
|
+
import { type Sorting } from "../tables.ts";
|
|
3
|
+
import { type DropPlacement } from "../tree.ts";
|
|
4
|
+
export type { Sorting } from "../tables.ts";
|
|
5
|
+
export type { DropPlacement } from "../tree.ts";
|
|
6
|
+
export interface TableColumn<Row> {
|
|
7
|
+
label: string;
|
|
8
|
+
header?: ReactNode;
|
|
9
|
+
id?: string;
|
|
10
|
+
cell: (row: Row) => ReactNode;
|
|
11
|
+
sort?: (row: Row) => string | number | null;
|
|
12
|
+
sortable?: boolean;
|
|
13
|
+
class?: string;
|
|
14
|
+
width?: number;
|
|
15
|
+
min_width?: number;
|
|
16
|
+
max_width?: number;
|
|
17
|
+
hideable?: boolean;
|
|
18
|
+
movable?: boolean;
|
|
19
|
+
filter?: ReactNode | ((close: () => void) => ReactNode);
|
|
20
|
+
filter_active?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface RowGroup<Row> {
|
|
23
|
+
key: string;
|
|
24
|
+
label: ReactNode;
|
|
25
|
+
rows: Row[];
|
|
26
|
+
class_name?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface TableVirtual {
|
|
29
|
+
row_height: number;
|
|
30
|
+
overscan?: number;
|
|
31
|
+
card_height?: number;
|
|
32
|
+
}
|
|
33
|
+
interface DataTableProps<Row> {
|
|
34
|
+
columns: TableColumn<Row>[];
|
|
35
|
+
rows: Row[];
|
|
36
|
+
groups?: RowGroup<Row>[];
|
|
37
|
+
row_key?: (row: Row) => string | number;
|
|
38
|
+
caption?: string;
|
|
39
|
+
virtual?: TableVirtual;
|
|
40
|
+
fill_last_column?: boolean;
|
|
41
|
+
wrap_class?: string;
|
|
42
|
+
sorting?: Sorting | Sorting[];
|
|
43
|
+
manual_sorting?: boolean;
|
|
44
|
+
on_sort?: (sorting: Sorting) => void;
|
|
45
|
+
on_sorting_change?: (sorting: Sorting[]) => void;
|
|
46
|
+
column_order?: string[];
|
|
47
|
+
on_column_order_change?: (order: string[]) => void;
|
|
48
|
+
column_widths?: Record<string, number>;
|
|
49
|
+
on_column_widths_change?: (widths: Record<string, number>, done: boolean) => void;
|
|
50
|
+
hidden_columns?: string[];
|
|
51
|
+
on_hidden_columns_change?: (hidden: string[]) => void;
|
|
52
|
+
row_passes?: (row: Row) => boolean;
|
|
53
|
+
on_clear_filters?: () => void;
|
|
54
|
+
get_children?: (row: Row) => Row[] | undefined;
|
|
55
|
+
expanded?: string[] | true;
|
|
56
|
+
on_expanded_change?: (expanded: string[]) => void;
|
|
57
|
+
on_row_click?: (row: Row, event: MouseEvent<HTMLTableRowElement>) => void;
|
|
58
|
+
on_row_context_menu?: (row: Row, event: MouseEvent<HTMLTableRowElement>) => void;
|
|
59
|
+
on_row_double_click?: (row: Row, event: MouseEvent<HTMLTableRowElement>) => void;
|
|
60
|
+
on_row_key_down?: (row: Row, event: KeyboardEvent<HTMLTableRowElement>) => void;
|
|
61
|
+
active_row?: string | number;
|
|
62
|
+
row_selected?: (row: Row) => boolean;
|
|
63
|
+
row_class?: (row: Row) => string | undefined;
|
|
64
|
+
row_title?: (row: Row) => string | undefined;
|
|
65
|
+
row_style?: (row: Row) => CSSProperties | undefined;
|
|
66
|
+
render_after_row?: (row: Row) => ReactNode;
|
|
67
|
+
on_row_drag_start?: (row: Row, event: DragEvent<HTMLTableRowElement>) => void;
|
|
68
|
+
on_row_drag_end?: () => void;
|
|
69
|
+
can_drop_on_row?: (row: Row, placement: DropPlacement) => boolean;
|
|
70
|
+
allow_inside_drop?: boolean;
|
|
71
|
+
on_row_drop?: (row: Row, placement: DropPlacement, event: DragEvent<HTMLTableRowElement>) => void;
|
|
72
|
+
}
|
|
73
|
+
export declare function DataTable<Row>(props: DataTableProps<Row>): import("react").JSX.Element;
|
|
74
|
+
export declare function Pager({ page, has_more, go }: {
|
|
75
|
+
page: number;
|
|
76
|
+
has_more: boolean;
|
|
77
|
+
go: (page: number) => void;
|
|
78
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
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, long lists drawn
|
|
4
|
+
// only where they show, and cards on phones.
|
|
5
|
+
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { getCoreRowModel, getExpandedRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
|
|
7
|
+
import { cx } from "../dom.js";
|
|
8
|
+
import { clamp_width, column_id, compare_values, move_column, ordered_columns, sort_after_click, sorting_list } from "../tables.js";
|
|
9
|
+
import { placement_at } from "../tree.js";
|
|
10
|
+
import { virtual_window } from "../virtual.js";
|
|
11
|
+
import { ContextMenu, Popover } from "./menus.js";
|
|
12
|
+
import { ResizeHandle } from "./split.js";
|
|
13
|
+
// A column's heading: its header with the label kept for screen readers and hover, or the label alone.
|
|
14
|
+
function heading(column) {
|
|
15
|
+
if (column.header === undefined)
|
|
16
|
+
return column.label;
|
|
17
|
+
return (_jsxs("span", { className: "column-header", title: column.label, children: [_jsx("span", { "aria-hidden": "true", children: column.header }), _jsx("span", { className: "visually-hidden", children: column.label })] }));
|
|
18
|
+
}
|
|
19
|
+
const is_empty = (value) => value === null || value === undefined || value === "";
|
|
20
|
+
// The rows that pass and, in a tree, those above a row that passes, with each row's passing children.
|
|
21
|
+
function filtered_tree(rows, passes, get_children, kept_children) {
|
|
22
|
+
return rows.filter((row) => {
|
|
23
|
+
const children = get_children ? filtered_tree(get_children(row) ?? [], passes, get_children, kept_children) : [];
|
|
24
|
+
kept_children.set(row, children);
|
|
25
|
+
return passes(row) || children.length > 0;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function TableRowView(props) {
|
|
29
|
+
const { original, id, columns, events } = props;
|
|
30
|
+
const after = props.after;
|
|
31
|
+
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) => {
|
|
32
|
+
const label = column.label;
|
|
33
|
+
if (!props.tree || index > 0)
|
|
34
|
+
return _jsx("td", { className: column.class, "data-label": label, children: column.cell(original) }, column_id(column));
|
|
35
|
+
const count = props.child_count;
|
|
36
|
+
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)));
|
|
37
|
+
}) }), after !== undefined && after !== null && typeof after !== "boolean" && _jsx("tr", { className: "row-extra", children: _jsx("td", { colSpan: columns.length, children: after }) })] }));
|
|
38
|
+
}
|
|
39
|
+
function same_style(first, second) {
|
|
40
|
+
if (first === second)
|
|
41
|
+
return true;
|
|
42
|
+
if (!first || !second)
|
|
43
|
+
return false;
|
|
44
|
+
const keys = Object.keys(first);
|
|
45
|
+
return keys.length === Object.keys(second).length && keys.every((key) => first[key] === second[key]);
|
|
46
|
+
}
|
|
47
|
+
function same_row(before, after) {
|
|
48
|
+
return Object.keys(after).every((key) => key === "style" ? same_style(before.style, after.style) : before[key] === after[key]);
|
|
49
|
+
}
|
|
50
|
+
// A row draws again only when something it shows changes, so picking one row leaves the others alone.
|
|
51
|
+
const TableRow = memo(TableRowView, same_row);
|
|
52
|
+
// Whether the page is phone-sized, where the rows are cards.
|
|
53
|
+
function use_phone() {
|
|
54
|
+
const query = "(max-width: 640px)";
|
|
55
|
+
const [phone, set_phone] = useState(() => typeof window.matchMedia === "function" && window.matchMedia(query).matches);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (typeof window.matchMedia !== "function")
|
|
58
|
+
return;
|
|
59
|
+
const list = window.matchMedia(query);
|
|
60
|
+
const changed = () => set_phone(list.matches);
|
|
61
|
+
list.addEventListener("change", changed);
|
|
62
|
+
return () => list.removeEventListener("change", changed);
|
|
63
|
+
}, []);
|
|
64
|
+
return phone;
|
|
65
|
+
}
|
|
66
|
+
// ── Table ────────────────────────────────────────────────────────────────────
|
|
67
|
+
export function DataTable(props) {
|
|
68
|
+
const { columns, row_key, caption, get_children, row_passes, groups, allow_inside_drop = true, fill_last_column: fill_last = false } = props;
|
|
69
|
+
const rows = useMemo(() => groups ? groups.flatMap((group) => group.rows) : props.rows, [groups, props.rows]);
|
|
70
|
+
const [own_expanded, set_own_expanded] = useState(true);
|
|
71
|
+
const [open_filter, set_open_filter] = useState(null);
|
|
72
|
+
const filter_closed = useRef({ id: "", at: 0 });
|
|
73
|
+
const [columns_menu, set_columns_menu] = useState(null);
|
|
74
|
+
const [dragged, set_dragged] = useState(null);
|
|
75
|
+
const [drop_target, set_drop_target] = useState(null);
|
|
76
|
+
const resizing = useRef(false);
|
|
77
|
+
const header_cells = useRef(new Map());
|
|
78
|
+
const [measured, set_measured] = useState({});
|
|
79
|
+
const [row_dragging, set_row_dragging] = useState(false);
|
|
80
|
+
const [row_drop, set_row_drop] = useState(null);
|
|
81
|
+
const by_id = useMemo(() => new Map(columns.map((column) => [column_id(column), column])), [columns]);
|
|
82
|
+
const all_ids = columns.map(column_id);
|
|
83
|
+
const manual = props.manual_sorting === true;
|
|
84
|
+
const is_sortable = (column) => Boolean(column) && (manual ? column.sortable !== false : Boolean(column.sort));
|
|
85
|
+
// Rows that come sorted keep every key, even one naming no column, so a click never drops it.
|
|
86
|
+
const sorting = manual ? sorting_list(props.sorting) : sorting_list(props.sorting).filter((key) => is_sortable(by_id.get(key.key)));
|
|
87
|
+
const hidden = props.hidden_columns ?? [];
|
|
88
|
+
const widths = props.column_widths ?? {};
|
|
89
|
+
const expanded = props.expanded ?? own_expanded;
|
|
90
|
+
const tree = get_children !== undefined;
|
|
91
|
+
// ── Rows ──
|
|
92
|
+
const { data, children_of } = useMemo(() => {
|
|
93
|
+
if (!row_passes)
|
|
94
|
+
return { data: rows, children_of: get_children };
|
|
95
|
+
const kept_children = new Map();
|
|
96
|
+
const kept = filtered_tree(rows, row_passes, get_children, kept_children);
|
|
97
|
+
return { data: kept, children_of: get_children && ((row) => kept_children.get(row) ?? []) };
|
|
98
|
+
}, [rows, row_passes, get_children]);
|
|
99
|
+
// TanStack flips a descending column's order, so an empty value is flipped first to stay last.
|
|
100
|
+
const descending = useRef(new Set());
|
|
101
|
+
descending.current = new Set(sorting.filter((key) => key.descending).map((key) => key.key));
|
|
102
|
+
const definitions = useMemo(() => columns.map((column) => ({
|
|
103
|
+
id: column_id(column),
|
|
104
|
+
accessorFn: column.sort,
|
|
105
|
+
enableSorting: Boolean(column.sort),
|
|
106
|
+
sortingFn: (left, right, id) => {
|
|
107
|
+
const [first, second] = [left.getValue(id), right.getValue(id)];
|
|
108
|
+
const order = compare_values(first, second);
|
|
109
|
+
return (is_empty(first) || is_empty(second)) && descending.current.has(id) ? -order : order;
|
|
110
|
+
},
|
|
111
|
+
})), [columns]);
|
|
112
|
+
const table = useReactTable({
|
|
113
|
+
data,
|
|
114
|
+
columns: definitions,
|
|
115
|
+
getRowId: row_key ? (row) => String(row_key(row)) : undefined,
|
|
116
|
+
getSubRows: children_of ? (row) => children_of(row) ?? [] : undefined,
|
|
117
|
+
state: {
|
|
118
|
+
sorting: sorting.filter((key) => by_id.has(key.key)).map((key) => ({ id: key.key, desc: key.descending })),
|
|
119
|
+
columnOrder: ordered_columns(props.column_order ?? all_ids, all_ids),
|
|
120
|
+
columnVisibility: Object.fromEntries(hidden.map((id) => [id, false])),
|
|
121
|
+
expanded: expanded === true ? true : Object.fromEntries(expanded.map((id) => [id, true])),
|
|
122
|
+
},
|
|
123
|
+
getCoreRowModel: getCoreRowModel(),
|
|
124
|
+
getSortedRowModel: getSortedRowModel(),
|
|
125
|
+
getExpandedRowModel: getExpandedRowModel(),
|
|
126
|
+
getRowCanExpand: (row) => row.subRows.length > 0,
|
|
127
|
+
manualSorting: manual,
|
|
128
|
+
// The table never pages, and the state above is rebuilt each render, so TanStack's reset would draw the table again forever.
|
|
129
|
+
autoResetPageIndex: false,
|
|
130
|
+
});
|
|
131
|
+
const shown_ids = table.getVisibleLeafColumns().map((column) => column.id).join("\n");
|
|
132
|
+
const shown = useMemo(() => shown_ids.split("\n").filter(Boolean).map((id) => by_id.get(id)), [shown_ids, by_id]);
|
|
133
|
+
function toggle_row(id, is_open) {
|
|
134
|
+
const open = expanded === true
|
|
135
|
+
? table.getCoreRowModel().flatRows.filter((each) => each.subRows.length > 0).map((each) => each.id)
|
|
136
|
+
: expanded;
|
|
137
|
+
const next = is_open ? open.filter((each) => each !== id) : [...open, id];
|
|
138
|
+
if (props.expanded === undefined)
|
|
139
|
+
set_own_expanded(next);
|
|
140
|
+
props.on_expanded_change?.(next);
|
|
141
|
+
}
|
|
142
|
+
// ── Sorting ──
|
|
143
|
+
function sort_by(id, adding) {
|
|
144
|
+
if (props.on_sorting_change)
|
|
145
|
+
return props.on_sorting_change(sort_after_click(sorting, id, adding));
|
|
146
|
+
const current = sorting.find((key) => key.key === id);
|
|
147
|
+
props.on_sort?.({ key: id, descending: current ? !current.descending : false });
|
|
148
|
+
}
|
|
149
|
+
// ── Widths ──
|
|
150
|
+
const width_of = (column) => widths[column_id(column)] ?? column.width;
|
|
151
|
+
// A column never sized is measured, so the first drag of its edge starts from where it is.
|
|
152
|
+
useLayoutEffect(() => {
|
|
153
|
+
if (!props.on_column_widths_change)
|
|
154
|
+
return;
|
|
155
|
+
const sizes = Object.fromEntries([...header_cells.current].map(([id, cell]) => [id, cell.offsetWidth]));
|
|
156
|
+
set_measured((before) => JSON.stringify(before) === JSON.stringify(sizes) ? before : sizes);
|
|
157
|
+
}, [shown_ids, props.on_column_widths_change]);
|
|
158
|
+
function resize(column, width, done) {
|
|
159
|
+
props.on_column_widths_change?.({ ...widths, [column_id(column)]: width }, done);
|
|
160
|
+
}
|
|
161
|
+
// ── Moving ──
|
|
162
|
+
const can_move = (column) => Boolean(props.on_column_order_change) && column.movable !== false;
|
|
163
|
+
const order = () => table.getState().columnOrder;
|
|
164
|
+
function move(id, target, after) {
|
|
165
|
+
if (id !== target)
|
|
166
|
+
props.on_column_order_change?.(move_column(order(), id, target, after));
|
|
167
|
+
}
|
|
168
|
+
// A column dropped on the left half of another goes before it, and on the right half after it.
|
|
169
|
+
function drag_over(column, event) {
|
|
170
|
+
if (!dragged || !can_move(column))
|
|
171
|
+
return;
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
const cell = event.currentTarget.getBoundingClientRect();
|
|
174
|
+
set_drop_target({ id: column_id(column), after: event.clientX > cell.left + cell.width / 2 });
|
|
175
|
+
}
|
|
176
|
+
function drop(event) {
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
if (dragged && drop_target)
|
|
179
|
+
move(dragged, drop_target.id, drop_target.after);
|
|
180
|
+
set_dragged(null);
|
|
181
|
+
set_drop_target(null);
|
|
182
|
+
}
|
|
183
|
+
// Alt with an arrow moves the column one place along, past any hidden ones.
|
|
184
|
+
function move_by_key(column, event) {
|
|
185
|
+
if (!event.altKey || !can_move(column) || (event.key !== "ArrowLeft" && event.key !== "ArrowRight"))
|
|
186
|
+
return;
|
|
187
|
+
event.preventDefault();
|
|
188
|
+
const ids = shown.map(column_id);
|
|
189
|
+
const index = ids.indexOf(column_id(column));
|
|
190
|
+
const target = ids[index + (event.key === "ArrowLeft" ? -1 : 1)];
|
|
191
|
+
if (target && by_id.get(target)?.movable !== false)
|
|
192
|
+
move(column_id(column), target, event.key === "ArrowRight");
|
|
193
|
+
}
|
|
194
|
+
// ── Filters ──
|
|
195
|
+
function toggle_filter(id, anchor) {
|
|
196
|
+
// The click that closed the popover by landing outside it is the same one that reaches its button.
|
|
197
|
+
const just_closed = filter_closed.current.id === id && Date.now() - filter_closed.current.at < 300;
|
|
198
|
+
set_open_filter(open_filter?.id === id || just_closed ? null : { id, anchor });
|
|
199
|
+
}
|
|
200
|
+
function close_filter() {
|
|
201
|
+
if (open_filter)
|
|
202
|
+
filter_closed.current = { id: open_filter.id, at: Date.now() };
|
|
203
|
+
set_open_filter(null);
|
|
204
|
+
}
|
|
205
|
+
const any_filter = columns.some((column) => column.filter_active);
|
|
206
|
+
// ── Hiding ──
|
|
207
|
+
function open_columns_menu(event) {
|
|
208
|
+
if (!props.on_hidden_columns_change)
|
|
209
|
+
return;
|
|
210
|
+
event.preventDefault();
|
|
211
|
+
if (event.type === "contextmenu")
|
|
212
|
+
return set_columns_menu({ x: event.clientX, y: event.clientY });
|
|
213
|
+
const button = event.currentTarget.getBoundingClientRect();
|
|
214
|
+
set_columns_menu({ x: button.left, y: button.bottom + 4 });
|
|
215
|
+
}
|
|
216
|
+
const column_choices = columns.map((column) => {
|
|
217
|
+
const id = column_id(column);
|
|
218
|
+
const visible = !hidden.includes(id);
|
|
219
|
+
// The last column on show stays, so the table never empties.
|
|
220
|
+
const fixed = column.hideable === false || (visible && shown.length === 1);
|
|
221
|
+
return { label: column.label, checked: visible, disabled: fixed, keep_open: true, run: () => props.on_hidden_columns_change(visible ? [...hidden, id] : hidden.filter((each) => each !== id)) };
|
|
222
|
+
});
|
|
223
|
+
// ── Dragging rows ──
|
|
224
|
+
// A drag started in this table lands anywhere, and any drag lands where the app's rule allows it.
|
|
225
|
+
function row_placement(row, event) {
|
|
226
|
+
if (!props.on_row_drop)
|
|
227
|
+
return null;
|
|
228
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
229
|
+
const placement = placement_at(box.height > 0 ? (event.clientY - box.top) / box.height : 0.5, allow_inside_drop);
|
|
230
|
+
const allowed = props.can_drop_on_row ? props.can_drop_on_row(row, placement) : row_dragging;
|
|
231
|
+
return allowed ? placement : null;
|
|
232
|
+
}
|
|
233
|
+
function row_drag_start(row, id, event) {
|
|
234
|
+
set_row_dragging(true);
|
|
235
|
+
props.on_row_drag_start(row, event);
|
|
236
|
+
// Some browsers start a drag only once it carries data.
|
|
237
|
+
if (event.dataTransfer && event.dataTransfer.types.length === 0)
|
|
238
|
+
event.dataTransfer.setData("text/plain", id);
|
|
239
|
+
}
|
|
240
|
+
function row_drag_over(row, id, event) {
|
|
241
|
+
const placement = row_placement(row, event);
|
|
242
|
+
if (!placement) {
|
|
243
|
+
if (row_drop?.id === id)
|
|
244
|
+
set_row_drop(null);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
event.preventDefault();
|
|
248
|
+
if (row_drop?.id !== id || row_drop.placement !== placement)
|
|
249
|
+
set_row_drop({ id, placement });
|
|
250
|
+
}
|
|
251
|
+
function row_drag_drop(row, event) {
|
|
252
|
+
const placement = row_placement(row, event);
|
|
253
|
+
set_row_drop(null);
|
|
254
|
+
if (!placement)
|
|
255
|
+
return;
|
|
256
|
+
event.preventDefault();
|
|
257
|
+
props.on_row_drop(row, placement, event);
|
|
258
|
+
}
|
|
259
|
+
function row_drag_end() {
|
|
260
|
+
set_row_dragging(false);
|
|
261
|
+
set_row_drop(null);
|
|
262
|
+
props.on_row_drag_end?.();
|
|
263
|
+
}
|
|
264
|
+
// The rows read these through a ref, so a new handler from the app never draws every row again.
|
|
265
|
+
const events = useRef(null);
|
|
266
|
+
events.current = {
|
|
267
|
+
click: (row, event) => props.on_row_click?.(row, event),
|
|
268
|
+
double_click: (row, event) => props.on_row_double_click?.(row, event),
|
|
269
|
+
context_menu: (row, event) => props.on_row_context_menu?.(row, event),
|
|
270
|
+
key_down: (row, event) => props.on_row_key_down?.(row, event),
|
|
271
|
+
toggle: toggle_row,
|
|
272
|
+
drag_start: row_drag_start,
|
|
273
|
+
drag_end: row_drag_end,
|
|
274
|
+
drag_over: (row, id, event) => { if (props.on_row_drop)
|
|
275
|
+
row_drag_over(row, id, event); },
|
|
276
|
+
drag_leave: (id) => { if (props.on_row_drop && row_drop?.id === id)
|
|
277
|
+
set_row_drop(null); },
|
|
278
|
+
drop: (row, _id, event) => { if (props.on_row_drop)
|
|
279
|
+
row_drag_drop(row, event); },
|
|
280
|
+
};
|
|
281
|
+
// ── Drawing ──
|
|
282
|
+
function header(column) {
|
|
283
|
+
const id = column_id(column);
|
|
284
|
+
const index = sorting.findIndex((key) => key.key === id);
|
|
285
|
+
const key = sorting[index];
|
|
286
|
+
const sortable = is_sortable(column) && Boolean(props.on_sort || props.on_sorting_change);
|
|
287
|
+
const filling = fill_last && column === shown[shown.length - 1];
|
|
288
|
+
const width = filling ? undefined : width_of(column);
|
|
289
|
+
const movable = can_move(column);
|
|
290
|
+
const class_name = [
|
|
291
|
+
column.class, column.filter_active && "filtered", dragged === id && "dragged", drop_target?.id === id && (drop_target.after ? "drop-after" : "drop-before"),
|
|
292
|
+
].filter(Boolean).join(" ") || undefined;
|
|
293
|
+
const marker = key && (_jsxs("span", { className: "sort-marker", "aria-hidden": "true", children: [key.descending ? " ▼" : " ▲", sorting.length > 1 && _jsx("sup", { className: "sort-rank", children: index + 1 })] }));
|
|
294
|
+
return (_jsxs("th", { ref: (cell) => { if (cell)
|
|
295
|
+
header_cells.current.set(id, cell);
|
|
296
|
+
else
|
|
297
|
+
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) => {
|
|
298
|
+
if (resizing.current)
|
|
299
|
+
return event.preventDefault();
|
|
300
|
+
event.dataTransfer.setData("text/plain", id);
|
|
301
|
+
set_dragged(id);
|
|
302
|
+
}, 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: [heading(column), marker] })) : movable ? (_jsx("span", { tabIndex: 0, onKeyDown: (event) => move_by_key(column, event), children: heading(column) })) : heading(column), 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));
|
|
303
|
+
}
|
|
304
|
+
function body_row(row, key) {
|
|
305
|
+
const original = row.original;
|
|
306
|
+
const keyed = props.on_row_key_down !== undefined;
|
|
307
|
+
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));
|
|
308
|
+
}
|
|
309
|
+
const row_line = (row) => ({ kind: "row", row, key: row_key ? String(row_key(row.original)) : row.id });
|
|
310
|
+
// Each group's heading and then its rows, which keep the order of the sorted whole, so each group is sorted on its own.
|
|
311
|
+
function grouped_lines(groups) {
|
|
312
|
+
const group_of = new Map();
|
|
313
|
+
groups.forEach((group, index) => group.rows.forEach((row) => { if (!group_of.has(row))
|
|
314
|
+
group_of.set(row, index); }));
|
|
315
|
+
const rows_by_group = groups.map(() => []);
|
|
316
|
+
let current = 0;
|
|
317
|
+
for (const row of table.getRowModel().rows) {
|
|
318
|
+
if (row.depth === 0)
|
|
319
|
+
current = group_of.get(row.original) ?? current;
|
|
320
|
+
rows_by_group[current].push(row);
|
|
321
|
+
}
|
|
322
|
+
// A group the filter emptied loses its heading, while one that was empty all along keeps it.
|
|
323
|
+
return groups.flatMap((group, index) => rows_by_group[index].length === 0 && group.rows.length > 0
|
|
324
|
+
? []
|
|
325
|
+
: [{ kind: "group", group }, ...rows_by_group[index].map(row_line)]);
|
|
326
|
+
}
|
|
327
|
+
const lines = groups ? grouped_lines(groups) : table.getRowModel().rows.map(row_line);
|
|
328
|
+
// ── Virtual rows ──
|
|
329
|
+
const phone = use_phone();
|
|
330
|
+
const line_height = props.virtual ? (phone ? props.virtual.card_height : props.virtual.row_height) : undefined;
|
|
331
|
+
const wrap = useRef(null);
|
|
332
|
+
const head = useRef(null);
|
|
333
|
+
const [view, set_view] = useState({ top: 0, height: 0 });
|
|
334
|
+
const rows_focused = useRef(false);
|
|
335
|
+
const measure = () => {
|
|
336
|
+
const element = wrap.current;
|
|
337
|
+
if (element)
|
|
338
|
+
set_view({ top: element.scrollTop, height: element.clientHeight });
|
|
339
|
+
};
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
if (line_height === undefined || typeof ResizeObserver === "undefined")
|
|
342
|
+
return;
|
|
343
|
+
const observer = new ResizeObserver(measure);
|
|
344
|
+
observer.observe(wrap.current);
|
|
345
|
+
return () => observer.disconnect();
|
|
346
|
+
}, [line_height === undefined]);
|
|
347
|
+
const head_height = head.current?.offsetHeight ?? 0;
|
|
348
|
+
const viewport = view.height || window.innerHeight;
|
|
349
|
+
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);
|
|
350
|
+
const active_index = props.active_row === undefined ? -1 : lines.findIndex((line) => line.kind === "row" && line.key === String(props.active_row));
|
|
351
|
+
// A row that moves the active row by key hands focus on to it once it is drawn.
|
|
352
|
+
const focus_pending = useRef(false);
|
|
353
|
+
useLayoutEffect(() => { focus_pending.current = line_height !== undefined && rows_focused.current; }, [props.active_row]);
|
|
354
|
+
// The active row scrolls into view as the keys move it.
|
|
355
|
+
useLayoutEffect(() => {
|
|
356
|
+
if (line_height === undefined || active_index < 0)
|
|
357
|
+
return;
|
|
358
|
+
const element = wrap.current;
|
|
359
|
+
const row_top = head_height + active_index * line_height;
|
|
360
|
+
const top = view.top;
|
|
361
|
+
const next = row_top < top + head_height ? row_top - head_height : row_top + line_height > top + viewport ? row_top + line_height - viewport : top;
|
|
362
|
+
if (next !== top) {
|
|
363
|
+
element.scrollTop = next;
|
|
364
|
+
set_view({ top: next, height: view.height });
|
|
365
|
+
}
|
|
366
|
+
}, [props.active_row, active_index, line_height]);
|
|
367
|
+
useLayoutEffect(() => {
|
|
368
|
+
if (!focus_pending.current || props.active_row === undefined)
|
|
369
|
+
return;
|
|
370
|
+
const active = String(props.active_row);
|
|
371
|
+
const row = [...wrap.current.querySelectorAll("tr[data-row-key]")].find((each) => each.dataset.rowKey === active);
|
|
372
|
+
if (!row)
|
|
373
|
+
return;
|
|
374
|
+
focus_pending.current = false;
|
|
375
|
+
const focused = document.activeElement;
|
|
376
|
+
if (focused !== row && (focused === null || focused === document.body || wrap.current.contains(focused)))
|
|
377
|
+
row.focus({ preventScroll: true });
|
|
378
|
+
});
|
|
379
|
+
function spacer(height, key) {
|
|
380
|
+
if (height <= 0)
|
|
381
|
+
return null;
|
|
382
|
+
return _jsx("tr", { className: "virtual-spacer", "aria-hidden": "true", style: { height }, children: _jsx("td", { colSpan: shown.length, style: { padding: 0, border: "none" } }) }, key);
|
|
383
|
+
}
|
|
384
|
+
function draw_line(line) {
|
|
385
|
+
if (line.kind === "row")
|
|
386
|
+
return body_row(line.row, line.key);
|
|
387
|
+
const group = line.group;
|
|
388
|
+
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}`));
|
|
389
|
+
}
|
|
390
|
+
const drawn = windowed ? lines.slice(windowed.start, windowed.end) : lines;
|
|
391
|
+
const fills_by_fixed = fill_last && shown.slice(0, -1).every((column) => width_of(column) !== undefined);
|
|
392
|
+
const filtered_column = open_filter ? by_id.get(open_filter.id) : undefined;
|
|
393
|
+
const filter_body = filtered_column?.filter;
|
|
394
|
+
const tools = (props.on_clear_filters && any_filter) || props.on_hidden_columns_change;
|
|
395
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: wrap, className: cx("table-wrap", props.virtual && "virtual", props.wrap_class), style: props.virtual ? { overflowY: "auto" } : undefined, onScroll: line_height === undefined ? undefined : measure, children: [tools && (_jsx("div", { className: "table-tools", children: _jsxs("span", { className: "table-tools-inner", children: [props.on_clear_filters && any_filter && _jsx("button", { type: "button", "aria-label": "Clear filters", title: "Clear filters", onClick: props.on_clear_filters, children: "Clear" }), props.on_hidden_columns_change && (_jsx("button", { type: "button", className: "icon columns-button", "aria-label": "Show or hide columns", title: "Show or hide columns", "aria-haspopup": "menu", onClick: open_columns_menu, children: _jsx("svg", { viewBox: "0 0 16 16", width: "12", height: "12", "aria-hidden": "true", children: _jsx("path", { d: "M2 2h3v12H2zm4.5 0h3v12h-3zM11 2h3v12h-3z" }) }) }))] }) })), _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))
|
|
396
|
+
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) })] }));
|
|
397
|
+
}
|
|
398
|
+
// ── Pages ────────────────────────────────────────────────────────────────────
|
|
399
|
+
// Newer and older buttons around the page number, or nothing when everything fits on one page.
|
|
400
|
+
export function Pager({ page, has_more, go }) {
|
|
401
|
+
if (page === 1 && !has_more)
|
|
402
|
+
return null;
|
|
403
|
+
return (_jsxs("nav", { className: "pager", "aria-label": "Pages", children: [_jsx("button", { disabled: page <= 1, onClick: () => go(page - 1), children: "\u2190 Newer" }), _jsxs("span", { className: "muted", children: ["Page ", page] }), _jsx("button", { disabled: !has_more, onClick: () => go(page + 1), children: "Older \u2192" })] }));
|
|
404
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Stands in for the native popover and modal dialog, which jsdom does not draw; a test imports it for its side effects.
|
|
2
|
+
// A shared setup file may load in a node environment too, where there is no window to stand in for.
|
|
3
|
+
if (typeof window !== "undefined") {
|
|
4
|
+
HTMLElement.prototype.showPopover = function () {
|
|
5
|
+
this.setAttribute("data-shown", "");
|
|
6
|
+
};
|
|
7
|
+
HTMLElement.prototype.hidePopover = function () {
|
|
8
|
+
this.removeAttribute("data-shown");
|
|
9
|
+
this.dispatchEvent(Object.assign(new Event("toggle"), { newState: "closed" }));
|
|
10
|
+
};
|
|
11
|
+
HTMLDialogElement.prototype.showModal = function () {
|
|
12
|
+
this.setAttribute("open", "");
|
|
13
|
+
};
|
|
14
|
+
HTMLDialogElement.prototype.close = function () {
|
|
15
|
+
this.removeAttribute("open");
|
|
16
|
+
};
|
|
17
|
+
// jsdom hides every popover, since none ever matches :popover-open, so a shown one is drawn again here over any app rule.
|
|
18
|
+
const shown_popovers = document.createElement("style");
|
|
19
|
+
shown_popovers.textContent = "[popover][data-shown] { display: block !important; }";
|
|
20
|
+
document.head.append(shown_popovers);
|
|
21
|
+
// Media queries all say no and scrolling does nothing, since jsdom lays nothing out.
|
|
22
|
+
window.matchMedia ??= (query) => ({ matches: false, media: query, onchange: null, addEventListener: () => { }, removeEventListener: () => { }, addListener: () => { }, removeListener: () => { }, dispatchEvent: () => false });
|
|
23
|
+
window.scrollTo = () => { };
|
|
24
|
+
// Nothing is ever resized either, so an observer never reports.
|
|
25
|
+
window.ResizeObserver ??= class {
|
|
26
|
+
observe() { }
|
|
27
|
+
unobserve() { }
|
|
28
|
+
disconnect() { }
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Times as the page shows them: how long ago, with the full date and time on hover, and the current time kept fresh.
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { age, stamp } from "../format.js";
|
|
5
|
+
// A time as its age, such as "3h ago", or the missing text, a dash unless told otherwise, when there is none.
|
|
6
|
+
export function Age({ epoch, missing = "—" }) {
|
|
7
|
+
if (!epoch)
|
|
8
|
+
return _jsx(_Fragment, { children: missing });
|
|
9
|
+
return _jsx("span", { title: stamp(epoch), children: age(epoch) });
|
|
10
|
+
}
|
|
11
|
+
// The current time, read again every interval, a minute unless told otherwise, so a page notices the day turning.
|
|
12
|
+
export function use_now(interval_in_milliseconds = 60_000) {
|
|
13
|
+
const [now, set_now] = useState(() => new Date());
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const timer = setInterval(() => set_now(new Date()), interval_in_milliseconds);
|
|
16
|
+
return () => clearInterval(timer);
|
|
17
|
+
}, [interval_in_milliseconds]);
|
|
18
|
+
return now;
|
|
19
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface ToastOptions {
|
|
2
|
+
tone?: "good" | "bad" | "warn" | "info";
|
|
3
|
+
link?: {
|
|
4
|
+
href: string;
|
|
5
|
+
text: string;
|
|
6
|
+
} | null;
|
|
7
|
+
action?: {
|
|
8
|
+
text: string;
|
|
9
|
+
run: () => void;
|
|
10
|
+
};
|
|
11
|
+
sticky?: boolean;
|
|
12
|
+
duration_in_milliseconds?: number;
|
|
13
|
+
on_close?: (reason: ToastCloseReason) => void;
|
|
14
|
+
}
|
|
15
|
+
export type ToastCloseReason = "timeout" | "dismissed" | "action" | "closed";
|
|
16
|
+
export declare function toast(message: string, options?: ToastOptions): () => void;
|
|
17
|
+
export interface UndoableOptions {
|
|
18
|
+
commit: () => void;
|
|
19
|
+
undo: () => void;
|
|
20
|
+
duration_in_milliseconds?: number;
|
|
21
|
+
undo_label?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface Undoable {
|
|
24
|
+
finish: () => void;
|
|
25
|
+
undo: () => void;
|
|
26
|
+
readonly settled: boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare function undoable(message: string, { commit, undo, duration_in_milliseconds, undo_label }: UndoableOptions): Undoable;
|
|
29
|
+
export declare function Toasts(): import("react").JSX.Element;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Short notes in the page's corner after something happened, with an optional link or action such as Undo.
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
let shown = [];
|
|
5
|
+
let next_id = 1;
|
|
6
|
+
const listeners = new Set();
|
|
7
|
+
function publish(toasts) {
|
|
8
|
+
shown = toasts;
|
|
9
|
+
for (const listener of listeners)
|
|
10
|
+
listener(shown);
|
|
11
|
+
}
|
|
12
|
+
// Takes the toast away and tells its owner why, once only.
|
|
13
|
+
function close_toast(id, reason) {
|
|
14
|
+
const item = shown.find((each) => each.id === id);
|
|
15
|
+
if (!item)
|
|
16
|
+
return;
|
|
17
|
+
publish(shown.filter((each) => each.id !== id));
|
|
18
|
+
item.on_close?.(reason);
|
|
19
|
+
}
|
|
20
|
+
// Shows the message and returns a function that closes it early.
|
|
21
|
+
export function toast(message, options = {}) {
|
|
22
|
+
const id = next_id++;
|
|
23
|
+
publish([...shown, { ...options, id, message }]);
|
|
24
|
+
if (!(options.sticky ?? options.tone === "bad"))
|
|
25
|
+
setTimeout(() => close_toast(id, "timeout"), options.duration_in_milliseconds ?? 6000);
|
|
26
|
+
return () => close_toast(id, "closed");
|
|
27
|
+
}
|
|
28
|
+
// Shows the message with an Undo button, then either undoes or commits the action, exactly once.
|
|
29
|
+
export function undoable(message, { commit, undo, duration_in_milliseconds = 8000, undo_label = "Undo" }) {
|
|
30
|
+
let settled = false;
|
|
31
|
+
let undo_wanted = false;
|
|
32
|
+
const settle = (reason) => {
|
|
33
|
+
if (settled)
|
|
34
|
+
return;
|
|
35
|
+
settled = true;
|
|
36
|
+
if (reason === "action" || undo_wanted)
|
|
37
|
+
undo();
|
|
38
|
+
else
|
|
39
|
+
commit();
|
|
40
|
+
};
|
|
41
|
+
const close = toast(message, {
|
|
42
|
+
action: { text: undo_label, run: () => { } },
|
|
43
|
+
duration_in_milliseconds,
|
|
44
|
+
on_close: settle,
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
finish: () => close(),
|
|
48
|
+
undo: () => {
|
|
49
|
+
if (settled)
|
|
50
|
+
return;
|
|
51
|
+
undo_wanted = true;
|
|
52
|
+
close();
|
|
53
|
+
},
|
|
54
|
+
get settled() {
|
|
55
|
+
return settled;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// ── Host ─────────────────────────────────────────────────────────────────────
|
|
60
|
+
// Where toasts stack; the Shell holds one, and a page without the Shell renders its own.
|
|
61
|
+
export function Toasts() {
|
|
62
|
+
const [toasts, set_toasts] = useState(shown);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
listeners.add(set_toasts);
|
|
65
|
+
return () => void listeners.delete(set_toasts);
|
|
66
|
+
}, []);
|
|
67
|
+
return (_jsx("div", { id: "toasts", className: "toasts", children: toasts.map((item) => {
|
|
68
|
+
const tone = item.tone ?? "info";
|
|
69
|
+
const close = () => close_toast(item.id, "dismissed");
|
|
70
|
+
return (_jsxs("div", { className: `toast tone-${tone}`, role: tone === "bad" ? "alert" : "status", children: [_jsx("span", { children: item.message }), item.link && _jsx("a", { href: item.link.href, onClick: close, children: item.link.text }), item.action && _jsx("button", { className: "toast-action", onClick: () => { close_toast(item.id, "action"); item.action.run(); }, children: item.action.text }), _jsx("button", { className: "icon remove", "aria-label": "Dismiss", onClick: close, children: "\u00D7" })] }, item.id));
|
|
71
|
+
}) }));
|
|
72
|
+
}
|