@x-tech-laboratories/x-libs 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +40 -11
  2. package/dist/backups.d.ts +31 -0
  3. package/dist/backups.js +43 -0
  4. package/dist/durations.d.ts +1 -0
  5. package/dist/durations.js +10 -0
  6. package/dist/electron/app.d.ts +13 -0
  7. package/dist/electron/app.js +27 -0
  8. package/dist/electron/bridge.d.ts +3 -1
  9. package/dist/electron/bridge.js +21 -0
  10. package/dist/electron/events.d.ts +21 -0
  11. package/dist/electron/events.js +25 -0
  12. package/dist/electron/fetch.d.ts +25 -0
  13. package/dist/electron/fetch.js +67 -0
  14. package/dist/electron/window.d.ts +11 -0
  15. package/dist/electron/window.js +49 -6
  16. package/dist/log.d.ts +30 -0
  17. package/dist/log.js +79 -0
  18. package/dist/server/backup_files.d.ts +3 -0
  19. package/dist/server/backup_files.js +25 -0
  20. package/dist/web/drag.d.ts +3 -0
  21. package/dist/web/drag.js +13 -0
  22. package/dist/web/format.d.ts +1 -0
  23. package/dist/web/format.js +5 -0
  24. package/dist/web/prefs.d.ts +12 -0
  25. package/dist/web/prefs.js +62 -0
  26. package/dist/web/react/controls.d.ts +39 -4
  27. package/dist/web/react/controls.js +51 -7
  28. package/dist/web/react/dialog.d.ts +12 -2
  29. package/dist/web/react/dialog.js +7 -3
  30. package/dist/web/react/layout.d.ts +26 -6
  31. package/dist/web/react/layout.js +15 -6
  32. package/dist/web/react/list_picker.d.ts +19 -0
  33. package/dist/web/react/list_picker.js +50 -0
  34. package/dist/web/react/media.d.ts +24 -0
  35. package/dist/web/react/media.js +18 -0
  36. package/dist/web/react/menu_button.d.ts +10 -0
  37. package/dist/web/react/menu_button.js +12 -0
  38. package/dist/web/react/menus.js +26 -2
  39. package/dist/web/react/prefs.d.ts +2 -0
  40. package/dist/web/react/prefs.js +11 -0
  41. package/dist/web/react/reveal.d.ts +10 -0
  42. package/dist/web/react/reveal.js +30 -0
  43. package/dist/web/react/shell.d.ts +8 -2
  44. package/dist/web/react/shell.js +17 -6
  45. package/dist/web/react/table.d.ts +31 -1
  46. package/dist/web/react/table.js +209 -24
  47. package/dist/web/react/time.d.ts +1 -0
  48. package/dist/web/react/time.js +11 -1
  49. package/dist/web/react/toasts.d.ts +12 -0
  50. package/dist/web/react/toasts.js +31 -0
  51. package/dist/web/react/tree.d.ts +7 -2
  52. package/dist/web/react/tree.js +22 -7
  53. package/dist/web/react/watch.d.ts +3 -0
  54. package/dist/web/react/watch.js +5 -0
  55. package/dist/web/selection.d.ts +25 -0
  56. package/dist/web/selection.js +106 -0
  57. package/dist/web/tables.d.ts +1 -0
  58. package/dist/web/tables.js +8 -0
  59. package/dist/web/tree.d.ts +8 -3
  60. package/dist/web/tree.js +31 -8
  61. package/dist/web/virtual.d.ts +7 -0
  62. package/dist/web/virtual.js +10 -0
  63. package/dist/web/watch.d.ts +6 -0
  64. package/dist/web/watch.js +17 -0
  65. package/package.json +1 -1
@@ -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
+ }
@@ -92,13 +92,37 @@ function use_menu_keys(container, dismiss, whole_menu) {
92
92
  }
93
93
  };
94
94
  }
95
- // A menu's buttons, also for use inside a Popover, such as a column filter's choices.
95
+ // Which radio run each item is in: radio items side by side, broken by a separator or another item.
96
+ function radio_runs(items) {
97
+ let run = 0;
98
+ return items.map((item, index) => {
99
+ const previous = items[index - 1];
100
+ if (!item.radio || item.separator_before || !previous?.radio)
101
+ run += 1;
102
+ return run;
103
+ });
104
+ }
105
+ // A menu's buttons, also for use inside a Popover, such as a column filter's choices; an item that keeps the menu open ticks at once, until new items arrive.
96
106
  export function MenuList({ items, on_close }) {
97
- return (_jsx(_Fragment, { children: items.map((item) => (_jsxs(Fragment, { children: [item.separator_before && _jsx("div", { className: "menu-separator", role: "separator" }), _jsxs("button", { type: "button", className: item.danger ? "menu-item danger" : "menu-item", role: item.checked === undefined ? "menuitem" : item.radio ? "menuitemradio" : "menuitemcheckbox", "aria-checked": item.checked, "aria-disabled": item.disabled || undefined, tabIndex: -1, onClick: () => {
107
+ const [ticks, set_ticks] = useState({ items, checked: {} });
108
+ const checked_now = ticks.items === items ? ticks.checked : {};
109
+ const runs = radio_runs(items);
110
+ const is_checked = (item) => item.checked === undefined ? undefined : checked_now[item.label] ?? item.checked;
111
+ function tick(item, index) {
112
+ if (item.checked === undefined)
113
+ return;
114
+ const changes = item.radio
115
+ ? Object.fromEntries(items.filter((other, other_index) => other.radio && runs[other_index] === runs[index]).map((other) => [other.label, other === item]))
116
+ : { [item.label]: !is_checked(item) };
117
+ set_ticks({ items, checked: { ...checked_now, ...changes } });
118
+ }
119
+ return (_jsx(_Fragment, { children: items.map((item, index) => (_jsxs(Fragment, { children: [item.separator_before && _jsx("div", { className: "menu-separator", role: "separator" }), _jsxs("button", { type: "button", className: item.danger ? "menu-item danger" : "menu-item", role: item.checked === undefined ? "menuitem" : item.radio ? "menuitemradio" : "menuitemcheckbox", "aria-checked": is_checked(item), "aria-disabled": item.disabled || undefined, tabIndex: -1, onClick: () => {
98
120
  if (item.disabled)
99
121
  return;
100
122
  if (!item.keep_open)
101
123
  on_close();
124
+ else
125
+ tick(item, index);
102
126
  item.run();
103
127
  }, children: [_jsx("span", { className: "menu-label", children: item.label }), item.hint && _jsx("span", { className: "menu-hint", children: item.hint })] })] }, item.label))) }));
104
128
  }
@@ -0,0 +1,2 @@
1
+ import { type StorageLike, type Validator } from "../prefs.ts";
2
+ export declare function use_pref<Value>(key: string, initial: Value, validate?: Validator<Value>, storage?: StorageLike | undefined): [Value, (next: Value) => void];
@@ -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: string | null;
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 {};
@@ -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 [menu_open, set_menu_open] = useState(false);
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
- }, [logged_out, menu_open]);
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: "#/", className: "brand", children: brand }), !logged_out && tools, !logged_out && _jsx(ThemeButton, { choice: theme }), !logged_out && (user_href ? _jsx("a", { className: "account-link", href: user_href, children: user }) : _jsx("span", { className: "user-name", children: user })), !logged_out && on_logout && _jsx("button", { onClick: on_logout, children: "Log out" })] }), _jsxs("div", { className: "layout", style: width === undefined ? undefined : { "--sidebar-width": `${width}px` }, children: [_jsxs("nav", { ref: sidebar, id: "sidebar", className: "sidebar", "aria-label": "Main", children: [sidebar_content ?? sections.map((section) => (_jsx("a", { href: section.href, "data-section": section.id, className: section.id === active ? "active" : undefined, "aria-current": section.id === active ? "page" : undefined, children: section.label }, section.id))), sidebar_foot, sidebar_width && width !== undefined && (_jsx(ResizeHandle, { side: "left", size: width, min: sidebar_width.min, max: sidebar_width.max, default_size: sidebar_width.default_width, on_resize: (next) => change_width(next, false), on_resize_end: (next) => change_width(next, true), label: "Resize the sidebar", class_name: "sidebar-resize" }))] }), _jsx("div", { className: "scrim", hidden: !menu_open, onClick: () => set_menu_open(false) }), _jsx("main", { ref: main, id: "main", tabIndex: -1, children: children })] }), _jsx(Toasts, {}), _jsx(Dialogs, {}), _jsx(ContextMenus, {})] }));
91
+ return (_jsxs(_Fragment, { children: [_jsx("a", { className: "skip", href: "#main", children: "Skip to content" }), _jsxs("header", { className: "topbar", children: [_jsx("button", { ref: menu_button, className: "icon menu-button", "aria-label": "Open the menu", "aria-expanded": menu_open, "aria-controls": "sidebar", onClick: () => set_menu_open(!menu_open), children: "\u2630" }), _jsx("a", { href: brand_href, className: "brand", children: brand }), !logged_out && tools, !logged_out && _jsx(ThemeButton, { choice: theme }), user_name !== undefined && (user_href ? _jsx("a", { className: "account-link", href: user_href, children: user_name }) : _jsx("span", { className: "user-name", children: user_name })), user_name !== undefined && on_logout && _jsx("button", { onClick: on_logout, children: "Log out" })] }), _jsxs("div", { className: "layout", style: width === undefined ? undefined : { "--sidebar-width": `${width}px` }, children: [_jsxs("nav", { ref: sidebar, id: "sidebar", className: "sidebar", "aria-label": "Main", children: [sidebar_content !== undefined && sidebar_content !== null ? _jsx("div", { className: "sidebar-content", children: sidebar_content }) : sections.map((section) => (_jsx("a", { href: section.href, "data-section": section.id, className: section.id === active ? "active" : undefined, "aria-current": section.id === active ? "page" : undefined, children: section.label }, section.id))), sidebar_foot, sidebar_width && width !== undefined && (_jsx(ResizeHandle, { side: "left", size: width, min: sidebar_width.min, max: sidebar_width.max, default_size: sidebar_width.default_width, on_resize: (next) => change_width(next, false), on_resize_end: (next) => change_width(next, true), label: "Resize the sidebar", class_name: "sidebar-resize" }))] }), _jsx("div", { className: "scrim", hidden: !menu_open, onClick: () => set_menu_open(false) }), _jsx("main", { ref: main, id: "main", className: main_class, tabIndex: -1, children: children })] }), status_bar !== undefined && _jsx("footer", { className: "status-bar", children: status_bar }), _jsx(Toasts, {}), _jsx(Dialogs, {}), _jsx(ContextMenus, {})] }));
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 }: {
@@ -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, and cards on phones.
4
- import { useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ // hides and filters, rows nested under rows, rows in groups under headings, rows dragged by the app's rules, long lists drawn
4
+ // only where they show, and cards on phones.
5
+ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
5
6
  import { getCoreRowModel, getExpandedRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
6
7
  import { clamp_width, column_id, compare_values, move_column, ordered_columns, sort_after_click, sorting_list } from "../tables.js";
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, rows, row_key, caption, get_children, row_passes } = props;
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 sorting = sorting_list(props.sorting).filter((key) => by_id.get(key.key)?.sort);
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 shown = table.getVisibleLeafColumns().map((column) => by_id.get(column.id));
75
- function toggle_row(row) {
122
+ const shown_ids = table.getVisibleLeafColumns().map((column) => column.id).join("\n");
123
+ const shown = useMemo(() => shown_ids.split("\n").filter(Boolean).map((id) => by_id.get(id)), [shown_ids, by_id]);
124
+ function toggle_row(id, is_open) {
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 = row.getIsExpanded() ? open.filter((id) => id !== row.id) : [...open, row.id];
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
- }, [shown.map(column_id).join("\n"), props.on_column_widths_change]);
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 = Boolean(column.sort) && Boolean(props.on_sort || props.on_sorting_change);
171
- const width = width_of(column);
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 === undefined ? undefined : { width, minWidth: width, maxWidth: width }, "aria-sort": sortable ? (key ? (key.descending ? "descending" : "ascending") : "none") : undefined, draggable: movable || undefined, title: movable ? "Drag to move this column" : undefined, onPointerDownCapture: (event) => { resizing.current = event.target.classList.contains("resize-handle"); }, onDragStart: (event) => {
288
+ header_cells.current.delete(id); }, className: class_name, scope: "col", style: width !== undefined ? { width, minWidth: width, maxWidth: width } : filling && column.min_width !== undefined ? { minWidth: column.min_width } : undefined, "aria-sort": sortable ? (key ? (key.descending ? "descending" : "ascending") : "none") : undefined, draggable: movable || undefined, title: movable ? "Drag to move this column" : undefined, onPointerDownCapture: (event) => { resizing.current = event.target.classList.contains("resize-handle"); }, onDragStart: (event) => {
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 extra = props.row_class?.(original);
190
- return (_jsx("tr", { className: extra, onClick: props.on_row_click && ((event) => props.on_row_click(original, event)), onContextMenu: props.on_row_context_menu && ((event) => props.on_row_context_menu(original, event)), children: shown.map((column, index) => {
191
- const label = column.label;
192
- if (!tree || index > 0)
193
- return _jsx("td", { className: column.class, "data-label": label, children: column.cell(original) }, column_id(column));
194
- const open = row.getIsExpanded();
195
- const count = row.subRows.length;
196
- return (_jsxs("td", { className: [column.class, "tree-cell"].filter(Boolean).join(" "), "data-label": label, style: { "--tree-depth": row.depth }, children: [count > 0 ? (_jsx("button", { type: "button", className: "icon tree-toggle", "aria-expanded": open, "aria-label": open ? "Collapse" : "Expand", onClick: (event) => { event.stopPropagation(); toggle_row(row); }, children: open ? "▾" : "▸" })) : _jsx("span", { className: "tree-spacer" }), column.cell(original), count > 0 && !open && _jsx("span", { className: "tree-count", title: `${count} inside`, children: count })] }, column_id(column)));
197
- }) }, row.id));
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) }) }), _jsx("tbody", { children: table.getRowModel().rows.map(body_row) })] }) }), open_filter && filtered_column && filter_body !== undefined && (_jsx(Popover, { anchor: open_filter.anchor, label: `Filter by ${filtered_column.label}`, on_close: close_filter, children: typeof filter_body === "function" ? filter_body(close_filter) : filter_body })), columns_menu && _jsx(ContextMenu, { x: columns_menu.x, y: columns_menu.y, label: "Columns", items: column_choices, on_close: () => set_columns_menu(null) })] }));
386
+ return (_jsxs(_Fragment, { children: [tools && (_jsxs("div", { className: "table-tools", children: [props.on_clear_filters && any_filter && _jsx("button", { type: "button", onClick: props.on_clear_filters, children: "Clear filters" }), props.on_hidden_columns_change && _jsx("button", { type: "button", "aria-haspopup": "menu", onClick: open_columns_menu, children: "Columns" })] })), _jsx("div", { ref: wrap, className: props.virtual ? "table-wrap virtual" : "table-wrap", style: props.virtual ? { overflowY: "auto" } : undefined, onScroll: line_height === undefined ? undefined : measure, children: _jsxs("table", { className: fill_last ? "responsive fill-last-column" : "responsive", style: fills_by_fixed ? { tableLayout: "fixed" } : undefined, children: [caption && _jsx("caption", { className: "visually-hidden", children: caption }), _jsx("thead", { ref: head, onContextMenu: props.on_hidden_columns_change && open_columns_menu, children: _jsx("tr", { children: shown.map(header) }) }), _jsxs("tbody", { onFocus: () => { rows_focused.current = true; }, onBlur: (event) => { if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget))
387
+ rows_focused.current = false; }, children: [windowed && spacer(windowed.offset_px, "spacer before"), drawn.map(draw_line), windowed && spacer(windowed.total_px - windowed.end * line_height, "spacer after")] })] }) }), open_filter && filtered_column && filter_body !== undefined && (_jsx(Popover, { anchor: open_filter.anchor, label: `Filter by ${filtered_column.label}`, on_close: close_filter, children: typeof filter_body === "function" ? filter_body(close_filter) : filter_body })), columns_menu && _jsx(ContextMenu, { x: columns_menu.x, y: columns_menu.y, label: "Columns", items: column_choices, on_close: () => set_columns_menu(null) })] }));
203
388
  }
204
389
  // ── Pages ────────────────────────────────────────────────────────────────────
205
390
  // Newer and older buttons around the page number, or nothing when everything fits on one page.
@@ -2,3 +2,4 @@ export declare function Age({ epoch, missing }: {
2
2
  epoch: number | null | undefined;
3
3
  missing?: string;
4
4
  }): import("react").JSX.Element;
5
+ export declare function use_now(interval_in_milliseconds?: number): Date;
@@ -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
+ }