@columenlabs/lumen-ui 0.1.120 → 0.1.122
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lumen-tabs.d.ts +1 -1
- package/dist/lumen-tabs.js +60 -4
- package/dist/saved-answers.d.ts +25 -0
- package/dist/saved-answers.js +60 -0
- package/dist/scroll-fade.js +3 -2
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -76,6 +76,7 @@ export { type McpServer, McpSettings } from './mcp-settings.js';
|
|
|
76
76
|
export { lumenToast } from './lumen-toast.js';
|
|
77
77
|
export { LumenShell, type LumenShellProps, type ShellUser, StatusItem, firstName, initials, maskAmount, useAmountsHidden, useApp, useShell, } from './shell.js';
|
|
78
78
|
export { apiJson } from './api-fetch.js';
|
|
79
|
+
export { savedAnswers, type SavedAnswersOptions, type SavedAnswersPersister, } from './saved-answers.js';
|
|
79
80
|
export { cn } from './lib/utils.js';
|
|
80
81
|
export { type HealthProbe, StatusHealth } from './status-health.js';
|
|
81
82
|
export { type StatusMessage, type StatusMessageTone, StatusMessages, groupStatusMessages, } from './status-messages.js';
|
package/dist/index.js
CHANGED
|
@@ -83,6 +83,7 @@ export { McpSettings } from './mcp-settings.js';
|
|
|
83
83
|
export { lumenToast } from './lumen-toast.js';
|
|
84
84
|
export { LumenShell, StatusItem, firstName, initials, maskAmount, useAmountsHidden, useApp, useShell, } from './shell.js';
|
|
85
85
|
export { apiJson } from './api-fetch.js';
|
|
86
|
+
export { savedAnswers, } from './saved-answers.js';
|
|
86
87
|
export { cn } from './lib/utils.js';
|
|
87
88
|
export { StatusHealth } from './status-health.js';
|
|
88
89
|
export { StatusMessages, groupStatusMessages, } from './status-messages.js';
|
package/dist/lumen-tabs.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
2
|
import type { NavIcon } from './nav.js';
|
|
3
3
|
/**
|
|
4
4
|
* One tab: its icon, its name and a count or mark after it. Every tab has an icon; a tab the person edits takes theirs through `iconOf` and LumenTabDrawer.
|
package/dist/lumen-tabs.js
CHANGED
|
@@ -1,8 +1,64 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// LumenTabs: a page's
|
|
3
|
+
// tabs, each tab its own LumenLayout. The tabs sit on the left of one row and what only the open tab
|
|
4
|
+
// uses (its inputs, its Run or Cancel) at the right end, so the page header keeps only what acts on
|
|
5
|
+
// the whole page. Tabs and actions share one row on a phone too ("it splits into 2 rows"); only
|
|
6
|
+
// actions so wide that the tabs would get under 12rem wrap to a line under them. Tabs that do not
|
|
7
|
+
// fit what the actions leave fold into a More menu at the row's end, never a cut-off tab.
|
|
8
|
+
import { ChevronDownIcon } from 'lucide-react';
|
|
9
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
10
|
+
import { cn } from './lib/utils.js';
|
|
11
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
|
|
3
12
|
import { Tabs, TabsList, TabsTrigger } from './ui/tabs.js';
|
|
13
|
+
/** The row's gap between tabs (`gap-6` in TabsList). */
|
|
14
|
+
const GAP = 24;
|
|
15
|
+
/** The More button, drawn like a tab. */
|
|
16
|
+
const MORE = 'relative inline-flex shrink-0 items-center gap-1.5 pb-2 text-sm leading-4 font-medium whitespace-nowrap outline-none after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-full hover:text-foreground focus-visible:rounded-sm focus-visible:ring-3 focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0';
|
|
4
17
|
export function LumenTabs({ tabs, value, onChange, actions, }) {
|
|
5
18
|
const shown = tabs.filter((t) => !t.hidden);
|
|
6
|
-
const
|
|
7
|
-
|
|
19
|
+
const box = useRef(null);
|
|
20
|
+
const ruler = useRef(null);
|
|
21
|
+
// How many tabs fit before the More menu; all of them until measured.
|
|
22
|
+
const [fit, setFit] = useState(shown.length);
|
|
23
|
+
// The More button names the open tab when it is folded, so its width moves with the open tab.
|
|
24
|
+
const sig = `${shown.map((t) => t.value).join('|')}#${value}`;
|
|
25
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-measure when the tab set changes
|
|
26
|
+
useLayoutEffect(() => {
|
|
27
|
+
const el = box.current;
|
|
28
|
+
const r = ruler.current;
|
|
29
|
+
if (!el || !r)
|
|
30
|
+
return;
|
|
31
|
+
const measure = () => {
|
|
32
|
+
const widths = [...r.children].map((c) => c.offsetWidth);
|
|
33
|
+
const more = widths.pop() ?? 0;
|
|
34
|
+
// A few pixels spare for rounding, so the row never needs to scroll.
|
|
35
|
+
const room = el.clientWidth - 4;
|
|
36
|
+
const all = widths.reduce((a, w, i) => a + w + (i ? GAP : 0), 0);
|
|
37
|
+
if (all <= room)
|
|
38
|
+
return setFit(widths.length);
|
|
39
|
+
let used = more;
|
|
40
|
+
let n = 0;
|
|
41
|
+
for (const w of widths) {
|
|
42
|
+
if (used + GAP + w > room)
|
|
43
|
+
break;
|
|
44
|
+
used += GAP + w;
|
|
45
|
+
n++;
|
|
46
|
+
}
|
|
47
|
+
setFit(Math.max(1, n));
|
|
48
|
+
};
|
|
49
|
+
measure();
|
|
50
|
+
// The row's room, and the tabs' own widths (a count that arrives after the first paint).
|
|
51
|
+
const seen = new ResizeObserver(measure);
|
|
52
|
+
seen.observe(el);
|
|
53
|
+
seen.observe(r);
|
|
54
|
+
return () => seen.disconnect();
|
|
55
|
+
}, [sig]);
|
|
56
|
+
const inRow = shown.slice(0, fit);
|
|
57
|
+
const folded = shown.slice(fit);
|
|
58
|
+
const openFolded = folded.find((t) => t.value === value);
|
|
59
|
+
const trigger = (t) => (_jsxs(_Fragment, { children: [_jsx(t.icon, {}), t.label, t.after] }));
|
|
60
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsxs("div", { ref: box, className: "relative min-w-48 max-w-full flex-1", children: [_jsx(Tabs, { "aria-hidden": true, className: "pointer-events-none invisible absolute h-0 overflow-hidden", children: _jsxs(TabsList, { ref: ruler, className: "w-max", children: [shown.map((t) => (_jsx(TabsTrigger, { value: t.value, tabIndex: -1, children: trigger(t) }, t.value))), _jsxs("span", { "data-slot": "tabs-trigger", className: MORE, children: [openFolded ? trigger(openFolded) : 'More', _jsx(ChevronDownIcon, {})] })] }) }), shown.length ? (_jsx(Tabs, { value: value, onValueChange: (v) => onChange(v), children: _jsxs(TabsList, { className: "max-w-full overflow-x-hidden", children: [inRow.map((t) => (_jsx(TabsTrigger, { value: t.value, children: trigger(t) }, t.value))), folded.length > 0 && (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { "data-slot": "tabs-trigger", "data-state": openFolded ? 'active' : 'inactive', className: cn(MORE, openFolded &&
|
|
61
|
+
'text-foreground after:bg-primary [&_svg:first-child]:text-primary'), children: [openFolded ? trigger(openFolded) : 'More', _jsx(ChevronDownIcon, { className: "text-muted-foreground" })] }), _jsx(DropdownMenuContent, { align: "end", className: "w-auto min-w-44",
|
|
62
|
+
// A pick from the menu moves the tab; no keyboard ring back on the button.
|
|
63
|
+
onCloseAutoFocus: (e) => e.preventDefault(), children: _jsx(DropdownMenuRadioGroup, { value: value, onValueChange: (v) => onChange(v), children: folded.map((t) => (_jsx(DropdownMenuRadioItem, { value: t.value, children: trigger(t) }, t.value))) }) })] }))] }) })) : null] }), actions ? _jsx("div", { className: "ml-auto flex shrink-0 items-center gap-0.5", children: actions }) : null] }));
|
|
8
64
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved answers: a react-query persister that keeps an app's answers in IndexedDB, so a page
|
|
3
|
+
* paints at once from the last visit. Pass it to `PersistQueryClientProvider` as `persister`.
|
|
4
|
+
*
|
|
5
|
+
* Nothing waits on the device for good: a read that has not answered within `restoreMs` (another
|
|
6
|
+
* tab holding a write, a broken store) gives up and the app asks the server, and an aborted or
|
|
7
|
+
* blocked store fails instead of hanging. The provider shows nothing until the restore settles,
|
|
8
|
+
* so a hung read would otherwise leave the app blank. Writes go out at most once a second, as one
|
|
9
|
+
* record kept as an object (no JSON round trip).
|
|
10
|
+
*/
|
|
11
|
+
/** The shape `@tanstack/react-query-persist-client` expects; kept structural so the kit needs no extra package. */
|
|
12
|
+
export interface SavedAnswersPersister<C = unknown> {
|
|
13
|
+
persistClient: (client: C) => void;
|
|
14
|
+
restoreClient: () => Promise<C | undefined>;
|
|
15
|
+
removeClient: () => Promise<void> | void;
|
|
16
|
+
}
|
|
17
|
+
export interface SavedAnswersOptions {
|
|
18
|
+
/** The IndexedDB database name; one per app. */
|
|
19
|
+
name: string;
|
|
20
|
+
/** How long the first paint waits for the saved answers before asking the server (ms). Default 1500. */
|
|
21
|
+
restoreMs?: number;
|
|
22
|
+
/** How long writes wait to batch (ms). Default 1000. */
|
|
23
|
+
writeMs?: number;
|
|
24
|
+
}
|
|
25
|
+
export declare function savedAnswers<C = unknown>({ name, restoreMs, writeMs, }: SavedAnswersOptions): SavedAnswersPersister<C>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved answers: a react-query persister that keeps an app's answers in IndexedDB, so a page
|
|
3
|
+
* paints at once from the last visit. Pass it to `PersistQueryClientProvider` as `persister`.
|
|
4
|
+
*
|
|
5
|
+
* Nothing waits on the device for good: a read that has not answered within `restoreMs` (another
|
|
6
|
+
* tab holding a write, a broken store) gives up and the app asks the server, and an aborted or
|
|
7
|
+
* blocked store fails instead of hanging. The provider shows nothing until the restore settles,
|
|
8
|
+
* so a hung read would otherwise leave the app blank. Writes go out at most once a second, as one
|
|
9
|
+
* record kept as an object (no JSON round trip).
|
|
10
|
+
*/
|
|
11
|
+
const KEY = 'queries';
|
|
12
|
+
export function savedAnswers({ name, restoreMs = 1500, writeMs = 1000, }) {
|
|
13
|
+
// No IndexedDB (a test runner, a locked-down browser): nothing is saved, every load asks the server.
|
|
14
|
+
if (typeof indexedDB === 'undefined')
|
|
15
|
+
return {
|
|
16
|
+
persistClient: () => { },
|
|
17
|
+
restoreClient: async () => undefined,
|
|
18
|
+
removeClient: () => { },
|
|
19
|
+
};
|
|
20
|
+
let db;
|
|
21
|
+
const open = () => {
|
|
22
|
+
if (!db)
|
|
23
|
+
db = new Promise((resolve, reject) => {
|
|
24
|
+
const req = indexedDB.open(name, 1);
|
|
25
|
+
req.onupgradeneeded = () => req.result.createObjectStore('cache');
|
|
26
|
+
req.onsuccess = () => resolve(req.result);
|
|
27
|
+
req.onerror = () => reject(req.error);
|
|
28
|
+
req.onblocked = () => reject(new Error(`${name}: saved answers blocked`));
|
|
29
|
+
}).catch((e) => {
|
|
30
|
+
// A failed open is tried again on the next read or write.
|
|
31
|
+
db = undefined;
|
|
32
|
+
throw e;
|
|
33
|
+
});
|
|
34
|
+
return db;
|
|
35
|
+
};
|
|
36
|
+
const run = (mode, op) => open().then((d) => new Promise((resolve, reject) => {
|
|
37
|
+
const tx = d.transaction('cache', mode);
|
|
38
|
+
const req = op(tx.objectStore('cache'));
|
|
39
|
+
tx.oncomplete = () => resolve(req.result);
|
|
40
|
+
tx.onerror = () => reject(tx.error);
|
|
41
|
+
// An aborted transaction (storage full, the browser gave up) fires only abort, never error.
|
|
42
|
+
tx.onabort = () => reject(tx.error ?? new Error(`${name}: saved answers aborted`));
|
|
43
|
+
}));
|
|
44
|
+
let next;
|
|
45
|
+
let timer;
|
|
46
|
+
return {
|
|
47
|
+
persistClient: (client) => {
|
|
48
|
+
next = client;
|
|
49
|
+
timer ??= setTimeout(() => {
|
|
50
|
+
timer = undefined;
|
|
51
|
+
void run('readwrite', (s) => s.put(next, KEY)).catch(() => { });
|
|
52
|
+
}, writeMs);
|
|
53
|
+
},
|
|
54
|
+
restoreClient: () => Promise.race([
|
|
55
|
+
run('readonly', (s) => s.get(KEY)).catch(() => undefined),
|
|
56
|
+
new Promise((resolve) => setTimeout(() => resolve(undefined), restoreMs)),
|
|
57
|
+
]),
|
|
58
|
+
removeClient: () => run('readwrite', (s) => s.delete(KEY)).then(() => { }, () => { }),
|
|
59
|
+
};
|
|
60
|
+
}
|
package/dist/scroll-fade.js
CHANGED
|
@@ -12,8 +12,9 @@ export function useScrollFade(given) {
|
|
|
12
12
|
const el = ref.current;
|
|
13
13
|
if (!el)
|
|
14
14
|
return;
|
|
15
|
-
|
|
16
|
-
const
|
|
15
|
+
// A couple of pixels past the edge (rounding, a border) is not "more beyond it".
|
|
16
|
+
const start = el.scrollLeft > 2;
|
|
17
|
+
const end = el.scrollLeft + el.clientWidth < el.scrollWidth - 2;
|
|
17
18
|
setSide(start && end ? 'both' : start ? 'start' : end ? 'end' : undefined);
|
|
18
19
|
}, [ref]);
|
|
19
20
|
useEffect(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.122",
|
|
4
4
|
"description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"@codemirror/lang-javascript": "6.2.5",
|
|
50
50
|
"@codemirror/lang-json": "6.0.2",
|
|
51
51
|
"@codemirror/lang-sql": "6.10.0",
|
|
52
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
52
|
+
"@columenlabs/lumen-ui-types": "0.1.122",
|
|
53
53
|
"@dnd-kit/core": "6.3.1",
|
|
54
54
|
"@dnd-kit/sortable": "10.0.0",
|
|
55
55
|
"@dnd-kit/utilities": "3.2.2",
|