@x-tech-laboratories/x-libs 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -11
- package/dist/backups.d.ts +31 -0
- package/dist/backups.js +43 -0
- package/dist/durations.d.ts +1 -0
- package/dist/durations.js +10 -0
- package/dist/electron/app.d.ts +13 -0
- package/dist/electron/app.js +27 -0
- package/dist/electron/bridge.d.ts +3 -1
- package/dist/electron/bridge.js +21 -0
- package/dist/electron/events.d.ts +21 -0
- package/dist/electron/events.js +25 -0
- package/dist/electron/fetch.d.ts +25 -0
- package/dist/electron/fetch.js +67 -0
- package/dist/electron/window.d.ts +11 -0
- package/dist/electron/window.js +49 -6
- package/dist/log.d.ts +30 -0
- package/dist/log.js +79 -0
- package/dist/server/backup_files.d.ts +3 -0
- package/dist/server/backup_files.js +25 -0
- package/dist/web/drag.d.ts +3 -0
- package/dist/web/drag.js +13 -0
- package/dist/web/format.d.ts +1 -0
- package/dist/web/format.js +5 -0
- package/dist/web/prefs.d.ts +12 -0
- package/dist/web/prefs.js +62 -0
- package/dist/web/react/controls.d.ts +39 -4
- package/dist/web/react/controls.js +51 -7
- package/dist/web/react/dialog.d.ts +12 -2
- package/dist/web/react/dialog.js +7 -3
- package/dist/web/react/layout.d.ts +26 -6
- package/dist/web/react/layout.js +15 -6
- package/dist/web/react/list_picker.d.ts +19 -0
- package/dist/web/react/list_picker.js +50 -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 +12 -0
- package/dist/web/react/menus.js +26 -2
- package/dist/web/react/prefs.d.ts +2 -0
- package/dist/web/react/prefs.js +11 -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 +8 -2
- package/dist/web/react/shell.js +17 -6
- package/dist/web/react/table.d.ts +31 -1
- package/dist/web/react/table.js +209 -24
- package/dist/web/react/time.d.ts +1 -0
- package/dist/web/react/time.js +11 -1
- package/dist/web/react/toasts.d.ts +12 -0
- package/dist/web/react/toasts.js +31 -0
- package/dist/web/react/tree.d.ts +7 -2
- package/dist/web/react/tree.js +22 -7
- package/dist/web/react/watch.d.ts +3 -0
- package/dist/web/react/watch.js +5 -0
- package/dist/web/selection.d.ts +25 -0
- package/dist/web/selection.js +106 -0
- package/dist/web/tables.d.ts +1 -0
- package/dist/web/tables.js +8 -0
- package/dist/web/tree.d.ts +8 -3
- package/dist/web/tree.js +31 -8
- package/dist/web/virtual.d.ts +7 -0
- package/dist/web/virtual.js +10 -0
- package/dist/web/watch.d.ts +6 -0
- package/dist/web/watch.js +17 -0
- package/package.json +1 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { open_context_menu } from "./menus.js";
|
|
3
|
+
export function MenuButton({ label, items, glyph = "⋮", class_name }) {
|
|
4
|
+
function open(event) {
|
|
5
|
+
// The button may sit inside a link, such as a whole card that opens its video.
|
|
6
|
+
event.preventDefault();
|
|
7
|
+
event.stopPropagation();
|
|
8
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
9
|
+
open_context_menu({ clientX: box.left, clientY: box.bottom, preventDefault: () => { } }, typeof items === "function" ? items() : items);
|
|
10
|
+
}
|
|
11
|
+
return (_jsx("button", { type: "button", className: class_name ? `icon menu-trigger ${class_name}` : "icon menu-trigger", "aria-label": label, title: label, "aria-haspopup": "menu", onClick: open, children: glyph }));
|
|
12
|
+
}
|
package/dist/web/react/menus.js
CHANGED
|
@@ -92,13 +92,37 @@ function use_menu_keys(container, dismiss, whole_menu) {
|
|
|
92
92
|
}
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
|
-
//
|
|
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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// A remembered setting as component state, read once when the component mounts and written on every change.
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { browser_storage, read_pref, write_pref } from "../prefs.js";
|
|
4
|
+
export function use_pref(key, initial, validate, storage = browser_storage()) {
|
|
5
|
+
const [value, set_value] = useState(() => read_pref(storage, key, initial, validate));
|
|
6
|
+
const change = (next) => {
|
|
7
|
+
set_value(next);
|
|
8
|
+
write_pref(storage, key, next);
|
|
9
|
+
};
|
|
10
|
+
return [value, change];
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface RevealOptions {
|
|
2
|
+
root_margin?: string;
|
|
3
|
+
initial_count?: number;
|
|
4
|
+
}
|
|
5
|
+
export interface Reveal {
|
|
6
|
+
shown_count: number;
|
|
7
|
+
more_ref: (element: Element | null) => void;
|
|
8
|
+
reset: () => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function use_reveal(page_size: number, options?: RevealOptions): Reveal;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A long list shown a page at a time, the next page added as its end scrolls into view.
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
export function use_reveal(page_size, options = {}) {
|
|
4
|
+
const root_margin = options.root_margin ?? "200px";
|
|
5
|
+
const [shown_count, set_shown_count] = useState(() => Math.max(page_size, options.initial_count ?? page_size));
|
|
6
|
+
const observer = useRef(null);
|
|
7
|
+
const marker = useRef(null);
|
|
8
|
+
const more_ref = useCallback((element) => {
|
|
9
|
+
observer.current?.disconnect();
|
|
10
|
+
observer.current = null;
|
|
11
|
+
marker.current = element;
|
|
12
|
+
if (!element)
|
|
13
|
+
return;
|
|
14
|
+
observer.current = new IntersectionObserver((entries) => {
|
|
15
|
+
if (entries.some((entry) => entry.isIntersecting))
|
|
16
|
+
set_shown_count((count) => count + page_size);
|
|
17
|
+
}, { rootMargin: root_margin });
|
|
18
|
+
observer.current.observe(element);
|
|
19
|
+
}, [page_size, root_margin]);
|
|
20
|
+
// Watching the marker afresh after each page asks again whether it is still in view, so a short page keeps filling.
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!observer.current || !marker.current)
|
|
23
|
+
return;
|
|
24
|
+
observer.current.unobserve(marker.current);
|
|
25
|
+
observer.current.observe(marker.current);
|
|
26
|
+
}, [shown_count]);
|
|
27
|
+
useEffect(() => () => observer.current?.disconnect(), []);
|
|
28
|
+
const reset = useCallback(() => set_shown_count(page_size), [page_size]);
|
|
29
|
+
return { shown_count, more_ref, reset };
|
|
30
|
+
}
|
|
@@ -25,13 +25,19 @@ interface ShellProps {
|
|
|
25
25
|
sidebar?: ReactNode;
|
|
26
26
|
sidebar_width?: SidebarWidth;
|
|
27
27
|
theme: ThemeChoice;
|
|
28
|
-
user
|
|
28
|
+
user?: string | null;
|
|
29
|
+
logged_out?: boolean;
|
|
29
30
|
user_href?: string;
|
|
30
31
|
on_logout?: () => void;
|
|
32
|
+
brand_href?: string;
|
|
33
|
+
menu_open?: boolean;
|
|
34
|
+
on_menu_open_change?: (open: boolean) => void;
|
|
35
|
+
main_class?: string;
|
|
31
36
|
tools?: ReactNode;
|
|
32
37
|
sidebar_foot?: ReactNode;
|
|
38
|
+
status_bar?: ReactNode;
|
|
33
39
|
view_key?: string;
|
|
34
40
|
children: ReactNode;
|
|
35
41
|
}
|
|
36
|
-
export declare function Shell({ brand, sections, active, sidebar: sidebar_content, sidebar_width, theme, user, user_href, on_logout, tools, sidebar_foot, view_key, 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;
|
|
37
43
|
export {};
|
package/dist/web/react/shell.js
CHANGED
|
@@ -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,20 +41,31 @@ 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, tools, sidebar_foot, view_key, children }) {
|
|
45
|
-
const [
|
|
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
|
+
const [own_menu_open, set_own_menu_open] = useState(false);
|
|
46
|
+
const menu_open = held_menu_open ?? own_menu_open;
|
|
46
47
|
const main = useRef(null);
|
|
47
48
|
const menu_button = useRef(null);
|
|
48
49
|
const sidebar = useRef(null);
|
|
49
50
|
const first_view = useRef(true);
|
|
50
|
-
const logged_out = user === null;
|
|
51
|
+
const logged_out = held_logged_out ?? user === null;
|
|
52
|
+
const user_name = typeof user === "string" && user !== "" ? user : undefined;
|
|
51
53
|
const [width, change_width] = use_sidebar_width(sidebar_width);
|
|
54
|
+
// Every opening and closing of the phone menu goes through here, so an app that holds it hears each one.
|
|
55
|
+
function set_menu_open(open) {
|
|
56
|
+
if (open === menu_open)
|
|
57
|
+
return;
|
|
58
|
+
if (held_menu_open === undefined)
|
|
59
|
+
set_own_menu_open(open);
|
|
60
|
+
on_menu_open_change?.(open);
|
|
61
|
+
}
|
|
52
62
|
// The login page has no theme button, so the saved theme is set here.
|
|
53
63
|
useEffect(() => theme.apply(), []);
|
|
54
64
|
useEffect(() => {
|
|
55
65
|
document.body.classList.toggle("logged-out", logged_out);
|
|
56
66
|
document.body.classList.toggle("menu-open", menu_open);
|
|
57
|
-
|
|
67
|
+
document.body.classList.toggle("has-status-bar", status_bar !== undefined);
|
|
68
|
+
}, [logged_out, menu_open, status_bar !== undefined]);
|
|
58
69
|
useEffect(() => {
|
|
59
70
|
if (menu_open)
|
|
60
71
|
sidebar.current?.querySelector("a[href], button:not(:disabled), input, select, textarea, [tabindex='0']")?.focus();
|
|
@@ -77,5 +88,5 @@ export function Shell({ brand, sections = [], active, sidebar: sidebar_content,
|
|
|
77
88
|
first_view.current = false;
|
|
78
89
|
window.scrollTo(0, 0);
|
|
79
90
|
}, [view_key]);
|
|
80
|
-
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:
|
|
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, {})] }));
|
|
81
92
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { type MouseEvent, type ReactNode } from "react";
|
|
1
|
+
import { type CSSProperties, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
|
|
2
2
|
import { type Sorting } from "../tables.ts";
|
|
3
|
+
import { type DropPlacement } from "../tree.ts";
|
|
3
4
|
export type { Sorting } from "../tables.ts";
|
|
5
|
+
export type { DropPlacement } from "../tree.ts";
|
|
4
6
|
export interface TableColumn<Row> {
|
|
5
7
|
label: string;
|
|
6
8
|
id?: string;
|
|
7
9
|
cell: (row: Row) => ReactNode;
|
|
8
10
|
sort?: (row: Row) => string | number | null;
|
|
11
|
+
sortable?: boolean;
|
|
9
12
|
class?: string;
|
|
10
13
|
width?: number;
|
|
11
14
|
min_width?: number;
|
|
@@ -15,12 +18,27 @@ export interface TableColumn<Row> {
|
|
|
15
18
|
filter?: ReactNode | ((close: () => void) => ReactNode);
|
|
16
19
|
filter_active?: boolean;
|
|
17
20
|
}
|
|
21
|
+
export interface RowGroup<Row> {
|
|
22
|
+
key: string;
|
|
23
|
+
label: ReactNode;
|
|
24
|
+
rows: Row[];
|
|
25
|
+
class_name?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface TableVirtual {
|
|
28
|
+
row_height: number;
|
|
29
|
+
overscan?: number;
|
|
30
|
+
card_height?: number;
|
|
31
|
+
}
|
|
18
32
|
interface DataTableProps<Row> {
|
|
19
33
|
columns: TableColumn<Row>[];
|
|
20
34
|
rows: Row[];
|
|
35
|
+
groups?: RowGroup<Row>[];
|
|
21
36
|
row_key?: (row: Row) => string | number;
|
|
22
37
|
caption?: string;
|
|
38
|
+
virtual?: TableVirtual;
|
|
39
|
+
fill_last_column?: boolean;
|
|
23
40
|
sorting?: Sorting | Sorting[];
|
|
41
|
+
manual_sorting?: boolean;
|
|
24
42
|
on_sort?: (sorting: Sorting) => void;
|
|
25
43
|
on_sorting_change?: (sorting: Sorting[]) => void;
|
|
26
44
|
column_order?: string[];
|
|
@@ -36,7 +54,19 @@ interface DataTableProps<Row> {
|
|
|
36
54
|
on_expanded_change?: (expanded: string[]) => void;
|
|
37
55
|
on_row_click?: (row: Row, event: MouseEvent<HTMLTableRowElement>) => void;
|
|
38
56
|
on_row_context_menu?: (row: Row, event: MouseEvent<HTMLTableRowElement>) => void;
|
|
57
|
+
on_row_double_click?: (row: Row, event: MouseEvent<HTMLTableRowElement>) => void;
|
|
58
|
+
on_row_key_down?: (row: Row, event: KeyboardEvent<HTMLTableRowElement>) => void;
|
|
59
|
+
active_row?: string | number;
|
|
60
|
+
row_selected?: (row: Row) => boolean;
|
|
39
61
|
row_class?: (row: Row) => string | undefined;
|
|
62
|
+
row_title?: (row: Row) => string | undefined;
|
|
63
|
+
row_style?: (row: Row) => CSSProperties | undefined;
|
|
64
|
+
render_after_row?: (row: Row) => ReactNode;
|
|
65
|
+
on_row_drag_start?: (row: Row, event: DragEvent<HTMLTableRowElement>) => void;
|
|
66
|
+
on_row_drag_end?: () => void;
|
|
67
|
+
can_drop_on_row?: (row: Row, placement: DropPlacement) => boolean;
|
|
68
|
+
allow_inside_drop?: boolean;
|
|
69
|
+
on_row_drop?: (row: Row, placement: DropPlacement, event: DragEvent<HTMLTableRowElement>) => void;
|
|
40
70
|
}
|
|
41
71
|
export declare function DataTable<Row>(props: DataTableProps<Row>): import("react").JSX.Element;
|
|
42
72
|
export declare function Pager({ page, has_more, go }: {
|
package/dist/web/react/table.js
CHANGED
|
@@ -1,9 +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,
|
|
4
|
-
|
|
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";
|
|
8
|
+
import { placement_at } from "../tree.js";
|
|
9
|
+
import { virtual_window } from "../virtual.js";
|
|
7
10
|
import { ContextMenu, Popover } from "./menus.js";
|
|
8
11
|
import { ResizeHandle } from "./split.js";
|
|
9
12
|
const is_empty = (value) => value === null || value === undefined || value === "";
|
|
@@ -15,9 +18,48 @@ function filtered_tree(rows, passes, get_children, kept_children) {
|
|
|
15
18
|
return passes(row) || children.length > 0;
|
|
16
19
|
});
|
|
17
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
|
+
}
|
|
18
59
|
// ── Table ────────────────────────────────────────────────────────────────────
|
|
19
60
|
export function DataTable(props) {
|
|
20
|
-
const { columns,
|
|
61
|
+
const { columns, row_key, caption, get_children, row_passes, groups, allow_inside_drop = true, fill_last_column: fill_last = false } = props;
|
|
62
|
+
const rows = useMemo(() => groups ? groups.flatMap((group) => group.rows) : props.rows, [groups, props.rows]);
|
|
21
63
|
const [own_expanded, set_own_expanded] = useState(true);
|
|
22
64
|
const [open_filter, set_open_filter] = useState(null);
|
|
23
65
|
const filter_closed = useRef({ id: "", at: 0 });
|
|
@@ -27,9 +69,14 @@ export function DataTable(props) {
|
|
|
27
69
|
const resizing = useRef(false);
|
|
28
70
|
const header_cells = useRef(new Map());
|
|
29
71
|
const [measured, set_measured] = useState({});
|
|
72
|
+
const [row_dragging, set_row_dragging] = useState(false);
|
|
73
|
+
const [row_drop, set_row_drop] = useState(null);
|
|
30
74
|
const by_id = useMemo(() => new Map(columns.map((column) => [column_id(column), column])), [columns]);
|
|
31
75
|
const all_ids = columns.map(column_id);
|
|
32
|
-
const
|
|
76
|
+
const manual = props.manual_sorting === true;
|
|
77
|
+
const is_sortable = (column) => Boolean(column) && (manual ? column.sortable !== false : Boolean(column.sort));
|
|
78
|
+
// Rows that come sorted keep every key, even one naming no column, so a click never drops it.
|
|
79
|
+
const sorting = manual ? sorting_list(props.sorting) : sorting_list(props.sorting).filter((key) => is_sortable(by_id.get(key.key)));
|
|
33
80
|
const hidden = props.hidden_columns ?? [];
|
|
34
81
|
const widths = props.column_widths ?? {};
|
|
35
82
|
const expanded = props.expanded ?? own_expanded;
|
|
@@ -61,7 +108,7 @@ export function DataTable(props) {
|
|
|
61
108
|
getRowId: row_key ? (row) => String(row_key(row)) : undefined,
|
|
62
109
|
getSubRows: children_of ? (row) => children_of(row) ?? [] : undefined,
|
|
63
110
|
state: {
|
|
64
|
-
sorting: sorting.map((key) => ({ id: key.key, desc: key.descending })),
|
|
111
|
+
sorting: sorting.filter((key) => by_id.has(key.key)).map((key) => ({ id: key.key, desc: key.descending })),
|
|
65
112
|
columnOrder: ordered_columns(props.column_order ?? all_ids, all_ids),
|
|
66
113
|
columnVisibility: Object.fromEntries(hidden.map((id) => [id, false])),
|
|
67
114
|
expanded: expanded === true ? true : Object.fromEntries(expanded.map((id) => [id, true])),
|
|
@@ -70,13 +117,15 @@ export function DataTable(props) {
|
|
|
70
117
|
getSortedRowModel: getSortedRowModel(),
|
|
71
118
|
getExpandedRowModel: getExpandedRowModel(),
|
|
72
119
|
getRowCanExpand: (row) => row.subRows.length > 0,
|
|
120
|
+
manualSorting: manual,
|
|
73
121
|
});
|
|
74
|
-
const
|
|
75
|
-
|
|
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) {
|
|
76
125
|
const open = expanded === true
|
|
77
126
|
? table.getCoreRowModel().flatRows.filter((each) => each.subRows.length > 0).map((each) => each.id)
|
|
78
127
|
: expanded;
|
|
79
|
-
const next =
|
|
128
|
+
const next = is_open ? open.filter((each) => each !== id) : [...open, id];
|
|
80
129
|
if (props.expanded === undefined)
|
|
81
130
|
set_own_expanded(next);
|
|
82
131
|
props.on_expanded_change?.(next);
|
|
@@ -96,7 +145,7 @@ export function DataTable(props) {
|
|
|
96
145
|
return;
|
|
97
146
|
const sizes = Object.fromEntries([...header_cells.current].map(([id, cell]) => [id, cell.offsetWidth]));
|
|
98
147
|
set_measured((before) => JSON.stringify(before) === JSON.stringify(sizes) ? before : sizes);
|
|
99
|
-
}, [
|
|
148
|
+
}, [shown_ids, props.on_column_widths_change]);
|
|
100
149
|
function resize(column, width, done) {
|
|
101
150
|
props.on_column_widths_change?.({ ...widths, [column_id(column)]: width }, done);
|
|
102
151
|
}
|
|
@@ -162,13 +211,72 @@ export function DataTable(props) {
|
|
|
162
211
|
const fixed = column.hideable === false || (visible && shown.length === 1);
|
|
163
212
|
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)) };
|
|
164
213
|
});
|
|
214
|
+
// ── Dragging rows ──
|
|
215
|
+
// A drag started in this table lands anywhere, and any drag lands where the app's rule allows it.
|
|
216
|
+
function row_placement(row, event) {
|
|
217
|
+
if (!props.on_row_drop)
|
|
218
|
+
return null;
|
|
219
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
220
|
+
const placement = placement_at(box.height > 0 ? (event.clientY - box.top) / box.height : 0.5, allow_inside_drop);
|
|
221
|
+
const allowed = props.can_drop_on_row ? props.can_drop_on_row(row, placement) : row_dragging;
|
|
222
|
+
return allowed ? placement : null;
|
|
223
|
+
}
|
|
224
|
+
function row_drag_start(row, id, event) {
|
|
225
|
+
set_row_dragging(true);
|
|
226
|
+
props.on_row_drag_start(row, event);
|
|
227
|
+
// Some browsers start a drag only once it carries data.
|
|
228
|
+
if (event.dataTransfer && event.dataTransfer.types.length === 0)
|
|
229
|
+
event.dataTransfer.setData("text/plain", id);
|
|
230
|
+
}
|
|
231
|
+
function row_drag_over(row, id, event) {
|
|
232
|
+
const placement = row_placement(row, event);
|
|
233
|
+
if (!placement) {
|
|
234
|
+
if (row_drop?.id === id)
|
|
235
|
+
set_row_drop(null);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
if (row_drop?.id !== id || row_drop.placement !== placement)
|
|
240
|
+
set_row_drop({ id, placement });
|
|
241
|
+
}
|
|
242
|
+
function row_drag_drop(row, event) {
|
|
243
|
+
const placement = row_placement(row, event);
|
|
244
|
+
set_row_drop(null);
|
|
245
|
+
if (!placement)
|
|
246
|
+
return;
|
|
247
|
+
event.preventDefault();
|
|
248
|
+
props.on_row_drop(row, placement, event);
|
|
249
|
+
}
|
|
250
|
+
function row_drag_end() {
|
|
251
|
+
set_row_dragging(false);
|
|
252
|
+
set_row_drop(null);
|
|
253
|
+
props.on_row_drag_end?.();
|
|
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
|
+
};
|
|
165
272
|
// ── Drawing ──
|
|
166
273
|
function header(column) {
|
|
167
274
|
const id = column_id(column);
|
|
168
275
|
const index = sorting.findIndex((key) => key.key === id);
|
|
169
276
|
const key = sorting[index];
|
|
170
|
-
const sortable =
|
|
171
|
-
const
|
|
277
|
+
const sortable = is_sortable(column) && Boolean(props.on_sort || props.on_sorting_change);
|
|
278
|
+
const filling = fill_last && column === shown[shown.length - 1];
|
|
279
|
+
const width = filling ? undefined : width_of(column);
|
|
172
280
|
const movable = can_move(column);
|
|
173
281
|
const class_name = [
|
|
174
282
|
column.class, column.filter_active && "filtered", dragged === id && "dragged", drop_target?.id === id && (drop_target.after ? "drop-after" : "drop-before"),
|
|
@@ -177,29 +285,106 @@ export function DataTable(props) {
|
|
|
177
285
|
return (_jsxs("th", { ref: (cell) => { if (cell)
|
|
178
286
|
header_cells.current.set(id, cell);
|
|
179
287
|
else
|
|
180
|
-
header_cells.current.delete(id); }, className: class_name, scope: "col", style: width
|
|
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) => {
|
|
181
289
|
if (resizing.current)
|
|
182
290
|
return event.preventDefault();
|
|
183
291
|
event.dataTransfer.setData("text/plain", id);
|
|
184
292
|
set_dragged(id);
|
|
185
|
-
}, 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));
|
|
186
294
|
}
|
|
187
|
-
function body_row(row) {
|
|
295
|
+
function body_row(row, key) {
|
|
188
296
|
const original = row.original;
|
|
189
|
-
const
|
|
190
|
-
return (_jsx(
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
297
|
+
const keyed = props.on_row_key_down !== undefined;
|
|
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));
|
|
299
|
+
}
|
|
300
|
+
const row_line = (row) => ({ kind: "row", row, key: row_key ? String(row_key(row.original)) : row.id });
|
|
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.
|
|
302
|
+
function grouped_lines(groups) {
|
|
303
|
+
const group_of = new Map();
|
|
304
|
+
groups.forEach((group, index) => group.rows.forEach((row) => { if (!group_of.has(row))
|
|
305
|
+
group_of.set(row, index); }));
|
|
306
|
+
const rows_by_group = groups.map(() => []);
|
|
307
|
+
let current = 0;
|
|
308
|
+
for (const row of table.getRowModel().rows) {
|
|
309
|
+
if (row.depth === 0)
|
|
310
|
+
current = group_of.get(row.original) ?? current;
|
|
311
|
+
rows_by_group[current].push(row);
|
|
312
|
+
}
|
|
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}`));
|
|
198
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);
|
|
199
383
|
const filtered_column = open_filter ? by_id.get(open_filter.id) : undefined;
|
|
200
384
|
const filter_body = filtered_column?.filter;
|
|
201
385
|
const tools = (props.on_clear_filters && any_filter) || props.on_hidden_columns_change;
|
|
202
|
-
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) }) }),
|
|
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) })] }));
|
|
203
388
|
}
|
|
204
389
|
// ── Pages ────────────────────────────────────────────────────────────────────
|
|
205
390
|
// Newer and older buttons around the page number, or nothing when everything fits on one page.
|
package/dist/web/react/time.d.ts
CHANGED
package/dist/web/react/time.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
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.
|
|
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";
|
|
3
4
|
import { age, stamp } from "../format.js";
|
|
4
5
|
// A time as its age, such as "3h ago", or the missing text, a dash unless told otherwise, when there is none.
|
|
5
6
|
export function Age({ epoch, missing = "—" }) {
|
|
@@ -7,3 +8,12 @@ export function Age({ epoch, missing = "—" }) {
|
|
|
7
8
|
return _jsx(_Fragment, { children: missing });
|
|
8
9
|
return _jsx("span", { title: stamp(epoch), children: age(epoch) });
|
|
9
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
|
+
}
|