@x-tech-laboratories/x-libs 0.3.0 → 0.6.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 (67) hide show
  1. package/README.md +63 -10
  2. package/dist/dates.d.ts +9 -0
  3. package/dist/dates.js +60 -0
  4. package/dist/durations.d.ts +8 -1
  5. package/dist/durations.js +9 -5
  6. package/dist/electron/bridge.d.ts +30 -0
  7. package/dist/electron/bridge.js +32 -0
  8. package/dist/electron/window.d.ts +85 -0
  9. package/dist/electron/window.js +85 -0
  10. package/dist/web/menus.d.ts +4 -7
  11. package/dist/web/menus.js +28 -86
  12. package/dist/web/react/badges.d.ts +26 -0
  13. package/dist/web/react/badges.js +28 -0
  14. package/dist/web/react/controls.d.ts +37 -0
  15. package/dist/web/react/controls.js +44 -0
  16. package/dist/web/react/dialog.d.ts +35 -0
  17. package/dist/web/react/dialog.js +78 -0
  18. package/dist/web/react/layout.d.ts +80 -0
  19. package/dist/web/react/layout.js +71 -0
  20. package/dist/web/react/menus.d.ts +31 -0
  21. package/dist/web/react/menus.js +159 -0
  22. package/dist/web/react/shell.d.ts +37 -0
  23. package/dist/web/react/shell.js +81 -0
  24. package/dist/web/react/shortcuts.d.ts +23 -0
  25. package/dist/web/react/shortcuts.js +95 -0
  26. package/dist/web/{preact → react}/split.d.ts +4 -4
  27. package/dist/web/{preact → react}/split.js +9 -10
  28. package/dist/web/react/states.d.ts +27 -0
  29. package/dist/web/react/states.js +49 -0
  30. package/dist/web/react/table.d.ts +46 -0
  31. package/dist/web/react/table.js +210 -0
  32. package/dist/web/react/time.d.ts +4 -0
  33. package/dist/web/react/time.js +9 -0
  34. package/dist/web/{toasts.d.ts → react/toasts.d.ts} +4 -1
  35. package/dist/web/react/toasts.js +41 -0
  36. package/dist/web/react/tree.d.ts +23 -0
  37. package/dist/web/react/tree.js +168 -0
  38. package/dist/web/states.d.ts +1 -8
  39. package/dist/web/states.js +6 -43
  40. package/dist/web/tables.d.ts +21 -14
  41. package/dist/web/tables.js +69 -33
  42. package/dist/web/theme.d.ts +7 -2
  43. package/dist/web/theme.js +35 -22
  44. package/dist/web/tree.d.ts +35 -0
  45. package/dist/web/tree.js +107 -0
  46. package/package.json +15 -4
  47. package/dist/web/badges.d.ts +0 -10
  48. package/dist/web/badges.js +0 -30
  49. package/dist/web/controls.d.ts +0 -6
  50. package/dist/web/controls.js +0 -55
  51. package/dist/web/dialogs.d.ts +0 -18
  52. package/dist/web/dialogs.js +0 -49
  53. package/dist/web/layout.d.ts +0 -28
  54. package/dist/web/layout.js +0 -53
  55. package/dist/web/preact/controls.d.ts +0 -24
  56. package/dist/web/preact/controls.js +0 -36
  57. package/dist/web/preact/dialog.d.ts +0 -11
  58. package/dist/web/preact/dialog.js +0 -20
  59. package/dist/web/preact/layout.d.ts +0 -80
  60. package/dist/web/preact/layout.js +0 -69
  61. package/dist/web/preact/menus.d.ts +0 -18
  62. package/dist/web/preact/menus.js +0 -29
  63. package/dist/web/preact/table.d.ts +0 -18
  64. package/dist/web/preact/table.js +0 -13
  65. package/dist/web/shell.d.ts +0 -7
  66. package/dist/web/shell.js +0 -51
  67. package/dist/web/toasts.js +0 -22
package/README.md CHANGED
@@ -6,7 +6,10 @@ Code the X-apps share, published as one npm package, `@x-tech-laboratories/x-lib
6
6
 
7
7
  | Import | What it holds |
8
8
  | ---------------------------------------- | ----------------------------------------------------------------------------- |
9
- | `durations` | `parse_duration` for settings such as `"2h30m"`, `format_duration`, `format_age`. |
9
+ | `durations` | `parse_duration` for settings such as `"2h30m"`, `format_duration` in months down to seconds, `format_age`. |
10
+ | `dates` | Local dates as `"2026-06-10"` or `"2026-06-10T09:30"`: reading, writing, adding days and months, overdue, and Yesterday/Today/Tomorrow labels. |
11
+ | `electron/bridge` | `define_bridge`: one method table for the preload, the main process, and a web page's calls to its server. |
12
+ | `electron/window` | `create_window`: links open in the browser, the background follows the system theme, and the size and place are remembered. |
10
13
  | `agent/commands` | `run_command` and `stream_command`: one shell command, in its own process group. |
11
14
  | `agent/long_runs` | `LongRuns`: commands that outlive the agent, their output and exit code kept on disk. |
12
15
  | `agent/keys` | Reads and makes OpenSSH ed25519 agent keys; signs and checks the login challenge. |
@@ -17,12 +20,16 @@ Code the X-apps share, published as one npm package, `@x-tech-laboratories/x-lib
17
20
  | `server/agents/connection`, `agent`, `local`, `route` | One connected agent, the `Agent` interface, `LocalAgent`, and the Fastify route. |
18
21
  | `server/passwords` | scrypt hashes and the `name:hash` accounts file. |
19
22
  | `server/hash_password` | The `hash-password` command. |
20
- | `web/dom`, `web/format`, `web/router`, `web/theme` | The page's element builder, formatters, hash router and theme choice. |
21
- | `web/layout`, `web/badges`, `web/states` | Panels, facts, tiles, cards, tabs, crumbs, toolbars, steppers; badges and chips; loading, empty and failed states. |
22
- | `web/controls`, `web/tables` | Search boxes, selects, segmented choices, switches, button groups; sortable tables that turn into cards on phones, and a pager. |
23
- | `web/toasts`, `web/dialogs`, `web/menus` | Toasts with a link or Undo, modal and confirm dialogs, right-click menus and popovers kept on screen. |
24
- | `web/split`, `web/shell` | Pane sizing and resize handles; the sidebar menu, the section it lights up, and the theme button. |
25
- | `web/preact/*` | The same widgets as Preact components: menus, split, controls, layout, dialog, table. |
23
+ | `web/dom`, `web/format`, `web/router`, `web/theme` | The page's element builder, formatters, hash router and theme choice, kept in this browser or the app's settings. |
24
+ | `web/states`, `web/tables`, `web/menus`, `web/split` | `LoginNeeded`; sorting by several columns, trees and column order; menu items, placement and keys; pane sizing. |
25
+ | `web/tree` | Rows with `parent_id` and `position`: nesting, subtrees, and where a dropped or keyed row lands. |
26
+ | `web/react/shell` | `Shell`: top bar, section links or the app's own sidebar, resizable, theme button, toasts, dialogs and menus; `use_route`. |
27
+ | `web/react/layout`, `badges`, `states` | Panels, facts, tiles, cards, tabs, crumbs, steppers, settings rows; badges and chips; `use_resource` and its loading states. |
28
+ | `web/react/time` | `Age`: a time as how long ago, with the full stamp on hover. |
29
+ | `web/react/controls`, `table` | Segmented choices, button groups, switches, search boxes, selects; `DataTable` on TanStack Table, sorting by several columns, with columns to move, resize, hide and filter, tree rows, and cards on phones; a pager. |
30
+ | `web/react/shortcuts` | `use_shortcuts` for keys such as `"mod+z"`, quiet while typing or while a dialog or popover is open; `describe_keys` for help text. |
31
+ | `web/react/tree` | `Tree`: nested rows that fold, walk with the arrow keys, and reorder by dragging or with Alt and the arrow keys. |
32
+ | `web/react/toasts`, `dialog`, `menus`, `split` | Toasts with a link or Undo that say how they closed; modal, confirm and text dialogs openable from anywhere; context menus and popovers worked by keyboard, openable from anywhere; resize handles and splits. |
26
33
 
27
34
  ```ts
28
35
  import { AgentHub } from "@x-tech-laboratories/x-libs/server/agents/hub";
@@ -37,11 +44,57 @@ which an app imports once:
37
44
  ```ts
38
45
  import "@x-tech-laboratories/halon/halon-tokens.css";
39
46
  import "@x-tech-laboratories/halon/halon.css";
40
- import { data_table } from "@x-tech-laboratories/x-libs/web/tables";
41
- import { ContextMenu } from "@x-tech-laboratories/x-libs/web/preact/menus";
47
+ import { DataTable } from "@x-tech-laboratories/x-libs/web/react/table";
48
+ import { toast } from "@x-tech-laboratories/x-libs/web/react/toasts";
42
49
  ```
43
50
 
44
- A request whose session has lapsed throws `LoginNeeded` from `web/states`; `load` and `attempt` pass it on so the page can ask for a login.
51
+ The widgets are React components, so a page renders with React through `createRoot` from `react-dom/client`; `Shell`
52
+ holds the toasts, dialogs and menus that `toast`, `confirm_dialog` and `open_context_menu` open from anywhere. `DataTable`
53
+ is drawn on `@tanstack/react-table` with Halon's markup; its sorting, column order, widths, hidden columns and open rows
54
+ are props with change callbacks, so the app keeps them in its own settings, and a column's filter is the app's own
55
+ content in a popover X-Libs opens from the column's funnel. A request whose session has lapsed
56
+ throws `LoginNeeded` from `web/states`, and `use_resource` passes it on as an unhandled rejection, which the page's
57
+ login listens for.
58
+
59
+ ## Shortcuts and trees
60
+
61
+ ```tsx
62
+ import { use_shortcuts } from "@x-tech-laboratories/x-libs/web/react/shortcuts";
63
+ import { Tree } from "@x-tech-laboratories/x-libs/web/react/tree";
64
+
65
+ use_shortcuts([
66
+ { keys: "mod+z", run: undo },
67
+ { keys: "escape", run: clear_picks },
68
+ ]);
69
+
70
+ <Tree items={tags} label="Tags" render_item={(tag) => tag.name} collapsed={collapsed} on_collapsed_change={set_collapsed}
71
+ on_move={(tag, { parent_id, position }) => move_tag(tag.id, parent_id, position)} />
72
+ ```
73
+
74
+ A shortcut stays quiet in text fields unless it has `when: "always"`, and while a dialog or popover is open unless it
75
+ has `over_overlays: true`. The tree builds Halon's `.tree` markup; a row dropped onto its top or bottom quarter lands
76
+ beside it, and onto its middle inside it.
77
+
78
+ ## Electron apps
79
+
80
+ The bridge and window helpers describe the few parts of Electron they use, so the app passes Electron's own objects in
81
+ and X-Libs does not depend on Electron.
82
+
83
+ ```ts
84
+ // shared
85
+ export const notes_bridge = define_bridge<NotesApi>("x_notes", ["list_notes", "add_note"]);
86
+ // preload
87
+ notes_bridge.expose(contextBridge, ipcRenderer);
88
+ // main
89
+ notes_bridge.handle(ipcMain, { list_notes, add_note });
90
+ create_window(BrowserWindow, {
91
+ title: "X-Notes", file: join(__dirname, "../renderer/index.html"), dev_url: process.env.ELECTRON_RENDERER_URL,
92
+ preload: join(__dirname, "../preload/index.mjs"), default_size: { width: 1100, height: 700 },
93
+ state_file: join(app.getPath("userData"), "window.json"), native_theme: nativeTheme, shell,
94
+ });
95
+ // web page
96
+ window.x_notes = notes_bridge.web_rpc((method, args) => post_json(`api/${method}`, args));
97
+ ```
45
98
 
46
99
  ## The app and its tasks
47
100
 
@@ -0,0 +1,9 @@
1
+ export declare function has_time(local_date: string): boolean;
2
+ export declare function parse_local(local_date: string): Date;
3
+ export declare function format_local(date: Date, with_time: boolean): string;
4
+ export declare function start_of_day(date: Date): Date;
5
+ export declare function days_between(first: Date, second: Date): number;
6
+ export declare function add_days(date: Date, days: number): Date;
7
+ export declare function add_months(date: Date, months: number): Date;
8
+ export declare function is_overdue(local_date: string, now?: Date): boolean;
9
+ export declare function relative_day_label(local_date: string, now?: Date, locale?: string): string;
package/dist/dates.js ADDED
@@ -0,0 +1,60 @@
1
+ // Local calendar dates written as "YYYY-MM-DD" or "YYYY-MM-DDTHH:MM", read and written in the device's own time zone.
2
+ const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
3
+ // ── Text and dates ───────────────────────────────────────────────────────────
4
+ export function has_time(local_date) {
5
+ return local_date.includes("T");
6
+ }
7
+ // A date without a time stands for midnight at the start of its day.
8
+ export function parse_local(local_date) {
9
+ const [day = "", time = "00:00"] = local_date.split("T");
10
+ const [year = 0, month = 1, day_of_month = 1] = day.split("-").map(Number);
11
+ const [hours = 0, minutes = 0] = time.split(":").map(Number);
12
+ return new Date(year, month - 1, day_of_month, hours, minutes);
13
+ }
14
+ export function format_local(date, with_time) {
15
+ const pad = (number) => String(number).padStart(2, "0");
16
+ const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
17
+ return with_time ? `${day}T${pad(date.getHours())}:${pad(date.getMinutes())}` : day;
18
+ }
19
+ // ── Arithmetic ───────────────────────────────────────────────────────────────
20
+ export function start_of_day(date) {
21
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
22
+ }
23
+ // Whole calendar days from the first date to the second, so a clock change in between still counts as one day.
24
+ export function days_between(first, second) {
25
+ return Math.round((start_of_day(second).getTime() - start_of_day(first).getTime()) / DAY_IN_MILLISECONDS);
26
+ }
27
+ export function add_days(date, days) {
28
+ const moved = new Date(date);
29
+ moved.setDate(moved.getDate() + days);
30
+ return moved;
31
+ }
32
+ // A day past the end of the target month falls back to its last day, so Jan 31 plus a month is Feb 28.
33
+ export function add_months(date, months) {
34
+ const last_day = new Date(date.getFullYear(), date.getMonth() + months + 1, 0).getDate();
35
+ return new Date(date.getFullYear(), date.getMonth() + months, Math.min(date.getDate(), last_day), date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds());
36
+ }
37
+ // ── Labels ───────────────────────────────────────────────────────────────────
38
+ // A date without a time is overdue only once its day has passed.
39
+ export function is_overdue(local_date, now = new Date()) {
40
+ const date = parse_local(local_date);
41
+ return has_time(local_date) ? date < now : date < start_of_day(now);
42
+ }
43
+ // Yesterday, Today or Tomorrow in the reader's language, else a short date with the year only when it is not this year, then the time if there is one.
44
+ export function relative_day_label(local_date, now = new Date(), locale) {
45
+ const date = parse_local(local_date);
46
+ const day_offset = days_between(now, date);
47
+ const day_label = Math.abs(day_offset) <= 1
48
+ ? capitalised(new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(day_offset, "day"), locale)
49
+ : date.toLocaleDateString(locale, {
50
+ month: "short",
51
+ day: "numeric",
52
+ year: date.getFullYear() === now.getFullYear() ? undefined : "numeric",
53
+ });
54
+ if (!has_time(local_date))
55
+ return day_label;
56
+ return `${day_label} ${date.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" })}`;
57
+ }
58
+ function capitalised(text, locale) {
59
+ return text.charAt(0).toLocaleUpperCase(locale) + text.slice(1);
60
+ }
@@ -1,3 +1,10 @@
1
+ declare const unit_names: readonly [readonly ["months", 2592000], readonly ["weeks", 604800], readonly ["days", 86400], readonly ["hours", 3600], readonly ["minutes", 60], readonly ["seconds", 1]];
2
+ export type DurationUnit = (typeof unit_names)[number][0];
3
+ export interface DurationWording {
4
+ separator?: string;
5
+ largest_unit?: DurationUnit;
6
+ }
1
7
  export declare function parse_duration(value: unknown, key: string): number;
2
- export declare function format_duration(seconds: number): string;
8
+ export declare function format_duration(seconds: number, wording?: DurationWording): string;
3
9
  export declare function format_age(seconds: number): string;
10
+ export {};
package/dist/durations.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Reads durations such as "2h30m" from settings and writes them back in words, for the server and the agent alike.
2
2
  import parse from "parse-duration";
3
- const unit_names = [["weeks", 604800], ["days", 86400], ["hours", 3600], ["minutes", 60], ["seconds", 1]];
3
+ // A month counts as 30 days.
4
+ const unit_names = [["months", 2592000], ["weeks", 604800], ["days", 86400], ["hours", 3600], ["minutes", 60], ["seconds", 1]];
4
5
  const words = new Intl.DurationFormat("en", { style: "long" });
5
6
  // A duration in whole seconds; `key` names the setting in the error.
6
7
  export function parse_duration(value, key) {
@@ -14,13 +15,14 @@ export function parse_duration(value, key) {
14
15
  }
15
16
  return Math.floor(seconds);
16
17
  }
17
- // The two largest whole units, such as "1 day, 2 hours".
18
- export function format_duration(seconds) {
18
+ // The two largest whole units, such as "1 day, 2 hours" or, with a space between, "1 week 2 days".
19
+ export function format_duration(seconds, wording = {}) {
19
20
  if (seconds < 1)
20
21
  return "0 seconds";
22
+ const first_unit = unit_names.findIndex(([name]) => name === (wording.largest_unit ?? "months"));
21
23
  const parts = {};
22
24
  let remaining = Math.floor(seconds);
23
- for (const [name, size] of unit_names) {
25
+ for (const [name, size] of unit_names.slice(first_unit)) {
24
26
  if (Object.keys(parts).length === 2)
25
27
  break;
26
28
  if (remaining < size)
@@ -28,7 +30,9 @@ export function format_duration(seconds) {
28
30
  parts[name] = Math.floor(remaining / size);
29
31
  remaining %= size;
30
32
  }
31
- return words.format(parts);
33
+ if (wording.separator === undefined)
34
+ return words.format(parts);
35
+ return Object.entries(parts).map(([name, count]) => words.format({ [name]: count })).join(wording.separator);
32
36
  }
33
37
  export function format_age(seconds) {
34
38
  return seconds < 0 ? "never" : `${format_duration(seconds)} ago`;
@@ -0,0 +1,30 @@
1
+ export interface IpcMainLike {
2
+ handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
3
+ removeHandler(channel: string): void;
4
+ }
5
+ export interface IpcRendererLike {
6
+ invoke(channel: string, ...args: unknown[]): Promise<unknown>;
7
+ }
8
+ export interface ContextBridgeLike {
9
+ exposeInMainWorld(api_key: string, api: unknown): void;
10
+ }
11
+ type AnyMethod = (...args: never[]) => unknown;
12
+ export type Client<Methods extends Record<keyof Methods, AnyMethod>> = {
13
+ [Name in keyof Methods]: (...args: Parameters<Methods[Name]>) => Promise<Awaited<ReturnType<Methods[Name]>>>;
14
+ };
15
+ export type Implementation<Methods extends Record<keyof Methods, AnyMethod>> = {
16
+ [Name in keyof Methods]: (...args: Parameters<Methods[Name]>) => ReturnType<Methods[Name]> | Promise<Awaited<ReturnType<Methods[Name]>>>;
17
+ };
18
+ export type CallRemote = (method: string, args: unknown[]) => Promise<unknown>;
19
+ export interface Bridge<Methods extends Record<keyof Methods, AnyMethod>> {
20
+ api_name: string;
21
+ method_names: readonly (keyof Methods & string)[];
22
+ channel: (method: keyof Methods & string) => string;
23
+ expose: (context_bridge: ContextBridgeLike, ipc_renderer: IpcRendererLike) => void;
24
+ ipc_client: (ipc_renderer: IpcRendererLike) => Client<Methods>;
25
+ handle: (ipc_main: IpcMainLike, implementation: Implementation<Methods>) => () => void;
26
+ web_rpc: (call_remote: CallRemote) => Client<Methods>;
27
+ dispatch: (implementation: Implementation<Methods>, method: string, args: unknown[]) => Promise<unknown>;
28
+ }
29
+ export declare function define_bridge<Methods extends Record<keyof Methods, AnyMethod>>(api_name: string, method_names: readonly (keyof Methods & string)[]): Bridge<Methods>;
30
+ export {};
@@ -0,0 +1,32 @@
1
+ // One table of method names serves the Electron preload, the main process and a web page's calls to its server alike.
2
+ export function define_bridge(api_name, method_names) {
3
+ const channel = (method) => `${api_name}:${method}`;
4
+ const is_method = (method) => method_names.includes(method);
5
+ function client_through(call) {
6
+ return Object.fromEntries(method_names.map((method) => [method, (...args) => call(method, args)]));
7
+ }
8
+ async function dispatch(implementation, method, args) {
9
+ if (!is_method(method))
10
+ throw new Error(`${api_name} has no method ${method}`);
11
+ const answer = implementation[method];
12
+ return answer.apply(implementation, args);
13
+ }
14
+ return {
15
+ api_name,
16
+ method_names,
17
+ channel,
18
+ dispatch,
19
+ ipc_client: (ipc_renderer) => client_through((method, args) => ipc_renderer.invoke(channel(method), ...args)),
20
+ expose: (context_bridge, ipc_renderer) => {
21
+ context_bridge.exposeInMainWorld(api_name, client_through((method, args) => ipc_renderer.invoke(channel(method), ...args)));
22
+ },
23
+ handle: (ipc_main, implementation) => {
24
+ for (const method of method_names) {
25
+ ipc_main.handle(channel(method), (_event, ...args) => dispatch(implementation, method, args));
26
+ }
27
+ return () => { for (const method of method_names)
28
+ ipc_main.removeHandler(channel(method)); };
29
+ },
30
+ web_rpc: client_through,
31
+ };
32
+ }
@@ -0,0 +1,85 @@
1
+ export interface Rectangle {
2
+ x: number;
3
+ y: number;
4
+ width: number;
5
+ height: number;
6
+ }
7
+ export interface BrowserWindowOptionsLike {
8
+ title: string;
9
+ icon?: string;
10
+ x?: number;
11
+ y?: number;
12
+ width: number;
13
+ height: number;
14
+ minWidth?: number;
15
+ minHeight?: number;
16
+ show: boolean;
17
+ backgroundColor: string;
18
+ webPreferences: {
19
+ preload?: string;
20
+ sandbox: boolean;
21
+ contextIsolation: boolean;
22
+ nodeIntegration: boolean;
23
+ };
24
+ }
25
+ export interface BrowserWindowLike {
26
+ webContents: {
27
+ setWindowOpenHandler(handler: (details: {
28
+ url: string;
29
+ }) => {
30
+ action: "deny";
31
+ }): void;
32
+ };
33
+ on(event: string, listener: () => void): unknown;
34
+ once(event: string, listener: () => void): unknown;
35
+ show(): void;
36
+ maximize(): void;
37
+ isMaximized(): boolean;
38
+ getNormalBounds(): Rectangle;
39
+ setBackgroundColor(color: string): void;
40
+ loadURL(url: string): Promise<void>;
41
+ loadFile(path: string): Promise<void>;
42
+ }
43
+ export interface NativeThemeLike {
44
+ shouldUseDarkColors: boolean;
45
+ on(event: "updated", listener: () => void): unknown;
46
+ removeListener(event: "updated", listener: () => void): unknown;
47
+ }
48
+ export interface ShellLike {
49
+ openExternal(url: string): Promise<void>;
50
+ }
51
+ export interface ScreenLike {
52
+ getAllDisplays(): {
53
+ workArea: Rectangle;
54
+ }[];
55
+ }
56
+ export interface Size {
57
+ width: number;
58
+ height: number;
59
+ }
60
+ export interface WindowOptions {
61
+ title: string;
62
+ icon?: string;
63
+ preload?: string;
64
+ dev_url?: string;
65
+ file: string;
66
+ default_size: Size;
67
+ min_size?: Size;
68
+ state_file?: string;
69
+ native_theme: NativeThemeLike;
70
+ shell: ShellLike;
71
+ screen?: ScreenLike;
72
+ background?: {
73
+ light: string;
74
+ dark: string;
75
+ };
76
+ sandbox?: boolean;
77
+ save_delay_ms?: number;
78
+ }
79
+ export interface WindowState {
80
+ bounds: Rectangle;
81
+ is_maximized: boolean;
82
+ }
83
+ export declare function read_window_state(state_file: string): WindowState | null;
84
+ export declare function write_window_state(state_file: string, state: WindowState): void;
85
+ export declare function create_window<Window extends BrowserWindowLike>(BrowserWindow: new (options: BrowserWindowOptionsLike) => Window, options: WindowOptions): Window;
@@ -0,0 +1,85 @@
1
+ // An Electron app's main window: links open in the browser, the background follows the system theme, and its size and place are remembered.
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ const DEFAULT_BACKGROUND = { light: "#f5f6f8", dark: "#0f1115" };
5
+ const DEFAULT_SAVE_DELAY_MS = 500;
6
+ // ── State file ───────────────────────────────────────────────────────────────
7
+ // A missing, unreadable or malformed file reads as no saved state.
8
+ export function read_window_state(state_file) {
9
+ try {
10
+ const state = JSON.parse(readFileSync(state_file, "utf8"));
11
+ const bounds = state.bounds;
12
+ const is_whole = (value) => Number.isInteger(value);
13
+ if (!bounds || ![bounds.x, bounds.y, bounds.width, bounds.height].every(is_whole) || bounds.width <= 0 || bounds.height <= 0)
14
+ return null;
15
+ return { bounds, is_maximized: state.is_maximized === true };
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ // Writes beside the file and renames over it, so a crash mid-write never leaves half a file.
22
+ export function write_window_state(state_file, state) {
23
+ mkdirSync(dirname(state_file), { recursive: true });
24
+ const temporary_file = `${state_file}.tmp`;
25
+ writeFileSync(temporary_file, JSON.stringify(state));
26
+ renameSync(temporary_file, state_file);
27
+ }
28
+ function overlaps(first, second) {
29
+ return first.x < second.x + second.width && second.x < first.x + first.width && first.y < second.y + second.height && second.y < first.y + first.height;
30
+ }
31
+ // ── Window ───────────────────────────────────────────────────────────────────
32
+ export function create_window(BrowserWindow, options) {
33
+ const background = options.background ?? DEFAULT_BACKGROUND;
34
+ const background_color = () => options.native_theme.shouldUseDarkColors ? background.dark : background.light;
35
+ const saved = options.state_file ? read_window_state(options.state_file) : null;
36
+ const on_a_screen = saved && (!options.screen || options.screen.getAllDisplays().some((display) => overlaps(saved.bounds, display.workArea)));
37
+ const size = saved?.bounds ?? options.default_size;
38
+ const window = new BrowserWindow({
39
+ title: options.title,
40
+ icon: options.icon,
41
+ ...(on_a_screen ? { x: saved.bounds.x, y: saved.bounds.y } : {}),
42
+ width: size.width,
43
+ height: size.height,
44
+ minWidth: options.min_size?.width,
45
+ minHeight: options.min_size?.height,
46
+ show: false,
47
+ backgroundColor: background_color(),
48
+ webPreferences: { preload: options.preload, sandbox: options.sandbox ?? false, contextIsolation: true, nodeIntegration: false },
49
+ });
50
+ window.once("ready-to-show", () => {
51
+ if (saved?.is_maximized)
52
+ window.maximize();
53
+ window.show();
54
+ });
55
+ window.webContents.setWindowOpenHandler(({ url }) => {
56
+ void options.shell.openExternal(url);
57
+ return { action: "deny" };
58
+ });
59
+ const follow_theme = () => window.setBackgroundColor(background_color());
60
+ options.native_theme.on("updated", follow_theme);
61
+ window.on("closed", () => options.native_theme.removeListener("updated", follow_theme));
62
+ if (options.state_file)
63
+ remember_state(window, options.state_file, options.save_delay_ms ?? DEFAULT_SAVE_DELAY_MS);
64
+ if (options.dev_url)
65
+ void window.loadURL(options.dev_url);
66
+ else
67
+ void window.loadFile(options.file);
68
+ return window;
69
+ }
70
+ // Saves a moment after the last move or resize, and at once when the window closes.
71
+ function remember_state(window, state_file, save_delay_ms) {
72
+ let timer;
73
+ const save_now = () => {
74
+ clearTimeout(timer);
75
+ timer = undefined;
76
+ write_window_state(state_file, { bounds: window.getNormalBounds(), is_maximized: window.isMaximized() });
77
+ };
78
+ const save_soon = () => {
79
+ clearTimeout(timer);
80
+ timer = setTimeout(save_now, save_delay_ms);
81
+ };
82
+ for (const event of ["resize", "move", "maximize", "unmaximize"])
83
+ window.on(event, save_soon);
84
+ window.on("close", save_now);
85
+ }
@@ -1,4 +1,3 @@
1
- import { type Child } from "./dom.ts";
2
1
  export interface MenuItem {
3
2
  label: string;
4
3
  run: () => void;
@@ -7,14 +6,12 @@ export interface MenuItem {
7
6
  danger?: boolean;
8
7
  separator_before?: boolean;
9
8
  checked?: boolean;
9
+ radio?: boolean;
10
+ keep_open?: boolean;
10
11
  }
11
- export declare function menu_item_class(item: MenuItem): string;
12
- export declare function menu_item_role(item: MenuItem): "menuitem" | "menuitemcheckbox";
13
12
  export declare function on_screen_position(x: number, y: number, width: number, height: number, room_width?: number, room_height?: number): {
14
13
  left: number;
15
14
  top: number;
16
15
  };
17
- export declare function place_on_screen(element: HTMLElement, x: number, y: number): void;
18
- export declare function dismiss_on_outside(element: HTMLElement, close: () => void): () => void;
19
- export declare function open_context_menu(x: number, y: number, items: MenuItem[], on_close?: () => void): () => void;
20
- export declare function open_popover(x: number, y: number, content: Child, label: string, on_close?: () => void): () => void;
16
+ export declare function item_after_key(key: string, current: number, count: number): number | null;
17
+ export declare function item_starting_with(labels: readonly string[], typed: string, current: number): number | null;
package/dist/web/menus.js CHANGED
@@ -1,14 +1,6 @@
1
- // Menus and popovers opened at a point: kept on screen, and closed by Escape, a click elsewhere, scrolling or leaving the window.
2
- import { h } from "./dom.js";
3
- export function menu_item_class(item) {
4
- return item.danger ? "menu-item danger" : "menu-item";
5
- }
6
- export function menu_item_role(item) {
7
- return item.checked === undefined ? "menuitem" : "menuitemcheckbox";
8
- }
9
- // ── Placing ──────────────────────────────────────────────────────────────────
1
+ // What a menu item holds, where a menu opened at a point goes so it stays on screen, and which item a key moves to.
10
2
  const edge_gap = 4;
11
- // Where an element opened at a point goes so it stays on screen: flipped to the point's other side, or pushed back from the edge.
3
+ // Flipped to the point's other side near an edge, or pushed back from the edge when neither side has room.
12
4
  export function on_screen_position(x, y, width, height, room_width = window.innerWidth, room_height = window.innerHeight) {
13
5
  const place = (start, size, room) => {
14
6
  if (start + size + edge_gap <= room)
@@ -19,80 +11,30 @@ export function on_screen_position(x, y, width, height, room_width = window.inne
19
11
  };
20
12
  return { left: place(x, width, room_width), top: place(y, height, room_height) };
21
13
  }
22
- export function place_on_screen(element, x, y) {
23
- const size = element.getBoundingClientRect();
24
- const position = on_screen_position(x, y, size.width, size.height);
25
- element.style.left = `${position.left}px`;
26
- element.style.top = `${position.top}px`;
27
- }
28
- // ── Dismissing ───────────────────────────────────────────────────────────────
29
- // Calls close on Escape, a press or right-click outside the element, scrolling outside it, or the window losing focus; returns a function that stops listening.
30
- export function dismiss_on_outside(element, close) {
31
- const outside = (event) => {
32
- if (!element.contains(event.target))
33
- close();
34
- };
35
- const escape = (event) => {
36
- if (event.key !== "Escape")
37
- return;
38
- event.preventDefault();
39
- event.stopPropagation();
40
- close();
41
- };
42
- // Listening at once would catch the very click that opened the element, which would close it straight away.
43
- const attach = setTimeout(() => {
44
- document.addEventListener("pointerdown", outside);
45
- document.addEventListener("contextmenu", outside);
46
- }, 0);
47
- window.addEventListener("keydown", escape);
48
- window.addEventListener("blur", close);
49
- window.addEventListener("scroll", outside, true);
50
- return () => {
51
- clearTimeout(attach);
52
- document.removeEventListener("pointerdown", outside);
53
- document.removeEventListener("contextmenu", outside);
54
- window.removeEventListener("keydown", escape);
55
- window.removeEventListener("blur", close);
56
- window.removeEventListener("scroll", outside, true);
57
- };
58
- }
59
- // ── Opening ──────────────────────────────────────────────────────────────────
60
- // Puts the element on the page at the point and wires its closing; returns the function that closes it.
61
- function open_at(element, x, y, on_close) {
62
- let stop = () => { };
63
- const close = () => {
64
- stop();
65
- element.remove();
66
- on_close?.();
67
- };
68
- document.body.append(element);
69
- place_on_screen(element, x, y);
70
- stop = dismiss_on_outside(element, close);
71
- return close;
72
- }
73
- // A right-click menu at the point, such as a mouse event's clientX and clientY.
74
- export function open_context_menu(x, y, items, on_close) {
75
- const menu = h("div", { class: "menu", role: "menu", tabindex: -1 });
76
- const close = open_at(menu, x, y, on_close);
77
- menu.append(...items.flatMap((item) => [
78
- item.separator_before ? h("div", { class: "menu-separator", role: "separator" }) : null,
79
- h("button", {
80
- type: "button", class: menu_item_class(item), role: menu_item_role(item),
81
- "aria-checked": item.checked === undefined ? null : String(item.checked), disabled: item.disabled ?? false,
82
- onclick: () => {
83
- close();
84
- item.run();
85
- },
86
- }, h("span", { class: "menu-label" }, item.label), item.hint && h("span", { class: "menu-hint" }, item.hint)),
87
- ].filter((node) => node !== null)));
88
- place_on_screen(menu, x, y);
89
- menu.querySelector(".menu-item:not(:disabled)")?.focus();
90
- return close;
91
- }
92
- // A popover holding any content, such as a column's filter, with its first field ready to type into.
93
- export function open_popover(x, y, content, label, on_close) {
94
- const popover = h("div", { class: "popover", role: "dialog", "aria-label": label }, content);
95
- const close = open_at(popover, x, y, on_close);
96
- popover.querySelector("input, select, textarea, button")?.focus();
97
- return close;
14
+ // ── Keys ─────────────────────────────────────────────────────────────────────
15
+ // The item an arrow, Home or End key moves to from the current one, wrapping round, or null for any other key.
16
+ export function item_after_key(key, current, count) {
17
+ if (count === 0)
18
+ return null;
19
+ switch (key) {
20
+ case "ArrowDown": return current < 0 ? 0 : (current + 1) % count;
21
+ case "ArrowUp": return current < 0 ? count - 1 : (current - 1 + count) % count;
22
+ case "Home": return 0;
23
+ case "End": return count - 1;
24
+ default: return null;
25
+ }
26
+ }
27
+ // The first item after the current one whose label starts with the typed text, or null when none does.
28
+ export function item_starting_with(labels, typed, current) {
29
+ const wanted = typed.toLocaleLowerCase();
30
+ // A repeated first letter steps on through the items that start with it.
31
+ const repeated = wanted.length > 1 && [...wanted].every((letter) => letter === wanted[0]);
32
+ const prefix = repeated ? wanted[0] : wanted;
33
+ const start = repeated || wanted.length === 1 ? current + 1 : Math.max(current, 0);
34
+ for (let offset = 0; offset < labels.length; offset += 1) {
35
+ const index = (start + offset) % labels.length;
36
+ if (labels[index].trim().toLocaleLowerCase().startsWith(prefix))
37
+ return index;
38
+ }
39
+ return null;
98
40
  }
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from "react";
2
+ export type Tone = "good" | "warn" | "bad" | "info" | "none";
3
+ export declare function Badge({ tone, title, children }: {
4
+ tone?: Tone;
5
+ title?: string;
6
+ children: ReactNode;
7
+ }): import("react").JSX.Element;
8
+ export declare function StateBadge<State extends string>({ labels, state, title }: {
9
+ labels: Record<State, [text: string, tone: Tone]>;
10
+ state: State;
11
+ title?: string;
12
+ }): import("react").JSX.Element;
13
+ export declare function Chip({ title, on_click, class_name, children }: {
14
+ title?: string;
15
+ on_click?: () => void;
16
+ class_name?: string;
17
+ children: ReactNode;
18
+ }): import("react").JSX.Element;
19
+ export declare function Chips({ inline, children }: {
20
+ inline?: boolean;
21
+ children: ReactNode;
22
+ }): import("react").JSX.Element;
23
+ export declare function Verdict({ ok, label }: {
24
+ ok: boolean | null | undefined;
25
+ label: string;
26
+ }): import("react").JSX.Element;