@columenlabs/lumen-ui 0.1.115 → 0.1.117
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/assistant-panel.d.ts +2 -6
- package/dist/assistant-panel.js +4 -84
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lumen-drawer.d.ts +9 -2
- package/dist/lumen-drawer.js +68 -6
- package/dist/lumen-view.js +39 -3
- package/dist/lumen-widget.d.ts +4 -1
- package/dist/lumen-widget.js +2 -2
- package/dist/pager.d.ts +22 -0
- package/dist/pager.js +64 -1
- package/dist/shell.js +3 -1
- package/dist/side-dock.d.ts +26 -0
- package/dist/side-dock.js +94 -0
- package/dist/ui/overlay-guard.d.ts +2 -0
- package/dist/ui/overlay-guard.js +2 -0
- package/dist/view-editor.js +1 -1
- package/dist/widget-settings.d.ts +1 -0
- package/package.json +2 -2
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
write: (next: T) => void;
|
|
5
|
-
use: () => T;
|
|
6
|
-
get: () => T;
|
|
7
|
-
};
|
|
2
|
+
import { tabState } from './side-dock.js';
|
|
3
|
+
export { tabState };
|
|
8
4
|
/** Whether the assistant is open in this tab. */
|
|
9
5
|
export declare const assistantOpen: {
|
|
10
6
|
write: (next: boolean) => void;
|
package/dist/assistant-panel.js
CHANGED
|
@@ -6,44 +6,18 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
6
6
|
// Open or closed and the width are each browser tab's own (sessionStorage). The
|
|
7
7
|
// product fills the body (the product's assistant: its chat, composer, cards and spend).
|
|
8
8
|
import { HistoryIcon, SparklesIcon } from 'lucide-react';
|
|
9
|
-
import { useEffect, useRef, useState
|
|
9
|
+
import { useEffect, useRef, useState } from 'react';
|
|
10
10
|
import { useIsMobile } from './hooks/use-mobile.js';
|
|
11
11
|
import { cn } from './lib/utils.js';
|
|
12
12
|
import { LumenBoundary } from './lumen-boundary.js';
|
|
13
13
|
import { LumenSelector } from './lumen-selector.js';
|
|
14
14
|
import { ASSISTANT_WIDTH, usePrefs } from './prefs.js';
|
|
15
|
+
import { ResizeEdge, tabState } from './side-dock.js';
|
|
15
16
|
import { Button } from './ui/button.js';
|
|
16
17
|
import { Kbd } from './ui/kbd.js';
|
|
17
18
|
import { Sheet, SheetContent, SheetDescription, SheetTitle } from './ui/sheet.js';
|
|
18
19
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
|
|
19
|
-
|
|
20
|
-
export function tabState(key, fallback, parse) {
|
|
21
|
-
const listeners = new Set();
|
|
22
|
-
const read = () => {
|
|
23
|
-
try {
|
|
24
|
-
const raw = sessionStorage.getItem(key);
|
|
25
|
-
return raw == null ? fallback : (parse(raw) ?? fallback);
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
return fallback;
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
let state = typeof sessionStorage === 'undefined' ? fallback : read();
|
|
32
|
-
const write = (next) => {
|
|
33
|
-
state = next;
|
|
34
|
-
try {
|
|
35
|
-
sessionStorage.setItem(key, String(next));
|
|
36
|
-
}
|
|
37
|
-
catch { }
|
|
38
|
-
for (const l of listeners)
|
|
39
|
-
l();
|
|
40
|
-
};
|
|
41
|
-
const use = () => useSyncExternalStore((l) => {
|
|
42
|
-
listeners.add(l);
|
|
43
|
-
return () => listeners.delete(l);
|
|
44
|
-
}, () => state, () => fallback);
|
|
45
|
-
return { write, use, get: () => state };
|
|
46
|
-
}
|
|
20
|
+
export { tabState };
|
|
47
21
|
/** Whether the assistant is open in this tab. */
|
|
48
22
|
export const assistantOpen = tabState('assistant_open', false, (raw) => raw === '1');
|
|
49
23
|
/** Dragged width in px, within ASSISTANT_WIDTH; 0 is the shell's default. */
|
|
@@ -109,65 +83,11 @@ function Docked({ name, children }) {
|
|
|
109
83
|
}, []);
|
|
110
84
|
return (_jsxs("aside", { ref: ref, "aria-label": name, "data-assistant-docked": "",
|
|
111
85
|
// Its width is the dragged one (or the shell's default), within ASSISTANT_WIDTH and half the window.
|
|
112
|
-
style: width ? { width } : undefined, className: "pointer-events-auto sticky top-0 h-svh w-(--shell-assistant-width) max-w-[50vw] min-w-80 shrink-0 border-l bg-background", children: [_jsx(ResizeEdge, { name: name, width: width || 416, onWidth: (w) => {
|
|
86
|
+
style: width ? { width } : undefined, className: "pointer-events-auto sticky top-0 h-svh w-(--shell-assistant-width) max-w-[50vw] min-w-80 shrink-0 border-l bg-background", children: [_jsx(ResizeEdge, { name: name, width: width || 416, min: ASSISTANT_WIDTH.min, max: Math.min(ASSISTANT_WIDTH.max, Math.floor(window.innerWidth / 2)), slot: "assistant-resize", onWidth: (w) => {
|
|
113
87
|
setDrag(null);
|
|
114
88
|
assistantWidth.write(w);
|
|
115
89
|
}, onDrag: setDrag }), _jsx(LumenBoundary, { where: "assistant", what: "panel", children: children })] }));
|
|
116
90
|
}
|
|
117
|
-
/**
|
|
118
|
-
* The panel's left edge: drag it, or focus it and use the arrow keys, to set the width; a double
|
|
119
|
-
* click goes back to the default. While dragging only `onDrag` hears it.
|
|
120
|
-
*/
|
|
121
|
-
function ResizeEdge({ name, width, onDrag, onWidth, }) {
|
|
122
|
-
const clamp = (w) => Math.round(Math.max(ASSISTANT_WIDTH.min, Math.min(w, ASSISTANT_WIDTH.max, Math.floor(window.innerWidth / 2))));
|
|
123
|
-
// The panel's width on screen (the default one is CSS's).
|
|
124
|
-
const now = (el) => Math.round(el.parentElement?.getBoundingClientRect().width ?? width);
|
|
125
|
-
const [dragging, setDragging] = useState(false);
|
|
126
|
-
useEffect(() => {
|
|
127
|
-
if (!dragging)
|
|
128
|
-
return;
|
|
129
|
-
document.body.style.cursor = 'col-resize';
|
|
130
|
-
document.body.style.userSelect = 'none';
|
|
131
|
-
return () => {
|
|
132
|
-
document.body.style.cursor = '';
|
|
133
|
-
document.body.style.userSelect = '';
|
|
134
|
-
};
|
|
135
|
-
}, [dragging]);
|
|
136
|
-
return (_jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": `Resize ${name}`, "aria-valuemin": ASSISTANT_WIDTH.min, "aria-valuemax": ASSISTANT_WIDTH.max, "aria-valuenow": width, tabIndex: 0, "data-slot": "assistant-resize", "data-dragging": dragging ? '' : undefined, className: "absolute inset-y-0 -left-1 z-20 w-2 cursor-col-resize outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 after:-translate-x-1/2 after:transition-colors hover:after:bg-ring focus-visible:after:bg-ring data-dragging:after:bg-ring", onPointerDown: (e) => {
|
|
137
|
-
if (e.button !== 0)
|
|
138
|
-
return;
|
|
139
|
-
e.preventDefault();
|
|
140
|
-
const el = e.currentTarget;
|
|
141
|
-
el.setPointerCapture(e.pointerId);
|
|
142
|
-
const x0 = e.clientX;
|
|
143
|
-
const w0 = now(el);
|
|
144
|
-
let last = w0;
|
|
145
|
-
setDragging(true);
|
|
146
|
-
const move = (m) => {
|
|
147
|
-
last = clamp(w0 + x0 - m.clientX);
|
|
148
|
-
onDrag(last);
|
|
149
|
-
};
|
|
150
|
-
const up = () => {
|
|
151
|
-
el.removeEventListener('pointermove', move);
|
|
152
|
-
el.removeEventListener('pointerup', up);
|
|
153
|
-
el.removeEventListener('pointercancel', up);
|
|
154
|
-
setDragging(false);
|
|
155
|
-
onWidth(last);
|
|
156
|
-
};
|
|
157
|
-
el.addEventListener('pointermove', move);
|
|
158
|
-
el.addEventListener('pointerup', up);
|
|
159
|
-
el.addEventListener('pointercancel', up);
|
|
160
|
-
}, onDoubleClick: () => onWidth(0), onKeyDown: (e) => {
|
|
161
|
-
const step = e.shiftKey ? 64 : 16;
|
|
162
|
-
if (e.key === 'ArrowLeft')
|
|
163
|
-
onWidth(clamp(now(e.currentTarget) + step));
|
|
164
|
-
else if (e.key === 'ArrowRight')
|
|
165
|
-
onWidth(clamp(now(e.currentTarget) - step));
|
|
166
|
-
else
|
|
167
|
-
return;
|
|
168
|
-
e.preventDefault();
|
|
169
|
-
} }));
|
|
170
|
-
}
|
|
171
91
|
/** The history groups, newest first. */
|
|
172
92
|
const GROUPS = ['Today', 'Yesterday', 'This week', 'Last week', 'Last month'];
|
|
173
93
|
/** Under this age a chat's time reads relative ("12m ago"), past it the clock, day or date. */
|
package/dist/index.d.ts
CHANGED
|
@@ -55,7 +55,7 @@ export { ConnectionBadge, type ConnectionField, type ConnectionLine, ConnectionL
|
|
|
55
55
|
export { LumenChatgptSignin, type LumenChatgptSigninProps } from './chatgpt-signin.js';
|
|
56
56
|
export { LumenExpandList, LumenExpandRow } from './expand-list.js';
|
|
57
57
|
export { type Fact, FactList, type FactListProps } from './fact-list.js';
|
|
58
|
-
export { LumenPager } from './pager.js';
|
|
58
|
+
export { LumenPageFooter, LumenPager, pageList } from './pager.js';
|
|
59
59
|
export { clearPersistedQueries, createDeskQueryClient, type PersistOptions } from './query-client.js';
|
|
60
60
|
export { type ClientPoint, type ClientPointKind, type LumenTelemetryOptions, apiRoute, reportClient, reportClientError, reportFailedFetch, stackFingerprint, useLumenTelemetry, } from './telemetry.js';
|
|
61
61
|
export { refreshHeaders, withRefreshToken } from './refresh.js';
|
package/dist/index.js
CHANGED
|
@@ -62,7 +62,7 @@ export { ConnectionBadge, ConnectionLogo, LumenConnection, activeWay, connection
|
|
|
62
62
|
export { LumenChatgptSignin } from './chatgpt-signin.js';
|
|
63
63
|
export { LumenExpandList, LumenExpandRow } from './expand-list.js';
|
|
64
64
|
export { FactList } from './fact-list.js';
|
|
65
|
-
export { LumenPager } from './pager.js';
|
|
65
|
+
export { LumenPageFooter, LumenPager, pageList } from './pager.js';
|
|
66
66
|
export { clearPersistedQueries, createDeskQueryClient } from './query-client.js';
|
|
67
67
|
export { apiRoute, reportClient, reportClientError, reportFailedFetch, stackFingerprint, useLumenTelemetry, } from './telemetry.js';
|
|
68
68
|
export { refreshHeaders, withRefreshToken } from './refresh.js';
|
package/dist/lumen-drawer.d.ts
CHANGED
|
@@ -17,6 +17,12 @@ export interface LumenDrawerProps {
|
|
|
17
17
|
defaultSize?: Size;
|
|
18
18
|
minSize?: Size;
|
|
19
19
|
maxSize?: Size;
|
|
20
|
+
/**
|
|
21
|
+
* Docks it beside the page on desktop instead of over it, under this name: a click on the page
|
|
22
|
+
* leaves it open (a list's next row swaps what it shows), it folds to a rail, and its width and
|
|
23
|
+
* fold are kept in this browser. A phone still gets the bottom sheet.
|
|
24
|
+
*/
|
|
25
|
+
dock?: string;
|
|
20
26
|
}
|
|
21
27
|
/**
|
|
22
28
|
* The suite's one pullout drawer (named LumenDrawer), on every app.
|
|
@@ -25,7 +31,8 @@ export interface LumenDrawerProps {
|
|
|
25
31
|
* the handle and the drawer take the pointer, so a click beside it falls through to the overlay
|
|
26
32
|
* and closes it (`!` beats the inline pointer-events Radix sets on a modal's content). Its body
|
|
27
33
|
* holds DrawerSections; its footer sits at the bottom (actions on the right, Close by default; on a
|
|
28
|
-
* phone it shows only when there are actions, as the sheet's X closes it).
|
|
34
|
+
* phone it shows only when there are actions, as the sheet's X closes it). With `dock` it sits beside
|
|
35
|
+
* the page on desktop instead (DockedDrawer), for a list whose rows each open it.
|
|
29
36
|
*/
|
|
30
|
-
export declare function LumenDrawer({ open, onClose, title, description, children, footer, footerStart, defaultSize, minSize, maxSize, }: LumenDrawerProps): import("react").JSX.Element;
|
|
37
|
+
export declare function LumenDrawer({ open, onClose, title, description, children, footer, footerStart, defaultSize, minSize, maxSize, dock, }: LumenDrawerProps): import("react").JSX.Element | null;
|
|
31
38
|
export {};
|
package/dist/lumen-drawer.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { XIcon } from 'lucide-react';
|
|
3
|
-
import { useEffect } from 'react';
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { PanelRightCloseIcon, PanelRightOpenIcon, XIcon } from 'lucide-react';
|
|
3
|
+
import { useEffect, useState, useSyncExternalStore } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
4
5
|
import { useIsMobile } from './hooks/use-mobile.js';
|
|
5
6
|
import { IconButton } from './icon-button.js';
|
|
6
7
|
import { lazyPart } from './lazy-part.js';
|
|
8
|
+
import { ResizeEdge, tabState, useDockSlot } from './side-dock.js';
|
|
7
9
|
import { Button } from './ui/button.js';
|
|
10
|
+
import { pickerOpen } from './ui/overlay-guard.js';
|
|
8
11
|
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from './ui/sheet.js';
|
|
9
12
|
/** The resizable panels load with the first drawer opened; until then it opens at its default width. */
|
|
10
13
|
const loadPanels = () => import('./lumen-drawer-panels.js');
|
|
@@ -16,10 +19,12 @@ const DrawerPanels = lazyPart(() => loadPanels().then((m) => m.DrawerPanels));
|
|
|
16
19
|
* the handle and the drawer take the pointer, so a click beside it falls through to the overlay
|
|
17
20
|
* and closes it (`!` beats the inline pointer-events Radix sets on a modal's content). Its body
|
|
18
21
|
* holds DrawerSections; its footer sits at the bottom (actions on the right, Close by default; on a
|
|
19
|
-
* phone it shows only when there are actions, as the sheet's X closes it).
|
|
22
|
+
* phone it shows only when there are actions, as the sheet's X closes it). With `dock` it sits beside
|
|
23
|
+
* the page on desktop instead (DockedDrawer), for a list whose rows each open it.
|
|
20
24
|
*/
|
|
21
|
-
export function LumenDrawer({ open, onClose, title, description, children, footer, footerStart, defaultSize = '32%', minSize = '320px', maxSize = '60%', }) {
|
|
25
|
+
export function LumenDrawer({ open, onClose, title, description, children, footer, footerStart, defaultSize = '32%', minSize = '320px', maxSize = '60%', dock, }) {
|
|
22
26
|
const isMobile = useIsMobile();
|
|
27
|
+
const slot = useDockSlot();
|
|
23
28
|
// Fetched once the page is idle, so the first drawer opened on a desktop has its panels ready.
|
|
24
29
|
useEffect(() => {
|
|
25
30
|
if (isMobile)
|
|
@@ -33,9 +38,66 @@ export function LumenDrawer({ open, onClose, title, description, children, foote
|
|
|
33
38
|
// ends on a clear edge. Without actions of its own it offers Close, except on a phone, where the
|
|
34
39
|
// sheet's X already closes it and a second Close only took room.
|
|
35
40
|
const plain = !footer && !footerStart;
|
|
36
|
-
const foot = (_jsxs("footer", { "data-slot": "drawer-footer", className: "flex shrink-0 items-center gap-2 border-t bg-muted/50 px-4 py-2.5", children: [footerStart && (_jsx("div", { className: "min-w-0 flex-1 truncate text-muted-foreground text-xs", children: footerStart })), _jsx("div", { className: "ml-auto flex items-center gap-2", children: footer ?? (_jsx(
|
|
41
|
+
const foot = (_jsxs("footer", { "data-slot": "drawer-footer", className: "flex shrink-0 items-center gap-2 border-t bg-muted/50 px-4 py-2.5", children: [footerStart && (_jsx("div", { className: "min-w-0 flex-1 truncate text-muted-foreground text-xs", children: footerStart })), _jsx("div", { className: "ml-auto flex items-center gap-2", children: footer ?? (_jsx(Button, { type: "button", size: "sm", variant: "outline", onClick: onClose, children: "Close" })) })] }));
|
|
42
|
+
if (dock && !isMobile && slot)
|
|
43
|
+
return open
|
|
44
|
+
? createPortal(_jsx(DockedDrawer, { dock: dock, title: title, description: description, onClose: onClose, defaultSize: defaultSize, minSize: minSize, maxSize: maxSize, body: body, foot: foot }), slot)
|
|
45
|
+
: null;
|
|
37
46
|
return (_jsx(Sheet, { open: open, onOpenChange: (o) => !o && onClose(), modal: isMobile, children: isMobile ? (_jsxs(SheetContent, { side: "bottom", className: "max-h-[85svh] gap-0 overflow-hidden", children: [_jsx(SheetHeader, { className: "order-first shrink-0 border-b pt-3", children: head }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto pt-2", children: body }), !plain && foot] })) : (_jsx(SheetContent, { side: "right", showCloseButton: false,
|
|
38
47
|
// The resize handle's grab zone is wider than the handle, so a drag can start on the
|
|
39
48
|
// overlay; the panels library marks that pointerdown handled, and then it doesn't close.
|
|
40
49
|
onPointerDownOutside: (e) => e.detail.originalEvent.defaultPrevented && e.preventDefault(), className: "pointer-events-none! gap-0 border-0 bg-transparent shadow-none data-[side=right]:left-0 data-[side=right]:w-auto data-[side=right]:border-l-0 data-[side=right]:sm:max-w-none", children: _jsx(DrawerPanels, { defaultSize: defaultSize, minSize: minSize, maxSize: maxSize, children: _jsxs("section", { className: "flex h-full flex-col overflow-hidden", children: [_jsxs("header", { className: "flex shrink-0 items-center gap-2 border-b px-4 py-3", children: [head, _jsx(SheetClose, { asChild: true, children: _jsx(IconButton, { icon: XIcon, label: "Close", className: "ml-auto" }) })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto pt-2", children: body }), foot] }) }) })) }));
|
|
41
50
|
}
|
|
51
|
+
/** What each docked drawer keeps, by its `dock` name: its dragged width (0: the default) and its fold. */
|
|
52
|
+
const docks = new Map();
|
|
53
|
+
function dockState(dock) {
|
|
54
|
+
let d = docks.get(dock);
|
|
55
|
+
if (!d) {
|
|
56
|
+
d = {
|
|
57
|
+
width: tabState(`lumen_dock_${dock}_width`, 0, (raw) => {
|
|
58
|
+
const n = Math.round(Number(raw));
|
|
59
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
60
|
+
}, 'local'),
|
|
61
|
+
folded: tabState(`lumen_dock_${dock}_folded`, false, (raw) => raw === 'true', 'local'),
|
|
62
|
+
};
|
|
63
|
+
docks.set(dock, d);
|
|
64
|
+
}
|
|
65
|
+
return d;
|
|
66
|
+
}
|
|
67
|
+
const onResize = (l) => {
|
|
68
|
+
window.addEventListener('resize', l);
|
|
69
|
+
return () => window.removeEventListener('resize', l);
|
|
70
|
+
};
|
|
71
|
+
/** A Size in px, a percentage being of the window's width. */
|
|
72
|
+
const px = (size, vw) => size.endsWith('%') ? (vw * Number.parseFloat(size)) / 100 : Number.parseFloat(size);
|
|
73
|
+
/** Room a docked drawer always leaves the page, px. */
|
|
74
|
+
const PAGE_MIN = 480;
|
|
75
|
+
/**
|
|
76
|
+
* A drawer docked right of the page (LumenShell's dock slot): the page narrows beside it and stays
|
|
77
|
+
* usable. Its left edge drags the width within `minSize` and `maxSize` (never leaving the page under
|
|
78
|
+
* PAGE_MIN); it folds to a rail that shows its title; Esc in it closes it, unless a picker in it is open.
|
|
79
|
+
*/
|
|
80
|
+
function DockedDrawer({ dock, title, description, onClose, defaultSize, minSize, maxSize, body, foot, }) {
|
|
81
|
+
const state = dockState(dock);
|
|
82
|
+
const saved = state.width.use();
|
|
83
|
+
const folded = state.folded.use();
|
|
84
|
+
const [drag, setDrag] = useState(null);
|
|
85
|
+
const vw = useSyncExternalStore(onResize, () => window.innerWidth, () => 1440);
|
|
86
|
+
const min = Math.round(px(minSize, vw));
|
|
87
|
+
const max = Math.max(min, Math.round(Math.min(px(maxSize, vw), vw - PAGE_MIN)));
|
|
88
|
+
const clamp = (w) => Math.max(min, Math.min(w, max));
|
|
89
|
+
const width = drag ?? clamp(saved || px(defaultSize, vw));
|
|
90
|
+
const name = typeof title === 'string' ? title : 'Details';
|
|
91
|
+
return (_jsx("aside", { "aria-label": name, "data-slot": "lumen-drawer", "data-docked": "", "data-folded": folded ? '' : undefined, style: folded ? undefined : { width }, className: "sticky top-0 flex h-svh shrink-0 flex-col border-l bg-background data-folded:w-11", onKeyDown: (e) => {
|
|
92
|
+
if (e.key !== 'Escape' || e.defaultPrevented || pickerOpen())
|
|
93
|
+
return;
|
|
94
|
+
// Only Esc from inside it: one from a dialog it opened (a portal) is that dialog's.
|
|
95
|
+
if (!e.currentTarget.contains(e.target))
|
|
96
|
+
return;
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
onClose();
|
|
99
|
+
}, children: folded ? (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col items-center gap-2 overflow-hidden py-3", children: [_jsx(IconButton, { icon: PanelRightOpenIcon, label: "Expand panel", onClick: () => state.folded.write(false) }), _jsx("span", { className: "min-h-0 truncate text-muted-foreground text-xs [writing-mode:vertical-rl]", title: name, children: title })] })) : (_jsxs(_Fragment, { children: [_jsx(ResizeEdge, { name: name, width: width, min: min, max: max, slot: "lumen-drawer-resize", onDrag: setDrag, onWidth: (w) => {
|
|
100
|
+
setDrag(null);
|
|
101
|
+
state.width.write(w);
|
|
102
|
+
} }), _jsxs("header", { className: "flex shrink-0 items-center gap-1 border-b px-4 py-3", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsx("h2", { className: "cn-font-heading truncate font-medium text-base text-foreground", title: name, children: title }), description && (_jsx("p", { className: "truncate text-muted-foreground text-xs", title: typeof description === 'string' ? description : undefined, children: description }))] }), _jsx(IconButton, { icon: PanelRightCloseIcon, label: "Collapse panel", onClick: () => state.folded.write(true) }), _jsx(IconButton, { icon: XIcon, label: "Close", onClick: onClose })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto pt-2", children: body }), foot] })) }));
|
|
103
|
+
}
|
package/dist/lumen-view.js
CHANGED
|
@@ -23,7 +23,7 @@ import { LumenSelector } from './lumen-selector.js';
|
|
|
23
23
|
import { LumenSkeleton, LumenStates, useLiveLoad } from './lumen-source.js';
|
|
24
24
|
import { LumenWidget } from './lumen-widget.js';
|
|
25
25
|
import { MAX_COMPARE, useSymbolChart } from './market/lumen-chart.js';
|
|
26
|
-
import { LumenPager } from './pager.js';
|
|
26
|
+
import { LumenPageFooter, LumenPager } from './pager.js';
|
|
27
27
|
import { maskAmount, useAmountsHidden } from './shell.js';
|
|
28
28
|
import { useSymbolSheet } from './symbol-sheet-context.js';
|
|
29
29
|
import { Badge } from './ui/badge.js';
|
|
@@ -297,7 +297,43 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
|
|
|
297
297
|
const compare = openChart && picked.length > 1 ? (_jsx(IconButton, { icon: ChartSpline, label: `Compare ${picked.join(', ')}`, variant: "outline", onClick: () => openChart(picked) })) : null;
|
|
298
298
|
// A server's page: the pager counts from the server's total, or the rows seen so far. Server
|
|
299
299
|
// groups page themselves: the header counts them.
|
|
300
|
-
const
|
|
300
|
+
const turn = served?.pages
|
|
301
|
+
? {
|
|
302
|
+
total: served.pages.total ?? served.pages.page * served.pages.size + (served.list?.length ?? 0),
|
|
303
|
+
page: served.pages.page,
|
|
304
|
+
size: served.pages.size,
|
|
305
|
+
onPage: served.pages.onPage,
|
|
306
|
+
onSize: served.pages.onSize ?? (() => { }),
|
|
307
|
+
sizes: served.pages.sizes ?? [served.pages.size],
|
|
308
|
+
noun: served.pages.noun ?? 'groups',
|
|
309
|
+
more: served.pages.total === null && served.pages.more,
|
|
310
|
+
}
|
|
311
|
+
: served
|
|
312
|
+
? null
|
|
313
|
+
: server
|
|
314
|
+
? {
|
|
315
|
+
total: server.total ?? server.page * server.size + rows.length,
|
|
316
|
+
page: server.page,
|
|
317
|
+
size: server.size,
|
|
318
|
+
onPage: server.onPage,
|
|
319
|
+
onSize: server.onSize ?? (() => { }),
|
|
320
|
+
sizes: server.sizes ?? [server.size],
|
|
321
|
+
more: server.total === null && server.more,
|
|
322
|
+
}
|
|
323
|
+
: paging
|
|
324
|
+
? {
|
|
325
|
+
total: tableRows.length,
|
|
326
|
+
page,
|
|
327
|
+
size: pageSize,
|
|
328
|
+
onPage: setPageIndex,
|
|
329
|
+
onSize: setPageSize,
|
|
330
|
+
noun: rows.length === total ? 'rows' : `of ${total} rows`,
|
|
331
|
+
more: false,
|
|
332
|
+
}
|
|
333
|
+
: null;
|
|
334
|
+
const pager = turn ? (_jsx(LumenPager, { ...turn })) : served ? (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: served.list ? `${served.list.length.toLocaleString()}${served.more ? '+' : ''} groups` : '' })) : (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: count }));
|
|
335
|
+
// The page footer (`pagination.footer`): under the table while there is more than one page.
|
|
336
|
+
const foot = turn && pagination.footer && !state && (turn.more || turn.total > turn.size) ? (_jsx(LumenPageFooter, { total: turn.total, page: turn.page, size: turn.size, onPage: turn.onPage, more: turn.more })) : null;
|
|
301
337
|
const table = pivot && config.pivot ? (_jsx(PivotTable, { domain: domain, rows: rows, pivot: config.pivot })) : (_jsx(LumenTable, { domain: domain, columns: columns, rows: tableRows, groupBy: groupBy, subgroup: sub, chips: chips, page: paging ? { index: page, size: pageSize } : null, inView: inLayout, fit: fit, onDetails: onDetails, tapped: tapped, selection: selection, groupActions: groupActions, sort: sort, onSort: sorted, serverSorts: server ? (server.sorts ?? []) : undefined, serverGroups: served, total: config.total !== false, add: onAddColumn || onColumns
|
|
302
338
|
? { ...onAddColumn, field: onColumns && { domain, columns, onColumns } }
|
|
303
339
|
: undefined }));
|
|
@@ -310,7 +346,7 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
|
|
|
310
346
|
groupBy,
|
|
311
347
|
sort: config.sort,
|
|
312
348
|
hidden: amountsHidden,
|
|
313
|
-
}) }))] })), filter: state ? undefined : filter, filterCount: filterCount, strip: state ? undefined : strip, link: link, note: note, load: load, shown: shown, rows: total, pinned: inLayout && !fit, className: cn('relative', !inLayout && 'overflow-x-auto overflow-y-hidden [--pin-top:0px]'), children: state ??
|
|
349
|
+
}) }))] })), filter: state ? undefined : filter, filterCount: filterCount, strip: state ? undefined : strip, foot: foot, link: link, note: note, load: load, shown: shown, rows: total, pinned: inLayout && !fit, className: cn('relative', !inLayout && 'overflow-x-auto overflow-y-hidden [--pin-top:0px]'), children: state ??
|
|
314
350
|
(load ? (_jsx(LumenStates, { load: load, skeleton: _jsx(LumenSkeleton, { rows: 10 }), children: table })) : (table)) }));
|
|
315
351
|
}
|
|
316
352
|
/**
|
package/dist/lumen-widget.d.ts
CHANGED
|
@@ -52,7 +52,7 @@ export interface CardLink {
|
|
|
52
52
|
/** Its own filter's linked chips (the phone's count beside the funnel). */
|
|
53
53
|
linked?: number;
|
|
54
54
|
}
|
|
55
|
-
export declare function LumenWidget({ id, kind, icon, title, note, controls, tools, phoneTitle, filter, filterCount, load, rows, loadNote, pad, pinned, compact, className, children, link: given, help, assistant, strip, shown, style, }: {
|
|
55
|
+
export declare function LumenWidget({ id, kind, icon, title, note, controls, tools, phoneTitle, filter, filterCount, load, rows, loadNote, pad, pinned, compact, className, children, link: given, help, assistant, strip, foot, shown, style, }: {
|
|
56
56
|
id: string;
|
|
57
57
|
/** Its controls (Edit widget ▸ Controls): Ask the assistant, Refresh, Filter box and
|
|
58
58
|
* Footer show or hide here; missing keys take the type's default. */
|
|
@@ -91,6 +91,8 @@ export declare function LumenWidget({ id, kind, icon, title, note, controls, too
|
|
|
91
91
|
* follows, or an offer such as Link to Holdings by Symbol.
|
|
92
92
|
*/
|
|
93
93
|
strip?: ReactNode;
|
|
94
|
+
/** A line under the body, above the load line: a table's page footer. */
|
|
95
|
+
foot?: ReactNode;
|
|
94
96
|
/** The card's source: its ↻, and its load time below the body. */
|
|
95
97
|
load?: LumenLoad;
|
|
96
98
|
rows?: number;
|
|
@@ -221,6 +223,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
|
|
|
221
223
|
pagination: {
|
|
222
224
|
on: boolean;
|
|
223
225
|
size: 25 | 50 | 100 | 250;
|
|
226
|
+
footer: boolean;
|
|
224
227
|
};
|
|
225
228
|
width?: "fit" | "scroll" | undefined;
|
|
226
229
|
} | undefined;
|
package/dist/lumen-widget.js
CHANGED
|
@@ -180,7 +180,7 @@ function useWidth() {
|
|
|
180
180
|
}, []);
|
|
181
181
|
return [ref, width];
|
|
182
182
|
}
|
|
183
|
-
export function LumenWidget({ id, kind, icon, title, note, controls, tools, phoneTitle, filter, filterCount = 0, load, rows, loadNote, pad = true, pinned = false, compact = false, className, children, link: given, help, assistant, strip, shown, style, }) {
|
|
183
|
+
export function LumenWidget({ id, kind, icon, title, note, controls, tools, phoneTitle, filter, filterCount = 0, load, rows, loadNote, pad = true, pinned = false, compact = false, className, children, link: given, help, assistant, strip, foot, shown, style, }) {
|
|
184
184
|
const [removing, setRemoving] = useState(false);
|
|
185
185
|
const cell = useGridCell();
|
|
186
186
|
const links = useLinks();
|
|
@@ -237,7 +237,7 @@ export function LumenWidget({ id, kind, icon, title, note, controls, tools, phon
|
|
|
237
237
|
headless && 'relative', className), children: [!headless && (_jsxs("div", { ref: head, className: cn('flex shrink-0 items-center gap-2 px-3', compact ? 'h-8' : 'h-10 border-b', (folded || below) && 'border-b-0', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: [layout && (_jsx(Button, { variant: "ghost", size: "icon-xs", className: "lumen-drag -ml-1 cursor-grab touch-none text-muted-foreground active:cursor-grabbing", "aria-label": `Drag ${title}`, children: _jsx(GripVerticalIcon, {}) })), link && !bare && (_jsx(LinkPicker, { color: link.color, onPick: link.onPick, label: title, className: "-mr-1" })), !compact && _jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: cn('truncate', compact ? 'text-xs text-muted-foreground' : 'text-sm font-semibold', phoneTitle === false && !layout && 'max-md:hidden'), children: title }), link?.color && link.follows && !folded && (_jsxs("span", { className: "min-w-0 shrink truncate text-xs", style: { color: linkVar(link.color) }, title: `Follows ${link.follows}`, children: ["\u00B7 ", link.follows] })), layout && headerOff && (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs(Badge, { variant: "secondary", className: "shrink-0 gap-1 text-muted-foreground", children: [_jsx(EyeOffIcon, {}), " Header off"] }) }), _jsx(TooltipContent, { children: "Shown only in Edit layout. Change it in Edit widget \u25B8 Controls." })] })), !layout && !bare && (_jsx(FoldChevron, { folded: folded, label: title, onToggle: toggle, className: "-ml-1" })), folded && rows != null && (_jsxs("span", { className: "shrink-0 text-xs text-muted-foreground num", children: [rows.toLocaleString(), " ", rows === 1 ? 'row' : 'rows'] })), inline && (_jsx("div", { "data-card-filter": true, className: "flex min-w-40 max-w-xl flex-1 items-center gap-2", children: filter })), note && !layout && !folded && (_jsx("span", { className: "hidden truncate text-xs text-muted-foreground sm:inline", children: note })), _jsxs("span", { className: cn('ml-auto flex shrink-0 items-center gap-1', bare && 'hidden'), children: [!layout && load?.updated && _jsx(LumenFreshness, { load: load }), !layout && !folded && tools, showFilter && mobile && link?.color && !!link.linked && (_jsxs("span", { className: "num flex items-center gap-1 text-xs text-muted-foreground", title: `${link.linked} linked`, children: [_jsx(LinkDot, { color: link.color, className: "size-2" }), link.linked] })), showFilter && mobile && (_jsxs("span", { className: "relative", children: [_jsx(IconButton, { icon: FunnelIcon, label: filterOpen ? 'Hide filter' : 'Filter', "aria-expanded": filterOpen, className: cn(filterOpen && 'text-primary'), onClick: () => setFilterOpen(!filterOpen) }), filterCount > 0 && (_jsx(Badge, { className: "num pointer-events-none absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px]", children: filterCount }))] })), !layout && !folded && help && _jsx(CardHelp, { title: title, children: help }), !layout && load && on('refresh') && (_jsx(LumenRefresh, { load: load, label: `Refresh ${title}` })), (controls?.editable || (!layout && (ask || load?.editScript))) && (_jsxs(DropdownMenu, { modal: false, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(IconButton, { icon: MoreHorizontalIcon, label: `${title} menu` }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-44", children: [ask && !layout && (_jsxs(DropdownMenuItem, { onSelect: ask, children: [_jsx(SparklesIcon, {}), " Ask ", assistantName] })), load?.editScript && !layout && (_jsxs(DropdownMenuItem, { onSelect: load.editScript, children: [_jsx(CodeIcon, {}), " Script"] })), (ask || load?.editScript) && !layout && controls?.editable && (_jsx(DropdownMenuSeparator, {})), controls?.editable && (_jsxs(_Fragment, { children: [!controls.fixed && (_jsxs(_Fragment, { children: [_jsxs(DropdownMenuItem, { onSelect: () => controls.onAction('edit'), children: [_jsx(PencilIcon, {}), " Edit widget"] }), _jsxs(DropdownMenuItem, { disabled: !own, onSelect: () => controls.onAction('duplicate'), children: [_jsx(CopyIcon, {}), " Duplicate"] })] })), _jsxs(DropdownMenuItem, { disabled: controls.first, onSelect: () => controls.onAction('up'), children: [_jsx(ArrowUpIcon, {}), " Move up"] }), _jsxs(DropdownMenuItem, { disabled: controls.last, onSelect: () => controls.onAction('down'), children: [_jsx(ArrowDownIcon, {}), " Move down"] }), controls.onFit && (_jsxs(DropdownMenuItem, { onSelect: controls.onFit, children: [_jsx(FoldVerticalIcon, {}), " Fit height"] })), !controls.fixed && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-muted-foreground focus:text-destructive", onSelect: () => setRemoving(true), children: [_jsx(Trash2Icon, {}), " Remove"] })] }))] }))] })] }))] })] })), below && !headless && (_jsx("div", { "data-card-filter": true, className: cn('flex items-center gap-2 border-b px-3 pb-2', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: filter })), strip && !layout && !folded && !headless ? (_jsx("div", { "data-card-strip": true, className: cn('flex min-w-0 items-center gap-2 border-b px-3 py-1.5 empty:hidden', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: strip })) : null, _jsx("div", { hidden: folded, className: cn('relative min-w-0',
|
|
238
238
|
// On the grid a card fills its cell: with a height of its own its body scrolls; one as
|
|
239
239
|
// tall as it draws keeps its body's height, so the grid can measure it.
|
|
240
|
-
!cell ? 'flex-1' : cell.fixed && 'min-h-0 flex-1 overflow-auto', pad && !table && !flat && (compact ? 'px-3 pb-3' : 'p-3')), children: _jsx(LumenBoundary, { where: id, children: children }) }), headless && (_jsx(HeaderlessMenu, { title: title, ask: ask, askLabel: `Ask ${assistantName}`, load: load && on('refresh') ? load : undefined, editScript: load?.editScript, filter: showFilter ? filter : undefined, filterCount: filterCount })), !layout && !folded && load && on('footer') && (_jsx("div", { className: cn('mt-auto shrink-0', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: _jsx(LumenLoadFooter, { load: load, rows: rows, note: loadNote }) })), controls && (_jsx(LumenConfirm, { open: removing, onOpenChange: setRemoving, title: `Remove ${title}?`, description: table ? 'Add widget brings a table back.' : 'Add widget brings it back.', action: "Remove", onConfirm: () => controls.onAction('remove') }))] }) }));
|
|
240
|
+
!cell ? 'flex-1' : cell.fixed && 'min-h-0 flex-1 overflow-auto', pad && !table && !flat && (compact ? 'px-3 pb-3' : 'p-3')), children: _jsx(LumenBoundary, { where: id, children: children }) }), foot && !folded && (_jsx("div", { className: cn('shrink-0', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: foot })), headless && (_jsx(HeaderlessMenu, { title: title, ask: ask, askLabel: `Ask ${assistantName}`, load: load && on('refresh') ? load : undefined, editScript: load?.editScript, filter: showFilter ? filter : undefined, filterCount: filterCount })), !layout && !folded && load && on('footer') && (_jsx("div", { className: cn('mt-auto shrink-0', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: _jsx(LumenLoadFooter, { load: load, rows: rows, note: loadNote }) })), controls && (_jsx(LumenConfirm, { open: removing, onOpenChange: setRemoving, title: `Remove ${title}?`, description: table ? 'Add widget brings a table back.' : 'Add widget brings it back.', action: "Remove", onConfirm: () => controls.onAction('remove') }))] }) }));
|
|
241
241
|
}
|
|
242
242
|
/**
|
|
243
243
|
* One widget in its card. Widgets over the view's rows (tiles,
|
package/dist/pager.d.ts
CHANGED
|
@@ -11,3 +11,25 @@ export declare function LumenPager({ total, page, size, onPage, onSize, noun, si
|
|
|
11
11
|
/** A server-paged source has rows past `total` it has not counted: shows `of 50+` and Next. */
|
|
12
12
|
more?: boolean;
|
|
13
13
|
}): import("react").JSX.Element;
|
|
14
|
+
/**
|
|
15
|
+
* The pages a footer numbers, from 0: the first, the last and one either side of `page`, with
|
|
16
|
+
* null for each gap (`…`). A gap of one page shows that page instead. With `more` the last is not
|
|
17
|
+
* known, so the list ends in a gap.
|
|
18
|
+
*/
|
|
19
|
+
export declare function pageList(page: number, pages: number, more?: boolean): (number | null)[];
|
|
20
|
+
/**
|
|
21
|
+
* The page footer under a table (`pagination.footer`): the rows shown, first and previous, the
|
|
22
|
+
* numbered pages, next and last, and Go to for any page. The header's pager keeps the page size.
|
|
23
|
+
* On a phone the numbers give way to Page [n] of N.
|
|
24
|
+
*/
|
|
25
|
+
export declare function LumenPageFooter({ total, page, size, onPage, more, className, }: {
|
|
26
|
+
/** The rows there are, or (with `more`) the rows known so far. */
|
|
27
|
+
total: number;
|
|
28
|
+
/** The page shown, from 0. */
|
|
29
|
+
page: number;
|
|
30
|
+
size: number;
|
|
31
|
+
onPage: (page: number) => void;
|
|
32
|
+
/** Rows past `total` not counted yet: the count reads `of 50+` and the last page is not offered. */
|
|
33
|
+
more?: boolean;
|
|
34
|
+
className?: string;
|
|
35
|
+
}): import("react").JSX.Element;
|
package/dist/pager.js
CHANGED
|
@@ -1,14 +1,77 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// The compact pager, the same look as LumenView's: the count opens the page sizes,
|
|
3
3
|
// and the chevrons turn the page while there is more than one. Coverage views page their rows with it.
|
|
4
|
+
// LumenPageFooter is the fuller one under a table: first, last, numbered pages and Go to.
|
|
4
5
|
import { viewPageSizes } from '@columenlabs/lumen-ui-types/views';
|
|
5
|
-
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
|
6
|
+
import { ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon, } from 'lucide-react';
|
|
7
|
+
import { useEffect, useState } from 'react';
|
|
8
|
+
import { useIsMobile } from './hooks/use-mobile.js';
|
|
6
9
|
import { IconButton } from './icon-button.js';
|
|
10
|
+
import { cn } from './lib/utils.js';
|
|
7
11
|
import { Button } from './ui/button.js';
|
|
8
12
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
|
|
13
|
+
import { Input } from './ui/input.js';
|
|
9
14
|
export function LumenPager({ total, page, size, onPage, onSize, noun = 'rows', sizes = viewPageSizes, more = false, }) {
|
|
10
15
|
const pages = Math.max(1, Math.ceil(total / size)) + (more ? 1 : 0);
|
|
11
16
|
return (_jsxs("nav", { "aria-label": "Pages", className: "flex items-center", children: [_jsxs(DropdownMenu, { modal: false, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "sm", className: "num h-7 px-2 text-xs font-normal text-muted-foreground", "aria-label": "Rows per page", children: pages > 1
|
|
12
17
|
? `${(page * size + 1).toLocaleString()}–${Math.min((page + 1) * size, total).toLocaleString()} of ${total.toLocaleString()}${more ? '+' : ''}`
|
|
13
18
|
: `${total.toLocaleString()} ${noun}` }) }), _jsx(DropdownMenuContent, { align: "end", className: "w-40", children: _jsx(DropdownMenuRadioGroup, { value: String(size), onValueChange: (v) => onSize(Number(v)), children: sizes.map((n) => (_jsxs(DropdownMenuRadioItem, { value: String(n), className: "num whitespace-nowrap", children: [n, " per page"] }, n))) }) })] }), pages > 1 && (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: ChevronLeftIcon, label: "Previous page", disabled: page === 0, onClick: () => onPage(page - 1) }), _jsx(IconButton, { icon: ChevronRightIcon, label: "Next page", disabled: page >= pages - 1, onClick: () => onPage(page + 1) })] }))] }));
|
|
14
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* The pages a footer numbers, from 0: the first, the last and one either side of `page`, with
|
|
22
|
+
* null for each gap (`…`). A gap of one page shows that page instead. With `more` the last is not
|
|
23
|
+
* known, so the list ends in a gap.
|
|
24
|
+
*/
|
|
25
|
+
export function pageList(page, pages, more = false) {
|
|
26
|
+
const last = more ? pages - 2 : pages - 1;
|
|
27
|
+
const keep = [...new Set([0, last, page - 1, page, page + 1])]
|
|
28
|
+
.filter((p) => p >= 0 && p < pages)
|
|
29
|
+
.sort((a, b) => a - b);
|
|
30
|
+
const out = [];
|
|
31
|
+
let prev = -1;
|
|
32
|
+
for (const p of keep) {
|
|
33
|
+
if (p - prev === 2)
|
|
34
|
+
out.push(prev + 1);
|
|
35
|
+
else if (p - prev > 2)
|
|
36
|
+
out.push(null);
|
|
37
|
+
out.push(p);
|
|
38
|
+
prev = p;
|
|
39
|
+
}
|
|
40
|
+
if (more)
|
|
41
|
+
out.push(null);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The page footer under a table (`pagination.footer`): the rows shown, first and previous, the
|
|
46
|
+
* numbered pages, next and last, and Go to for any page. The header's pager keeps the page size.
|
|
47
|
+
* On a phone the numbers give way to Page [n] of N.
|
|
48
|
+
*/
|
|
49
|
+
export function LumenPageFooter({ total, page, size, onPage, more = false, className, }) {
|
|
50
|
+
const isMobile = useIsMobile();
|
|
51
|
+
const pages = Math.max(1, Math.ceil(total / size)) + (more ? 1 : 0);
|
|
52
|
+
const to = (p) => onPage(Math.max(0, Math.min(p, pages - 1)));
|
|
53
|
+
const from = Math.min(page * size + 1, total);
|
|
54
|
+
const range = `${from.toLocaleString()}–${Math.min((page + 1) * size, total).toLocaleString()} of ${total.toLocaleString()}${more ? '+' : ''}`;
|
|
55
|
+
const goTo = _jsx(PageInput, { page: page, pages: pages, onPage: to });
|
|
56
|
+
return (_jsxs("nav", { "aria-label": "Pages", "data-page-footer": true, className: cn('flex items-center justify-between gap-x-3 gap-y-1 border-t px-3 py-1.5 whitespace-nowrap', isMobile && 'flex-col', className), children: [_jsx("span", { className: "num truncate text-xs text-muted-foreground", children: range }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsx(IconButton, { icon: ChevronsLeftIcon, label: "First page", disabled: page === 0, onClick: () => to(0) }), _jsx(IconButton, { icon: ChevronLeftIcon, label: "Previous page", disabled: page === 0, onClick: () => to(page - 1) }), isMobile ? (_jsxs("span", { className: "num flex items-center gap-1.5 px-1 text-xs text-muted-foreground", children: ["Page ", goTo, " of ", more ? `${pages - 1}+` : pages.toLocaleString()] })) : (pageList(page, pages, more).map((p, i) => p === null ? (_jsx("span", { "aria-hidden": true, className: "w-5 text-center text-xs text-muted-foreground", children: "\u2026" }, `gap${i}`)) : (_jsx(Button, { variant: p === page ? 'outline' : 'ghost', size: "sm", "aria-label": `Page ${p + 1}`, "aria-current": p === page ? 'page' : undefined, className: cn('num min-w-7 px-1.5 text-xs', p !== page && 'text-muted-foreground'), onClick: () => to(p), children: (p + 1).toLocaleString() }, p)))), _jsx(IconButton, { icon: ChevronRightIcon, label: "Next page", disabled: page >= pages - 1, onClick: () => to(page + 1) }), _jsx(IconButton, { icon: ChevronsRightIcon, label: more ? 'Last page known' : 'Last page', disabled: page >= pages - 1, onClick: () => to(pages - 1) })] }), !isMobile && (_jsxs("span", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: ["Go to ", goTo] }))] }));
|
|
57
|
+
}
|
|
58
|
+
/** The page number a person types: Enter or leaving the box opens it, Esc puts it back. */
|
|
59
|
+
function PageInput({ page, pages, onPage, }) {
|
|
60
|
+
const [text, setText] = useState(String(page + 1));
|
|
61
|
+
useEffect(() => setText(String(page + 1)), [page]);
|
|
62
|
+
const go = () => {
|
|
63
|
+
const n = Number.parseInt(text, 10);
|
|
64
|
+
if (Number.isFinite(n) && n - 1 !== page)
|
|
65
|
+
onPage(n - 1);
|
|
66
|
+
else
|
|
67
|
+
setText(String(page + 1));
|
|
68
|
+
};
|
|
69
|
+
return (_jsx(Input, { inputMode: "numeric", "aria-label": `Page, 1 to ${pages.toLocaleString()}`, value: text, onChange: (e) => setText(e.target.value.replace(/\D/g, '')), onFocus: (e) => e.target.select(), onBlur: go, onKeyDown: (e) => {
|
|
70
|
+
if (e.key === 'Enter')
|
|
71
|
+
go();
|
|
72
|
+
if (e.key === 'Escape' && text !== String(page + 1)) {
|
|
73
|
+
e.stopPropagation();
|
|
74
|
+
setText(String(page + 1));
|
|
75
|
+
}
|
|
76
|
+
}, className: "num h-7 w-14 px-1.5 text-center md:text-xs" }));
|
|
77
|
+
}
|
package/dist/shell.js
CHANGED
|
@@ -13,6 +13,7 @@ import { LumenFinder } from './lumen-finder.js';
|
|
|
13
13
|
import { usePrefs } from './prefs.js';
|
|
14
14
|
import { withRefreshToken } from './refresh.js';
|
|
15
15
|
import { LumenSettings, ThemeToggle, allSettings, } from './settings.js';
|
|
16
|
+
import { DockSlot } from './side-dock.js';
|
|
16
17
|
import { SourceHealthProvider } from './source-health.js';
|
|
17
18
|
import { StatusTimeZone } from './status.js';
|
|
18
19
|
import { Avatar, AvatarFallback } from './ui/avatar.js';
|
|
@@ -96,6 +97,7 @@ const settingsFromUrl = (() => {
|
|
|
96
97
|
})();
|
|
97
98
|
export function LumenShell({ app, appLabel, brand, nav, status, health, statusEnd, releases, releaseApps, settings, shared, sharedLabel, settingsGroups, user = null, signOutUrl, profile, security, onRefresh, hideAmounts = false, onLogs, headerActions = null, panel, finder, wrap = (frame) => frame, children, }) {
|
|
98
99
|
const [statusSlot, setStatusSlot] = useState(null);
|
|
100
|
+
const [dockSlot, setDockSlot] = useState(null);
|
|
99
101
|
const [refreshing, setRefreshing] = useState(false);
|
|
100
102
|
// Refresh reloads the layout too: once the data is fresh the page mounts
|
|
101
103
|
// again, so every card, tab and saved layout starts from what the server holds now.
|
|
@@ -139,7 +141,7 @@ export function LumenShell({ app, appLabel, brand, nav, status, health, statusEn
|
|
|
139
141
|
refreshing,
|
|
140
142
|
hideAmounts,
|
|
141
143
|
headerActions,
|
|
142
|
-
}, children: _jsx(SourceHealthProvider, { children: wrap(_jsx(TooltipProvider, { delayDuration: 200, children: _jsx(SidebarProvider, { defaultOpen: sidebarOpen(), children: _jsxs(LumenFinder, { app: app, settings: findSettings, openSettings: openSettings, config: finder, children: [_jsx(CloseDrawerOnNavigate, {}), _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: brand }), _jsx(SidebarContent, { children: nav }), _jsx(SidebarFooter, { children: _jsx(NavUser, { who: user, signOutUrl: signOutUrl, onSettings: () => openSettings(), onLogs: onLogs }) }), _jsx(SidebarRail, {})] }), _jsxs(SidebarInset, { className: "flex min-h-svh min-w-0 flex-col", children: [_jsx("div", { className: "flex min-w-0 flex-1 flex-col", children: children }, epoch), _jsxs("footer", { "data-slot": "shell-status", "aria-label": "Status", className: "sticky bottom-0 z-10 flex h-(--shell-status-height) shrink-0 items-center gap-4 overflow-x-auto overflow-y-hidden border-t bg-background px-(--shell-gutter) text-xs whitespace-nowrap text-muted-foreground", children: [status, _jsx("div", { ref: setStatusSlot, className: "flex shrink-0 items-center gap-4" }), _jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: [health, releases && _jsx(WhatsNew, { api: releases, apps: releaseApps }), statusEnd, _jsx(StatusTimeZone, {})] })] })] }), panel, _jsx(LumenSettings, { open: settingsAt !== null, section: settingsAt ?? lastSection, onOpen: openSettings, onClose: () => setSettingsAt(null), appLabel: appLabel, profile: profile, security: security, sections: settings, shared: shared, sharedLabel: sharedLabel, groups: settingsGroups }), _jsx(Toaster, { position: "bottom-right" })] }) }) })) }) }));
|
|
144
|
+
}, children: _jsx(DockSlot.Provider, { value: dockSlot, children: _jsx(SourceHealthProvider, { children: wrap(_jsx(TooltipProvider, { delayDuration: 200, children: _jsx(SidebarProvider, { defaultOpen: sidebarOpen(), children: _jsxs(LumenFinder, { app: app, settings: findSettings, openSettings: openSettings, config: finder, children: [_jsx(CloseDrawerOnNavigate, {}), _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: brand }), _jsx(SidebarContent, { children: nav }), _jsx(SidebarFooter, { children: _jsx(NavUser, { who: user, signOutUrl: signOutUrl, onSettings: () => openSettings(), onLogs: onLogs }) }), _jsx(SidebarRail, {})] }), _jsxs(SidebarInset, { className: "flex min-h-svh min-w-0 flex-col", children: [_jsx("div", { className: "flex min-w-0 flex-1 flex-col", children: children }, epoch), _jsxs("footer", { "data-slot": "shell-status", "aria-label": "Status", className: "sticky bottom-0 z-10 flex h-(--shell-status-height) shrink-0 items-center gap-4 overflow-x-auto overflow-y-hidden border-t bg-background px-(--shell-gutter) text-xs whitespace-nowrap text-muted-foreground", children: [status, _jsx("div", { ref: setStatusSlot, className: "flex shrink-0 items-center gap-4" }), _jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: [health, releases && _jsx(WhatsNew, { api: releases, apps: releaseApps }), statusEnd, _jsx(StatusTimeZone, {})] })] })] }), _jsx("div", { ref: setDockSlot, "data-slot": "shell-dock", className: "contents" }), panel, _jsx(LumenSettings, { open: settingsAt !== null, section: settingsAt ?? lastSection, onOpen: openSettings, onClose: () => setSettingsAt(null), appLabel: appLabel, profile: profile, security: security, sections: settings, shared: shared, sharedLabel: sharedLabel, groups: settingsGroups }), _jsx(Toaster, { position: "bottom-right" })] }) }) })) }) }) }));
|
|
143
145
|
}
|
|
144
146
|
/** A status bar entry: an optional health dot, a label and its value ("Mail · Up to date"). */
|
|
145
147
|
export function StatusItem({ label, value, ok, className, }) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A value kept for this browser tab (sessionStorage), or for this browser with `'local'`
|
|
3
|
+
* (localStorage), read with `use` and set with `write`.
|
|
4
|
+
*/
|
|
5
|
+
export declare function tabState<T>(key: string, fallback: T, parse: (raw: string) => T | null, keep?: 'tab' | 'local'): {
|
|
6
|
+
write: (next: T) => void;
|
|
7
|
+
use: () => T;
|
|
8
|
+
get: () => T;
|
|
9
|
+
};
|
|
10
|
+
/** Where a docked drawer renders on desktop; null outside LumenShell (it then opens over the page). */
|
|
11
|
+
export declare const DockSlot: import("react").Context<HTMLElement | null>;
|
|
12
|
+
export declare const useDockSlot: () => HTMLElement | null;
|
|
13
|
+
/**
|
|
14
|
+
* A docked panel's left edge: drag it, or focus it and use the arrow keys, to set the width between
|
|
15
|
+
* `min` and `max`; a double click goes back to the default (0). While dragging only `onDrag` hears it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function ResizeEdge({ name, width, min, max, onDrag, onWidth, slot, }: {
|
|
18
|
+
name: string;
|
|
19
|
+
width: number;
|
|
20
|
+
min: number;
|
|
21
|
+
max: number;
|
|
22
|
+
onDrag: (w: number | null) => void;
|
|
23
|
+
onWidth: (w: number) => void;
|
|
24
|
+
/** Its `data-slot`. */
|
|
25
|
+
slot: string;
|
|
26
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// What a panel docked right of the page needs: a value it keeps (its width, folded or not), the
|
|
3
|
+
// left edge that drags its width, and the shell's slot a docked drawer renders into (LumenShell puts
|
|
4
|
+
// it between the page and the assistant, so the page narrows to make room instead of being covered).
|
|
5
|
+
import { createContext, useContext, useEffect, useState, useSyncExternalStore } from 'react';
|
|
6
|
+
/**
|
|
7
|
+
* A value kept for this browser tab (sessionStorage), or for this browser with `'local'`
|
|
8
|
+
* (localStorage), read with `use` and set with `write`.
|
|
9
|
+
*/
|
|
10
|
+
export function tabState(key, fallback, parse, keep = 'tab') {
|
|
11
|
+
const listeners = new Set();
|
|
12
|
+
const store = () => (keep === 'local' ? localStorage : sessionStorage);
|
|
13
|
+
const read = () => {
|
|
14
|
+
try {
|
|
15
|
+
const raw = store().getItem(key);
|
|
16
|
+
return raw == null ? fallback : (parse(raw) ?? fallback);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
let state = typeof sessionStorage === 'undefined' ? fallback : read();
|
|
23
|
+
const write = (next) => {
|
|
24
|
+
state = next;
|
|
25
|
+
try {
|
|
26
|
+
store().setItem(key, String(next));
|
|
27
|
+
}
|
|
28
|
+
catch { }
|
|
29
|
+
for (const l of listeners)
|
|
30
|
+
l();
|
|
31
|
+
};
|
|
32
|
+
const use = () => useSyncExternalStore((l) => {
|
|
33
|
+
listeners.add(l);
|
|
34
|
+
return () => listeners.delete(l);
|
|
35
|
+
}, () => state, () => fallback);
|
|
36
|
+
return { write, use, get: () => state };
|
|
37
|
+
}
|
|
38
|
+
/** Where a docked drawer renders on desktop; null outside LumenShell (it then opens over the page). */
|
|
39
|
+
export const DockSlot = createContext(null);
|
|
40
|
+
export const useDockSlot = () => useContext(DockSlot);
|
|
41
|
+
/**
|
|
42
|
+
* A docked panel's left edge: drag it, or focus it and use the arrow keys, to set the width between
|
|
43
|
+
* `min` and `max`; a double click goes back to the default (0). While dragging only `onDrag` hears it.
|
|
44
|
+
*/
|
|
45
|
+
export function ResizeEdge({ name, width, min, max, onDrag, onWidth, slot, }) {
|
|
46
|
+
const clamp = (w) => Math.round(Math.max(min, Math.min(w, max)));
|
|
47
|
+
// The panel's width on screen (the default one is CSS's).
|
|
48
|
+
const now = (el) => Math.round(el.parentElement?.getBoundingClientRect().width ?? width);
|
|
49
|
+
const [dragging, setDragging] = useState(false);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!dragging)
|
|
52
|
+
return;
|
|
53
|
+
document.body.style.cursor = 'col-resize';
|
|
54
|
+
document.body.style.userSelect = 'none';
|
|
55
|
+
return () => {
|
|
56
|
+
document.body.style.cursor = '';
|
|
57
|
+
document.body.style.userSelect = '';
|
|
58
|
+
};
|
|
59
|
+
}, [dragging]);
|
|
60
|
+
return (_jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": `Resize ${name}`, "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": width, tabIndex: 0, "data-slot": slot, "data-dragging": dragging ? '' : undefined, className: "absolute inset-y-0 -left-1 z-20 w-2 cursor-col-resize outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 after:-translate-x-1/2 after:transition-colors hover:after:bg-ring focus-visible:after:bg-ring data-dragging:after:bg-ring", onPointerDown: (e) => {
|
|
61
|
+
if (e.button !== 0)
|
|
62
|
+
return;
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
const el = e.currentTarget;
|
|
65
|
+
el.setPointerCapture(e.pointerId);
|
|
66
|
+
const x0 = e.clientX;
|
|
67
|
+
const w0 = now(el);
|
|
68
|
+
let last = w0;
|
|
69
|
+
setDragging(true);
|
|
70
|
+
const move = (m) => {
|
|
71
|
+
last = clamp(w0 + x0 - m.clientX);
|
|
72
|
+
onDrag(last);
|
|
73
|
+
};
|
|
74
|
+
const up = () => {
|
|
75
|
+
el.removeEventListener('pointermove', move);
|
|
76
|
+
el.removeEventListener('pointerup', up);
|
|
77
|
+
el.removeEventListener('pointercancel', up);
|
|
78
|
+
setDragging(false);
|
|
79
|
+
onWidth(last);
|
|
80
|
+
};
|
|
81
|
+
el.addEventListener('pointermove', move);
|
|
82
|
+
el.addEventListener('pointerup', up);
|
|
83
|
+
el.addEventListener('pointercancel', up);
|
|
84
|
+
}, onDoubleClick: () => onWidth(0), onKeyDown: (e) => {
|
|
85
|
+
const step = e.shiftKey ? 64 : 16;
|
|
86
|
+
if (e.key === 'ArrowLeft')
|
|
87
|
+
onWidth(clamp(now(e.currentTarget) + step));
|
|
88
|
+
else if (e.key === 'ArrowRight')
|
|
89
|
+
onWidth(clamp(now(e.currentTarget) - step));
|
|
90
|
+
else
|
|
91
|
+
return;
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
} }));
|
|
94
|
+
}
|
|
@@ -1,2 +1,4 @@
|
|
|
1
|
+
/** Whether a picker is open, so a panel that closes on Esc leaves this one to it. */
|
|
2
|
+
export declare const pickerOpen: () => boolean;
|
|
1
3
|
/** The overlay's `onEscapeKeyDown`: ignores Esc while a picker is open, else runs the caller's. */
|
|
2
4
|
export declare function escapeToPicker(own?: (e: KeyboardEvent) => void): (e: KeyboardEvent) => void;
|
package/dist/ui/overlay-guard.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// document before a base-ui picker open inside it (LumenSelector, Combobox), which isn't one of
|
|
3
3
|
// Radix's layers, so one Esc closed both. While such a picker is open the overlay leaves Esc to it.
|
|
4
4
|
const OPEN_PICKER = '[data-slot="lumen-selector"][data-open], [data-slot="combobox-content"][data-open]';
|
|
5
|
+
/** Whether a picker is open, so a panel that closes on Esc leaves this one to it. */
|
|
6
|
+
export const pickerOpen = () => !!document.querySelector(OPEN_PICKER);
|
|
5
7
|
/** The overlay's `onEscapeKeyDown`: ignores Esc while a picker is open, else runs the caller's. */
|
|
6
8
|
export function escapeToPicker(own) {
|
|
7
9
|
return (e) => {
|
package/dist/view-editor.js
CHANGED
|
@@ -890,7 +890,7 @@ function DetailsSection({ domain, draft, setDraft, shownKeys, }) {
|
|
|
890
890
|
}), 'As loaded'), _jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, "aria-label": "Sort direction", disabled: !draft.sort, value: draft.sort?.desc ? 'desc' : 'asc', onValueChange: (v) => v && draft.sort && setDraft({ ...draft, sort: { ...draft.sort, desc: v === 'desc' } }), children: [_jsx(ToggleGroupItem, { value: "asc", "aria-label": "Ascending", children: _jsx(ArrowUpIcon, {}) }), _jsx(ToggleGroupItem, { value: "desc", "aria-label": "Descending", children: _jsx(ArrowDownIcon, {}) })] })] }) }), _jsx(Row, { label: "Pages", children: _jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Switch, { "aria-label": "Pagination", checked: pagination.on, onCheckedChange: (on) => setDraft({ ...draft, pagination: { ...pagination, on } }) }), _jsx(LumenSelector, { label: "Rows per page", size: "sm", className: "num w-36", disabled: !pagination.on, options: viewPageSizes.map((n) => ({ value: String(n), label: `${n} per page` })), value: String(pagination.size), onChange: (v) => setDraft({
|
|
891
891
|
...draft,
|
|
892
892
|
pagination: { ...pagination, size: Number(v) },
|
|
893
|
-
}) })] }) }), _jsx(Row, { label: "Table width", children: _jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: draft.width ?? 'scroll', onValueChange: (v) => v && setDraft({ ...draft, width: v }), "aria-label": "Table width", children: [_jsxs(ToggleGroupItem, { value: "fit", "aria-label": "Fit to width", children: [_jsx(ShrinkIcon, {}), " Fit"] }), _jsxs(ToggleGroupItem, { value: "scroll", "aria-label": "Scroll sideways", children: [_jsx(MoveHorizontalIcon, {}), " Scroll"] })] }) })] }));
|
|
893
|
+
}) })] }) }), _jsx(Row, { label: "Page footer", children: _jsx(Switch, { "aria-label": "Page footer", checked: pagination.footer, disabled: !pagination.on, onCheckedChange: (footer) => setDraft({ ...draft, pagination: { ...pagination, footer } }) }) }), _jsx(Row, { label: "Table width", children: _jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: draft.width ?? 'scroll', onValueChange: (v) => v && setDraft({ ...draft, width: v }), "aria-label": "Table width", children: [_jsxs(ToggleGroupItem, { value: "fit", "aria-label": "Fit to width", children: [_jsx(ShrinkIcon, {}), " Fit"] }), _jsxs(ToggleGroupItem, { value: "scroll", "aria-label": "Scroll sideways", children: [_jsx(MoveHorizontalIcon, {}), " Scroll"] })] }) })] }));
|
|
894
894
|
}
|
|
895
895
|
const CHIP_LABELS = { text: 'Text', column: 'Chip', inline: 'On first' };
|
|
896
896
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.117",
|
|
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.117",
|
|
53
53
|
"@dnd-kit/core": "6.3.1",
|
|
54
54
|
"@dnd-kit/sortable": "10.0.0",
|
|
55
55
|
"@dnd-kit/utilities": "3.2.2",
|