@peers-app/peers-ui 0.28.4 → 0.28.6

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 (40) hide show
  1. package/dist/command-palette/command-palette-ui.d.ts +1 -0
  2. package/dist/command-palette/command-palette-ui.js +11 -8
  3. package/dist/components/data-grid/autocomplete.d.ts +31 -0
  4. package/dist/components/data-grid/autocomplete.js +199 -0
  5. package/dist/components/data-grid/data-grid.d.ts +56 -0
  6. package/dist/components/data-grid/data-grid.js +179 -0
  7. package/dist/components/data-grid/index.d.ts +3 -0
  8. package/dist/components/data-grid/index.js +7 -0
  9. package/dist/components/data-grid/reactive.d.ts +5 -0
  10. package/dist/components/data-grid/reactive.js +9 -0
  11. package/dist/components/group-switcher.js +14 -12
  12. package/dist/components/router.js +2 -9
  13. package/dist/components/tooltip.d.ts +1 -0
  14. package/dist/components/tooltip.js +2 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +1 -0
  17. package/dist/operator-console/operator-console.js +7 -7
  18. package/dist/root-layout/layouts/full-screen-layout.js +10 -4
  19. package/dist/screens/console-logs/console-logs-list.d.ts +1 -0
  20. package/dist/screens/console-logs/console-logs-list.js +4 -3
  21. package/dist/screens/console-logs/mobile-log-card.d.ts +1 -0
  22. package/dist/screens/console-logs/mobile-log-card.js +8 -7
  23. package/dist/screens/console-logs/resizable-table-header.d.ts +1 -0
  24. package/dist/screens/console-logs/resizable-table-header.js +3 -2
  25. package/dist/screens/network-viewer/usage-graph.d.ts +1 -1
  26. package/dist/screens/network-viewer/usage-graph.js +1 -1
  27. package/dist/screens/search/global-search.js +5 -5
  28. package/dist/screens/settings/color-mode-dropdown.d.ts +5 -0
  29. package/dist/screens/settings/color-mode-dropdown.js +8 -2
  30. package/dist/screens/settings/settings-page.js +1 -8
  31. package/dist/screens/setup-user.d.ts +1 -0
  32. package/dist/screens/setup-user.js +9 -8
  33. package/dist/screens/welcome-modal.js +9 -9
  34. package/dist/tabs-layout/tabs-layout.js +25 -31
  35. package/dist/theme.css +126 -0
  36. package/package.json +4 -4
  37. package/dist/screens/tools/tool-test-details.d.ts +0 -3
  38. package/dist/screens/tools/tool-test-details.js +0 -38
  39. package/dist/screens/tools/tool-test-list.d.ts +0 -3
  40. package/dist/screens/tools/tool-test-list.js +0 -43
@@ -1 +1,2 @@
1
+ /** Searchable overlay for launching apps, running commands, and starting threads. */
1
2
  export declare function CommandPaletteOverlay(): import("react/jsx-runtime").JSX.Element | null;
@@ -12,6 +12,7 @@ const system_apps_1 = require("../system-apps");
12
12
  const tabs_state_1 = require("../tabs-layout/tabs-state");
13
13
  const routes_loader_1 = require("../ui-router/routes-loader");
14
14
  const command_palette_1 = require("./command-palette");
15
+ /** Searchable overlay for launching apps, running commands, and starting threads. */
15
16
  function CommandPaletteOverlay() {
16
17
  const [isOpen] = (0, hooks_1.useObservable)(command_palette_1.isCommandPaletteOpen);
17
18
  const [_persistedQuery] = (0, hooks_1.useObservable)(command_palette_1.commandSearchQuery);
@@ -198,15 +199,15 @@ function CommandPaletteOverlay() {
198
199
  if (e.target === e.currentTarget) {
199
200
  (0, command_palette_1.closeCommandPalette)();
200
201
  }
201
- }, children: (0, jsx_runtime_1.jsxs)("div", { className: `${isDark ? "bg-dark text-light" : "bg-white text-dark"}`, style: {
202
+ }, children: (0, jsx_runtime_1.jsxs)("div", { className: `${isDark ? "bg-dark text-light" : "peers-surface text-body"}`, style: {
202
203
  width: "90%",
203
204
  maxWidth: "600px",
204
205
  borderRadius: "12px",
205
206
  boxShadow: isDark ? "0 20px 40px rgba(0, 0, 0, 0.5)" : "0 20px 40px rgba(0, 0, 0, 0.1)",
206
- border: isDark ? "1px solid #495057" : "1px solid #dee2e6",
207
+ border: isDark ? "1px solid #495057" : "1px solid var(--peers-surface-border)",
207
208
  overflow: "hidden",
208
209
  }, children: [(0, jsx_runtime_1.jsx)("div", { className: "p-3 border-bottom", style: {
209
- borderBottomColor: isDark ? "#495057" : "#dee2e6",
210
+ borderBottomColor: isDark ? "#495057" : "var(--peers-surface-border)",
210
211
  }, children: (0, jsx_runtime_1.jsxs)("div", { className: "position-relative", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-search position-absolute", style: {
211
212
  left: "12px",
212
213
  top: "50%",
@@ -230,7 +231,7 @@ function CommandPaletteOverlay() {
230
231
  border: "none",
231
232
  outline: "none",
232
233
  boxShadow: "none",
233
- backgroundColor: isDark ? "#343a40" : "#f8f9fa",
234
+ backgroundColor: isDark ? "#343a40" : "var(--peers-surface-bg)",
234
235
  } })] }) }), (0, jsx_runtime_1.jsx)("div", { style: {
235
236
  maxHeight: "400px",
236
237
  overflowY: "auto",
@@ -275,11 +276,13 @@ function CommandPaletteOverlay() {
275
276
  fontSize: "16px",
276
277
  color: isSelected ? (isDark ? "#ffffff" : "#0d6efd") : "#6c757d",
277
278
  minWidth: "16px",
278
- } })), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { className: "fw-medium", children: command.label }), command.description && ((0, jsx_runtime_1.jsx)("div", { className: "small text-muted", style: { fontSize: "12px" }, children: command.description }))] })] }), command.shortcut && ((0, jsx_runtime_1.jsx)("div", { className: `small px-2 py-1 rounded ${isDark ? "bg-secondary bg-opacity-50" : "bg-light"}`, style: {
279
+ } })), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { className: "fw-medium", children: command.label }), command.description && ((0, jsx_runtime_1.jsx)("div", { className: "small text-muted", style: { fontSize: "12px" }, children: command.description }))] })] }), command.shortcut && ((0, jsx_runtime_1.jsx)("div", { className: `small px-2 py-1 rounded ${isDark ? "bg-secondary bg-opacity-50" : "bg-body"}`, style: {
279
280
  fontSize: "11px",
280
281
  fontFamily: "monospace",
281
282
  color: isDark ? "#adb5bd" : "#6c757d",
282
- border: isDark ? "1px solid #495057" : "1px solid #dee2e6",
283
+ border: isDark
284
+ ? "1px solid #495057"
285
+ : "1px solid var(--peers-surface-border)",
283
286
  }, children: command.shortcut }))] }, command.id));
284
287
  })] }, category))), searchQuery.trim() && ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { className: "px-3 py-1 small text-muted fw-bold text-uppercase", style: { fontSize: "11px", letterSpacing: "0.5px" }, children: "Actions" }), (0, jsx_runtime_1.jsx)("div", { className: `px-3 py-2 d-flex align-items-center ${(allItems.length === 0 || selectedIndex >= allItems.length) &&
285
288
  searchQuery.trim()
@@ -290,7 +293,7 @@ function CommandPaletteOverlay() {
290
293
  cursor: "pointer",
291
294
  transition: "background-color 0.1s ease",
292
295
  borderTop: allItems.length > 0
293
- ? `1px solid ${isDark ? "#495057" : "#dee2e6"}`
296
+ ? `1px solid ${isDark ? "#495057" : "var(--peers-surface-border)"}`
294
297
  : "none",
295
298
  marginTop: allItems.length > 0 ? "8px" : "0",
296
299
  paddingTop: allItems.length > 0 ? "12px" : "8px",
@@ -310,7 +313,7 @@ function CommandPaletteOverlay() {
310
313
  : "#6c757d",
311
314
  minWidth: "16px",
312
315
  } }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("div", { className: "fw-medium", children: ["Start new thread:", " ", (0, jsx_runtime_1.jsxs)("span", { className: "text-muted", children: [searchQuery.trim().slice(0, 50), searchQuery.trim().length > 50 ? "..." : ""] })] }), (0, jsx_runtime_1.jsx)("div", { className: "small text-muted", style: { fontSize: "12px" }, children: "Press Enter to create" })] })] }) })] }))] })) }), (0, jsx_runtime_1.jsxs)("div", { className: `px-3 py-2 small text-muted border-top d-flex align-items-center justify-content-between`, style: {
313
- borderTopColor: isDark ? "#495057" : "#dee2e6",
316
+ borderTopColor: isDark ? "#495057" : "var(--peers-surface-border)",
314
317
  fontSize: "11px",
315
318
  }, children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center gap-3", children: [(0, jsx_runtime_1.jsxs)("span", { children: [(0, jsx_runtime_1.jsx)("kbd", { className: "small", children: "\u21B5" }), " to select"] }), (0, jsx_runtime_1.jsxs)("span", { children: [(0, jsx_runtime_1.jsx)("kbd", { className: "small", children: "\u2191\u2193" }), " to navigate"] }), (0, jsx_runtime_1.jsxs)("span", { children: [(0, jsx_runtime_1.jsx)("kbd", { className: "small", children: "esc" }), " to close"] })] }), (0, jsx_runtime_1.jsxs)("div", { children: [allItems.length, " result", allItems.length !== 1 ? "s" : "", searchQuery.trim() && allItems.length === 0 && " • Start thread"] })] })] }) }));
316
319
  }
@@ -0,0 +1,31 @@
1
+ import { type Ref } from "react";
2
+ import { type GridValue } from "./reactive";
3
+ /** Single-selection lookup, independent of any table, ORM, or contract. */
4
+ export interface AutocompleteProps<T> {
5
+ /** Current selection; observable updates are reflected without recreating the control. */
6
+ value: GridValue<T | null>;
7
+ /** Local choices in display order, optionally supplied by a Peers observable. */
8
+ options?: GridValue<readonly T[]>;
9
+ /** Remote search. Stale responses are ignored and the signal is aborted on replacement. */
10
+ loadOptions?: (query: string, signal: AbortSignal) => Promise<readonly T[]>;
11
+ getOptionKey: (option: T) => string;
12
+ getOptionLabel: (option: T) => string;
13
+ /** Explicit write boundary; a writable local observable can be passed directly. */
14
+ onChange: (value: T | null) => void;
15
+ label: string;
16
+ placeholder?: string;
17
+ disabled?: boolean;
18
+ required?: boolean;
19
+ clearable?: boolean;
20
+ minLength?: number;
21
+ debounceMs?: number;
22
+ maxResults?: number;
23
+ inputRef?: Ref<HTMLInputElement>;
24
+ invalid?: boolean;
25
+ }
26
+ /**
27
+ * Searchable, clearable Bootstrap combobox inspired by JobTrak's Typeahead/FK controls.
28
+ * Arrow keys and Enter belong to the popup while open; Tab selects a highlighted result
29
+ * and continues naturally. The popup is portaled so a scrolling grid cannot clip it.
30
+ */
31
+ export declare function Autocomplete<T>(props: AutocompleteProps<T>): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Autocomplete = Autocomplete;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const react_1 = require("react");
6
+ const react_dom_1 = require("react-dom");
7
+ const reactive_1 = require("./reactive");
8
+ const emptyOptions = [];
9
+ // Bootstrap button states remain available without drawing separate button boxes.
10
+ const indicatorStyle = {
11
+ width: "2rem",
12
+ height: "100%",
13
+ padding: 0,
14
+ border: 0,
15
+ "--bs-btn-color": "var(--bs-tertiary-color)",
16
+ "--bs-btn-hover-color": "var(--bs-secondary-color)",
17
+ "--bs-btn-active-color": "var(--bs-body-color)",
18
+ "--bs-btn-disabled-color": "var(--bs-tertiary-color)",
19
+ "--bs-btn-bg": "transparent",
20
+ "--bs-btn-hover-bg": "transparent",
21
+ "--bs-btn-active-bg": "transparent",
22
+ "--bs-btn-disabled-bg": "transparent",
23
+ };
24
+ /**
25
+ * Searchable, clearable Bootstrap combobox inspired by JobTrak's Typeahead/FK controls.
26
+ * Arrow keys and Enter belong to the popup while open; Tab selects a highlighted result
27
+ * and continues naturally. The popup is portaled so a scrolling grid cannot clip it.
28
+ */
29
+ function Autocomplete(props) {
30
+ const value = (0, reactive_1.useGridValue)(props.value);
31
+ const options = (0, reactive_1.useGridValue)(props.options ?? emptyOptions);
32
+ const id = (0, react_1.useId)();
33
+ const input = (0, react_1.useRef)(null);
34
+ const [open, setOpen] = (0, react_1.useState)(false);
35
+ const [query, setQuery] = (0, react_1.useState)(null);
36
+ const [active, setActive] = (0, react_1.useState)(-1);
37
+ const [remote, setRemote] = (0, react_1.useState)([]);
38
+ const [loading, setLoading] = (0, react_1.useState)(false);
39
+ const [error, setError] = (0, react_1.useState)("");
40
+ const [position, setPosition] = (0, react_1.useState)({ left: 0, top: 0, width: 0, maxHeight: 240 });
41
+ const search = query ?? "";
42
+ const eligible = search.length >= (props.minLength ?? 0);
43
+ const results = (props.loadOptions
44
+ ? remote
45
+ : options.filter((option) => props.getOptionLabel(option).toLocaleLowerCase().includes(search.toLocaleLowerCase()))).slice(0, props.maxResults ?? 100);
46
+ (0, react_1.useEffect)(() => {
47
+ if (!open || props.disabled || !eligible || !props.loadOptions) {
48
+ setLoading(false);
49
+ setRemote([]);
50
+ return;
51
+ }
52
+ const controller = new AbortController();
53
+ let current = true;
54
+ setLoading(true);
55
+ setRemote([]);
56
+ setError("");
57
+ const timer = setTimeout(async () => {
58
+ try {
59
+ const rows = await props.loadOptions?.(search, controller.signal);
60
+ if (current)
61
+ setRemote(rows ?? []);
62
+ }
63
+ catch (cause) {
64
+ if (current)
65
+ setError(cause instanceof Error ? cause.message : "Could not load choices");
66
+ }
67
+ finally {
68
+ if (current)
69
+ setLoading(false);
70
+ }
71
+ }, props.debounceMs ?? 150);
72
+ return () => {
73
+ current = false;
74
+ controller.abort();
75
+ clearTimeout(timer);
76
+ };
77
+ }, [open, props.disabled, search, eligible, props.loadOptions, props.debounceMs]);
78
+ (0, react_1.useLayoutEffect)(() => {
79
+ if (!open)
80
+ return;
81
+ const place = () => {
82
+ const rect = input.current?.parentElement?.getBoundingClientRect();
83
+ if (!rect)
84
+ return;
85
+ const below = window.innerHeight - rect.bottom - 8;
86
+ const height = Math.min(240, Math.max(100, below >= 140 ? below : rect.top - 8));
87
+ setPosition({
88
+ left: rect.left,
89
+ width: rect.width,
90
+ top: below >= 140 ? rect.bottom : Math.max(8, rect.top - height),
91
+ maxHeight: height,
92
+ });
93
+ };
94
+ place();
95
+ window.addEventListener("resize", place);
96
+ window.addEventListener("scroll", place, true);
97
+ return () => {
98
+ window.removeEventListener("resize", place);
99
+ window.removeEventListener("scroll", place, true);
100
+ };
101
+ }, [open]);
102
+ (0, react_1.useEffect)(() => {
103
+ if (open && active >= 0)
104
+ document.getElementById(`${id}-${active}`)?.scrollIntoView?.({ block: "nearest" });
105
+ }, [open, active, id]);
106
+ const close = () => {
107
+ setOpen(false);
108
+ setQuery(null);
109
+ setActive(-1);
110
+ };
111
+ const choose = (option) => {
112
+ props.onChange(option);
113
+ close();
114
+ };
115
+ const setRef = (node) => {
116
+ input.current = node;
117
+ if (typeof props.inputRef === "function")
118
+ props.inputRef(node);
119
+ else if (props.inputRef)
120
+ props.inputRef.current = node;
121
+ };
122
+ const show = open && !props.disabled;
123
+ const showClear = (props.clearable ?? !props.required) && value !== null;
124
+ const selectedLabel = value === null ? "" : props.getOptionLabel(value);
125
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "peers-autocomplete position-relative", style: { minWidth: 0, width: "100%" }, children: [(0, jsx_runtime_1.jsx)("input", { ref: setRef, className: `form-control ${props.invalid ? "is-invalid" : ""}`, style: {
126
+ minWidth: 0,
127
+ paddingRight: props.invalid
128
+ ? showClear
129
+ ? "6.25rem"
130
+ : "4.25rem"
131
+ : showClear
132
+ ? "4.75rem"
133
+ : "2.75rem",
134
+ backgroundPosition: props.invalid
135
+ ? `right ${showClear ? "4.75rem" : "2.75rem"} center`
136
+ : undefined,
137
+ }, role: "combobox", "aria-label": props.label, "aria-autocomplete": "list", "aria-expanded": show, "aria-controls": show ? id : undefined, "aria-activedescendant": show && active >= 0 && results[active] ? `${id}-${active}` : undefined, "aria-invalid": props.invalid || undefined, autoComplete: "off", value: query ?? selectedLabel, placeholder: props.placeholder, disabled: props.disabled, required: props.required, onFocus: (event) => {
138
+ event.target.select();
139
+ }, onBlur: close, onChange: (event) => {
140
+ setQuery(event.target.value);
141
+ setError("");
142
+ if (props.loadOptions) {
143
+ setRemote([]);
144
+ setLoading(true);
145
+ }
146
+ setOpen(true);
147
+ setActive(-1);
148
+ }, onKeyDown: (event) => {
149
+ if (event.nativeEvent.isComposing || event.ctrlKey || event.metaKey || event.altKey)
150
+ return;
151
+ if (event.key === "ArrowDown" || (event.key === "ArrowUp" && show)) {
152
+ event.preventDefault();
153
+ event.stopPropagation();
154
+ setOpen(true);
155
+ setActive((prior) => event.key === "ArrowDown"
156
+ ? Math.min(prior + 1, results.length - 1)
157
+ : prior <= 0
158
+ ? results.length - 1
159
+ : prior - 1);
160
+ }
161
+ else if (event.key === "Enter" && show) {
162
+ event.preventDefault();
163
+ event.stopPropagation();
164
+ const option = results[active >= 0 ? active : 0];
165
+ if (eligible && option && !loading && !error)
166
+ choose(option);
167
+ }
168
+ else if (event.key === "Escape" && (show || query !== null)) {
169
+ event.preventDefault();
170
+ event.stopPropagation();
171
+ close();
172
+ }
173
+ else if (event.key === "Tab" &&
174
+ eligible &&
175
+ show &&
176
+ active >= 0 &&
177
+ results[active] &&
178
+ !loading &&
179
+ !error) {
180
+ choose(results[active]);
181
+ }
182
+ } }), (0, jsx_runtime_1.jsxs)("span", { className: "position-absolute top-0 end-0 h-100 d-flex align-items-center", style: { paddingRight: "0.25rem" }, children: [showClear && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn d-inline-flex align-items-center justify-content-center", style: indicatorStyle, tabIndex: -1, "aria-label": `Clear ${props.label}`, disabled: props.disabled, onMouseDown: (event) => event.preventDefault(), onClick: () => {
183
+ choose(null);
184
+ input.current?.focus();
185
+ }, children: (0, jsx_runtime_1.jsx)("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", focusable: "false", children: (0, jsx_runtime_1.jsx)("path", { d: "m4 4 8 8M12 4l-8 8", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }) }) })), (0, jsx_runtime_1.jsx)("span", { "aria-hidden": "true", style: {
186
+ height: "55%",
187
+ borderLeft: "1px solid var(--bs-border-color)",
188
+ marginInline: "0.125rem",
189
+ } }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn d-inline-flex align-items-center justify-content-center", style: indicatorStyle, tabIndex: -1, "aria-label": `Show ${props.label} choices`, disabled: props.disabled, onMouseDown: (event) => event.preventDefault(), onClick: () => {
190
+ input.current?.focus();
191
+ setQuery(null);
192
+ setActive(-1);
193
+ setOpen(!open);
194
+ }, children: (0, jsx_runtime_1.jsx)("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", focusable: "false", children: (0, jsx_runtime_1.jsx)("path", { d: "m4 6 4 4 4-4", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }) })] }), show &&
195
+ (0, react_dom_1.createPortal)((0, jsx_runtime_1.jsx)("div", { id: id, role: "listbox", "aria-label": `${props.label} choices`, className: "border rounded bg-body text-body shadow", style: { position: "fixed", ...position, overflowY: "auto", zIndex: 1080 }, children: loading || error || !eligible || !results.length ? ((0, jsx_runtime_1.jsx)("div", { className: `p-2 ${error ? "text-danger" : "text-body-secondary"}`, role: "status", children: loading
196
+ ? "Loading…"
197
+ : error ||
198
+ (!eligible ? `Type at least ${props.minLength} characters` : "No matches") })) : (results.map((option, index) => ((0, jsx_runtime_1.jsx)("div", { id: `${id}-${index}`, role: "option", "aria-selected": value !== null && props.getOptionKey(value) === props.getOptionKey(option), className: `px-3 py-2 ${active === index ? "text-bg-primary" : ""}`, style: { cursor: "pointer" }, onMouseEnter: () => setActive(index), onMouseDown: (event) => event.preventDefault(), onClick: () => choose(option), children: props.getOptionLabel(option) }, props.getOptionKey(option))))) }), document.body)] }));
199
+ }
@@ -0,0 +1,56 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type GridValue } from "./reactive";
3
+ /** Stable lookup value; labels can change without changing the stored relationship. */
4
+ export interface DataGridOption {
5
+ value: string;
6
+ label: string;
7
+ }
8
+ /** One entry in a multi-column sort, in priority order. */
9
+ export interface DataGridSort {
10
+ columnId: string;
11
+ direction: "asc" | "desc";
12
+ }
13
+ /** Declarative column, with optional editing, lookup, custom actions, and totals. */
14
+ export interface DataGridColumn<T> {
15
+ id: string;
16
+ label: string;
17
+ type?: "text" | "number" | "date" | "boolean" | "select";
18
+ width?: CSSProperties["width"];
19
+ /** Column width hint in pixels, including cell padding; editors fit the remaining space. */
20
+ minWidth?: number;
21
+ getValue?: (row: T) => unknown;
22
+ /** Omit to make this a display/action column. Numbers remain text until the owner validates. */
23
+ editable?: boolean | ((row: T) => boolean);
24
+ required?: boolean;
25
+ options?: GridValue<readonly DataGridOption[]>;
26
+ renderCell?: (row: T) => ReactNode;
27
+ sortValue?: (row: T) => string | number | null | undefined;
28
+ sortable?: boolean;
29
+ footer?: (rows: readonly T[]) => ReactNode;
30
+ align?: "left" | "right" | "center";
31
+ }
32
+ /** Persistence-free grid. Owners stage edits and validate/save through their own tools. */
33
+ export interface DataGridProps<T> {
34
+ rows: GridValue<readonly T[]>;
35
+ columns: readonly DataGridColumn<T>[];
36
+ rowKey: (row: T) => string;
37
+ rowLabel?: (row: T) => string;
38
+ label: string;
39
+ onCellChange: (row: T, columnId: string, value: string | boolean) => void;
40
+ /** Optional controlled or observable sort; use onSortChange as its explicit write boundary. */
41
+ sort?: GridValue<readonly DataGridSort[]>;
42
+ defaultSort?: readonly DataGridSort[];
43
+ onSortChange?: (sort: DataGridSort[]) => void;
44
+ disabled?: boolean;
45
+ getRowError?: (row: T) => string | undefined;
46
+ onAddRow?: () => void;
47
+ addRowLabel?: string;
48
+ className?: string;
49
+ }
50
+ /**
51
+ * An always-editable, Bootstrap grid ported from JobTrak's spreadsheet interactions.
52
+ * Up/down move vertically; left/right move at caret boundaries or when all text is selected.
53
+ * Enter moves to the next row's first editor. Native Tab/Shift+Tab are preserved, and popup
54
+ * editors can consume keys. Sorting never mutates caller rows; focus retains a stable order.
55
+ */
56
+ export declare function DataGrid<T>(props: DataGridProps<T>): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DataGrid = DataGrid;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const react_1 = require("react");
6
+ const autocomplete_1 = require("./autocomplete");
7
+ const reactive_1 = require("./reactive");
8
+ const noSort = [];
9
+ /**
10
+ * An always-editable, Bootstrap grid ported from JobTrak's spreadsheet interactions.
11
+ * Up/down move vertically; left/right move at caret boundaries or when all text is selected.
12
+ * Enter moves to the next row's first editor. Native Tab/Shift+Tab are preserved, and popup
13
+ * editors can consume keys. Sorting never mutates caller rows; focus retains a stable order.
14
+ */
15
+ function DataGrid(props) {
16
+ const rows = (0, reactive_1.useGridValue)(props.rows);
17
+ const [localSort, setLocalSort] = (0, react_1.useState)(props.defaultSort ?? noSort);
18
+ const sort = (0, reactive_1.useGridValue)(props.sort ?? localSort);
19
+ const table = (0, react_1.useRef)(null);
20
+ const add = (0, react_1.useRef)(null);
21
+ const order = (0, react_1.useRef)([]);
22
+ const pendingNew = (0, react_1.useRef)(null);
23
+ const [focused, setFocused] = (0, react_1.useState)(false);
24
+ const sorted = (0, react_1.useMemo)(() => {
25
+ const next = [...rows];
26
+ if (focused && order.current.length) {
27
+ const rank = new Map(order.current.map((id, index) => [id, index]));
28
+ next.sort((a, b) => (rank.get(props.rowKey(a)) ?? Infinity) - (rank.get(props.rowKey(b)) ?? Infinity));
29
+ }
30
+ else {
31
+ next.sort((a, b) => {
32
+ for (const spec of sort) {
33
+ const column = props.columns.find((item) => item.id === spec.columnId);
34
+ const av = column?.sortValue?.(a) ?? column?.getValue?.(a) ?? "";
35
+ const bv = column?.sortValue?.(b) ?? column?.getValue?.(b) ?? "";
36
+ const result = typeof av === "number" && typeof bv === "number"
37
+ ? av - bv
38
+ : String(av).localeCompare(String(bv), undefined, {
39
+ numeric: true,
40
+ sensitivity: "base",
41
+ });
42
+ if (result)
43
+ return spec.direction === "asc" ? result : -result;
44
+ }
45
+ return 0;
46
+ });
47
+ }
48
+ order.current = next.map(props.rowKey);
49
+ return next;
50
+ }, [rows, sort, props.columns, props.rowKey, focused]);
51
+ const focusCell = (r, c) => {
52
+ const cell = table.current?.querySelector(`[data-grid-row="${r}"][data-grid-col="${c}"]`);
53
+ const target = cell?.querySelector("input:not(:disabled),textarea:not(:disabled),button:not(:disabled):not([tabindex='-1']),select:not(:disabled)");
54
+ if (!target)
55
+ return false;
56
+ target.focus();
57
+ target.select?.();
58
+ return true;
59
+ };
60
+ const focusNextRow = (r) => {
61
+ for (let row = r; row < sorted.length; row++) {
62
+ for (let col = 0; col < props.columns.length; col++) {
63
+ if (props.columns[col].editable && focusCell(row, col))
64
+ return true;
65
+ }
66
+ }
67
+ if (add.current && !add.current.disabled) {
68
+ add.current.focus();
69
+ return true;
70
+ }
71
+ return false;
72
+ };
73
+ const onKey = (event, r, c) => {
74
+ if (event.defaultPrevented ||
75
+ event.nativeEvent.isComposing ||
76
+ event.altKey ||
77
+ event.ctrlKey ||
78
+ event.metaKey ||
79
+ event.shiftKey)
80
+ return;
81
+ const target = event.target;
82
+ if (target.tagName === "BUTTON" && event.key === "Enter")
83
+ return;
84
+ let moved = false;
85
+ if (event.key === "Enter")
86
+ moved = focusNextRow(r + 1);
87
+ if (event.key === "ArrowUp" || event.key === "ArrowDown") {
88
+ const step = event.key === "ArrowUp" ? -1 : 1;
89
+ for (let row = r + step; row >= 0 && row < sorted.length; row += step) {
90
+ if (focusCell(row, c)) {
91
+ moved = true;
92
+ break;
93
+ }
94
+ }
95
+ }
96
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
97
+ const textInput = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
98
+ const all = textInput && target.selectionStart === 0 && target.selectionEnd === target.value.length;
99
+ const atEdge = !textInput ||
100
+ target.selectionStart === null ||
101
+ all ||
102
+ (event.key === "ArrowLeft"
103
+ ? target.selectionStart === 0 && target.selectionEnd === 0
104
+ : target.selectionStart === target.value.length);
105
+ if (!atEdge)
106
+ return;
107
+ const step = event.key === "ArrowLeft" ? -1 : 1;
108
+ const count = props.columns.length;
109
+ for (let index = r * count + c + step; index >= 0 && index < sorted.length * count; index += step) {
110
+ if (focusCell(Math.floor(index / count), index % count)) {
111
+ moved = true;
112
+ break;
113
+ }
114
+ }
115
+ }
116
+ if (moved) {
117
+ event.preventDefault();
118
+ event.stopPropagation();
119
+ }
120
+ };
121
+ const toggleSort = (column, append) => {
122
+ const prior = sort.find((entry) => entry.columnId === column.id);
123
+ const rest = append ? sort.filter((entry) => entry.columnId !== column.id) : [];
124
+ const next = prior?.direction === "desc"
125
+ ? rest
126
+ : [...rest, { columnId: column.id, direction: prior ? "desc" : "asc" }];
127
+ setFocused(false);
128
+ setLocalSort(next);
129
+ props.onSortChange?.(next);
130
+ };
131
+ return ((0, jsx_runtime_1.jsx)("div", { className: `table-responsive ${props.className ?? ""}`, children: (0, jsx_runtime_1.jsxs)("table", { ref: table, className: "table table-striped table-hover table-bordered align-middle mb-0", "aria-label": props.label, style: {
132
+ tableLayout: "fixed",
133
+ minWidth: props.columns.reduce((sum, column) => sum + (column.minWidth ?? (typeof column.width === "number" ? column.width : 80)), 0),
134
+ }, onFocus: () => setFocused(true), onBlur: (event) => {
135
+ if (!event.currentTarget.contains(event.relatedTarget))
136
+ setFocused(false);
137
+ }, children: [(0, jsx_runtime_1.jsx)("thead", { children: (0, jsx_runtime_1.jsx)("tr", { children: props.columns.map((column) => {
138
+ const spec = sort.find((entry) => entry.columnId === column.id);
139
+ const sortable = column.sortable !== false && Boolean(column.getValue || column.sortValue);
140
+ return ((0, jsx_runtime_1.jsx)("th", { scope: "col", style: { width: column.width, minWidth: column.minWidth, whiteSpace: "nowrap" }, "aria-sort": sort[0]?.columnId === column.id
141
+ ? spec?.direction === "asc"
142
+ ? "ascending"
143
+ : "descending"
144
+ : undefined, children: sortable ? ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn p-0 border-0 text-body fw-bold text-start", title: "Sort; Shift-click adds another column", onClick: (e) => toggleSort(column, e.shiftKey), children: [column.label, spec && ((0, jsx_runtime_1.jsxs)("span", { "aria-hidden": "true", children: [" ", spec.direction === "asc" ? "↓" : "↑", sort.length > 1 ? sort.indexOf(spec) + 1 : ""] }))] })) : (column.label) }, column.id));
145
+ }) }) }), (0, jsx_runtime_1.jsx)("tbody", { children: sorted.map((row, r) => {
146
+ const key = props.rowKey(row);
147
+ const label = props.rowLabel?.(row) || key;
148
+ const error = props.getRowError?.(row);
149
+ return ((0, jsx_runtime_1.jsx)(GridRow, { row: row, columns: props.columns, label: label, index: r, error: error, disabled: props.disabled, onChange: props.onCellChange, onKey: onKey, onMount: (node) => {
150
+ if (node && pendingNew.current && !pendingNew.current.has(key)) {
151
+ pendingNew.current = null;
152
+ const input = node.querySelector("input:not(:disabled)");
153
+ input?.focus();
154
+ input?.select?.();
155
+ }
156
+ } }, key));
157
+ }) }), (props.onAddRow || props.columns.some((column) => column.footer)) && ((0, jsx_runtime_1.jsxs)("tfoot", { children: [props.columns.some((column) => column.footer) && ((0, jsx_runtime_1.jsx)("tr", { children: props.columns.map((column) => ((0, jsx_runtime_1.jsx)("td", { className: "fw-bold", style: { textAlign: column.align }, children: column.footer?.(rows) }, column.id))) })), props.onAddRow && ((0, jsx_runtime_1.jsx)("tr", { children: (0, jsx_runtime_1.jsx)("td", { colSpan: props.columns.length, children: (0, jsx_runtime_1.jsxs)("button", { ref: add, type: "button", className: "btn btn-outline-secondary btn-sm", disabled: props.disabled, onClick: () => {
158
+ pendingNew.current = new Set(rows.map(props.rowKey));
159
+ props.onAddRow?.();
160
+ }, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-plus-lg", "aria-hidden": "true" }), " ", props.addRowLabel || "Add row"] }) }) }))] }))] }) }));
161
+ }
162
+ function GridRow({ row, columns, label, index, error, disabled, onChange, onKey, onMount, }) {
163
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("tr", { ref: onMount, className: error ? "table-danger" : undefined, children: columns.map((column, c) => ((0, jsx_runtime_1.jsx)("td", { "data-grid-row": index, "data-grid-col": c, style: { textAlign: column.align ?? (column.type === "number" ? "right" : undefined) }, onKeyDown: (event) => onKey(event, index, c), children: (0, jsx_runtime_1.jsx)(GridCell, { row: row, column: column, label: `${column.label} — ${label}`, disabled: disabled, invalid: !!error, onChange: (value) => onChange(row, column.id, value) }) }, column.id))) }), error && ((0, jsx_runtime_1.jsx)("tr", { className: "table-danger", children: (0, jsx_runtime_1.jsxs)("td", { colSpan: columns.length, role: "alert", children: [label, ": ", error] }) }))] }));
164
+ }
165
+ function GridCell({ row, column, label, disabled, invalid, onChange, }) {
166
+ const options = (0, reactive_1.useGridValue)(column.options ?? []);
167
+ const editable = typeof column.editable === "function" ? column.editable(row) : column.editable;
168
+ const raw = column.getValue?.(row);
169
+ if (!editable)
170
+ return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: column.renderCell?.(row) ?? (raw == null ? "" : String(raw)) });
171
+ if (column.type === "select")
172
+ return ((0, jsx_runtime_1.jsx)(autocomplete_1.Autocomplete, { label: label, value: options.find((option) => option.value === raw) ??
173
+ (raw ? { value: String(raw), label: `Unavailable (${String(raw)})` } : null), options: options, getOptionKey: (option) => option.value, getOptionLabel: (option) => option.label, onChange: (option) => onChange(option?.value ?? ""), disabled: disabled, required: column.required, invalid: invalid }));
174
+ return ((0, jsx_runtime_1.jsx)("input", { "aria-label": label, "aria-invalid": invalid || undefined, required: column.required, disabled: disabled, type: column.type === "boolean" ? "checkbox" : column.type === "date" ? "date" : "text", className: `${column.type === "boolean" ? "form-check-input" : "form-control"}${invalid ? " is-invalid" : ""}`, style: {
175
+ // Column sizing belongs to the table; reusing it here would consume the cell padding.
176
+ minWidth: 0,
177
+ textAlign: column.type === "number" ? "right" : undefined,
178
+ }, inputMode: column.type === "number" ? "decimal" : undefined, value: column.type === "boolean" ? undefined : String(raw ?? ""), checked: column.type === "boolean" ? Boolean(raw) : undefined, onChange: (event) => onChange(column.type === "boolean" ? event.target.checked : event.target.value) }));
179
+ }
@@ -0,0 +1,3 @@
1
+ export { Autocomplete, type AutocompleteProps } from "./autocomplete";
2
+ export { DataGrid, type DataGridColumn, type DataGridOption, type DataGridProps, type DataGridSort, } from "./data-grid";
3
+ export type { GridValue } from "./reactive";
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DataGrid = exports.Autocomplete = void 0;
4
+ var autocomplete_1 = require("./autocomplete");
5
+ Object.defineProperty(exports, "Autocomplete", { enumerable: true, get: function () { return autocomplete_1.Autocomplete; } });
6
+ var data_grid_1 = require("./data-grid");
7
+ Object.defineProperty(exports, "DataGrid", { enumerable: true, get: function () { return data_grid_1.DataGrid; } });
@@ -0,0 +1,5 @@
1
+ import type { ReadonlyObservable } from "@peers-app/peers-sdk";
2
+ /** A plain value or a Peers observable/computed, read without acquiring write authority. */
3
+ export type GridValue<T> = T | ReadonlyObservable<T>;
4
+ /** Subscribe through Peers' concurrent-safe React adapter; never write to the source. */
5
+ export declare function useGridValue<T>(source: GridValue<T>): T;
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useGridValue = useGridValue;
4
+ const use_observable_1 = require("../../hooks/use-observable");
5
+ /** Subscribe through Peers' concurrent-safe React adapter; never write to the source. */
6
+ function useGridValue(source) {
7
+ // useObservable's public overload accepts a writable union; we use only its read side.
8
+ return (0, use_observable_1.useObservable)(source)[0];
9
+ }