@columenlabs/lumen-ui 0.1.116 → 0.1.118

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 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';
@@ -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 pager = served?.pages ? (_jsx(LumenPager, { total: served.pages.total ?? served.pages.page * served.pages.size + (served.list?.length ?? 0), page: served.pages.page, size: served.pages.size, onPage: served.pages.onPage, onSize: served.pages.onSize ?? (() => { }), sizes: served.pages.sizes ?? [served.pages.size], noun: served.pages.noun ?? 'groups', more: served.pages.total === null && served.pages.more })) : served ? (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: served.list ? `${served.list.length.toLocaleString()}${served.more ? '+' : ''} groups` : '' })) : server ? (_jsx(LumenPager, { total: server.total ?? server.page * server.size + rows.length, page: server.page, size: server.size, onPage: server.onPage, onSize: server.onSize ?? (() => { }), sizes: server.sizes ?? [server.size], more: server.total === null && server.more })) : paging ? (_jsx(LumenPager, { total: tableRows.length, page: page, size: pageSize, onPage: setPageIndex, onSize: setPageSize, noun: rows.length === total ? 'rows' : `of ${total} rows` })) : (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: count }));
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
  /**
@@ -43,7 +43,7 @@ export function StackedColumns({ rows, x, parts, label, height = 220, tick = Str
43
43
  // The highest part with a value carries the column's rounded cap.
44
44
  const top = (r) => parts.findLast((p) => Number(r[p.key]) > 0)?.key;
45
45
  const ticks = niceTicks(Math.max(0, ...rows.map(total)));
46
- return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(ChartContainer, { config: config, role: "img", "aria-label": label, className: "aspect-auto w-full", style: { height }, children: _jsxs(BarChart, { data: rows, margin: { left: 0, right: 0, top: 8, bottom: 0 }, children: [_jsx(CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: x, tickLine: false, axisLine: false, tickMargin: 8, minTickGap: 12, tickFormatter: (v) => tick(String(v)) }), _jsx(YAxis, { tickLine: false, axisLine: false, domain: [0, ticks.at(-1) ?? 'auto'], ticks: ticks, width: axisWidth(ticks.map(fmt)), tickFormatter: (v) => fmt(Number(v)) }), _jsx(ChartTooltip, { cursor: { fill: 'var(--muted)', opacity: 0.5 }, content: _jsx(ChartTooltipContent, { labelFormatter: (_, payload) => {
46
+ return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(ChartContainer, { config: config, role: "img", "aria-label": label, className: "aspect-auto w-full", style: { height }, children: _jsxs(BarChart, { data: rows, margin: { left: 0, right: 0, top: 8, bottom: 0 }, children: [_jsx(CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: x, tickLine: false, axisLine: false, tickMargin: 8, minTickGap: 12, tickFormatter: (v) => tick(String(v)) }), _jsx(YAxis, { tickLine: false, axisLine: false, domain: [0, ticks.at(-1) ?? 'auto'], ticks: ticks, width: axisWidth(ticks.map(fmt)), tick: axisTick(fmt) }), _jsx(ChartTooltip, { cursor: { fill: 'var(--muted)', opacity: 0.5 }, content: _jsx(ChartTooltipContent, { labelFormatter: (_, payload) => {
47
47
  const r = payload?.[0]?.payload;
48
48
  return r ? (_jsxs("span", { className: "flex w-full gap-3", children: [_jsx("span", { children: tick(String(r[x])) }), _jsx("span", { className: "num ml-auto", children: fmt(total(r)) })] })) : null;
49
49
  }, formatter: (v, name) => (_jsxs("span", { className: "flex w-full items-center gap-2", children: [_jsx("span", { className: "size-2 shrink-0 rounded-sm", style: { background: config[String(name)]?.color } }), _jsx("span", { children: config[String(name)]?.label }), _jsx("span", { className: "num ml-auto pl-3", children: fmt(Number(v)) })] })) }) }), parts.map((p) => (_jsx(Bar, { dataKey: p.key, stackId: "a", fill: p.fill, isAnimationActive: false, shape: (b) => (_jsx(Rectangle, { ...b, radius: top(b.payload) === p.key ? CAP : 0 })) }, p.key)))] }) }), legend && (_jsx("ul", { className: "flex flex-wrap justify-center gap-x-3 gap-y-1 text-xs text-muted-foreground", children: parts.map((p) => (_jsxs("li", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "size-2 rounded-sm", style: { background: p.fill } }), p.name] }, p.key))) }))] }));
@@ -59,7 +59,7 @@ export function TimeLines({ rows, x, lines, label, height = 220, tick = String,
59
59
  const left = axis(false);
60
60
  const hasRight = lines.some((l) => l.right);
61
61
  const right = hasRight ? axis(true) : [];
62
- return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(ChartContainer, { config: config, role: "img", "aria-label": label, className: "aspect-auto w-full", style: { height }, children: _jsxs(LineChart, { data: rows, margin: { left: 0, right: 0, top: 8, bottom: 0 }, children: [_jsx(CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: x, tickLine: false, axisLine: false, tickMargin: 8, minTickGap: 12, tickFormatter: (v) => tick(String(v)) }), _jsx(YAxis, { yAxisId: "left", tickLine: false, axisLine: false, domain: [0, left.at(-1) ?? 'auto'], ticks: left, width: axisWidth(left.map(fmt)), tickFormatter: (v) => fmt(Number(v)) }), hasRight && (_jsx(YAxis, { yAxisId: "right", orientation: "right", tickLine: false, axisLine: false, domain: [0, right.at(-1) ?? 'auto'], ticks: right, width: axisWidth(right.map(fmt)), tickFormatter: (v) => fmt(Number(v)) })), _jsx(ChartTooltip, { cursor: { stroke: 'var(--border)' }, content: _jsx(ChartTooltipContent, { labelFormatter: (_, payload) => {
62
+ return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(ChartContainer, { config: config, role: "img", "aria-label": label, className: "aspect-auto w-full", style: { height }, children: _jsxs(LineChart, { data: rows, margin: { left: 0, right: 0, top: 8, bottom: 0 }, children: [_jsx(CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: x, tickLine: false, axisLine: false, tickMargin: 8, minTickGap: 12, tickFormatter: (v) => tick(String(v)) }), _jsx(YAxis, { yAxisId: "left", tickLine: false, axisLine: false, domain: [0, left.at(-1) ?? 'auto'], ticks: left, width: axisWidth(left.map(fmt)), tick: axisTick(fmt) }), hasRight && (_jsx(YAxis, { yAxisId: "right", orientation: "right", tickLine: false, axisLine: false, domain: [0, right.at(-1) ?? 'auto'], ticks: right, width: axisWidth(right.map(fmt)), tick: axisTick(fmt) })), _jsx(ChartTooltip, { cursor: { stroke: 'var(--border)' }, content: _jsx(ChartTooltipContent, { labelFormatter: (_, payload) => {
63
63
  const r = payload?.[0]?.payload;
64
64
  return r ? tick(String(r[x])) : null;
65
65
  }, formatter: (v, name) => (_jsxs("span", { className: "flex w-full items-center gap-2", children: [_jsx("span", { className: "size-2 shrink-0 rounded-sm", style: { background: config[String(name)]?.color } }), _jsx("span", { children: config[String(name)]?.label }), _jsx("span", { className: "num ml-auto pl-3", children: fmt(Number(v)) })] })) }) }), lines.map((l) => (_jsx(Line, { dataKey: l.key, yAxisId: l.right ? 'right' : 'left', type: "monotone", stroke: l.fill, strokeWidth: 2, dot: false, connectNulls: false, isAnimationActive: false }, l.key)))] }) }), legend && (_jsx("ul", { className: "flex flex-wrap justify-center gap-x-3 gap-y-1 text-xs text-muted-foreground", children: lines.map((l) => (_jsxs("li", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "h-0.5 w-3 rounded-full", style: { background: l.fill } }), l.name] }, l.key))) }))] }));
@@ -124,5 +124,10 @@ function niceTicks(max, count = 4) {
124
124
  const n = Math.ceil(max / step - 1e-9);
125
125
  return Array.from({ length: n + 1 }, (_, i) => Math.round(i * step * 1e6) / 1e6);
126
126
  }
127
- /** Room for the widest tick label (about 7px a character at text-xs), so none is clipped. */
128
- const axisWidth = (labels) => Math.max(24, Math.max(0, ...labels.map((l) => l.length)) * 7 + 8);
127
+ /** Room for the widest tick label (about 7.5px a character at text-xs), so none is clipped. */
128
+ const axisWidth = (labels) => Math.max(24, Math.max(0, ...labels.map((l) => l.length)) * 7.5 + 8);
129
+ /**
130
+ * A value axis's tick on one line: the chart's own text breaks a label at its space (`300` over
131
+ * `GB`) when the axis is a hair too narrow, and the top one then runs off the chart.
132
+ */
133
+ const axisTick = (fmt) => ({ x, y, payload, textAnchor }) => (_jsx("text", { x: x, y: y, dy: "0.355em", textAnchor: textAnchor ?? 'end', className: "fill-muted-foreground", style: { whiteSpace: 'pre' }, children: fmt(Number(payload.value)) }));
@@ -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;
@@ -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
+ }
@@ -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
  /**
@@ -116,6 +116,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
116
116
  pagination: {
117
117
  on: boolean;
118
118
  size: 25 | 50 | 100 | 250;
119
+ footer: boolean;
119
120
  };
120
121
  width?: "fit" | "scroll" | undefined;
121
122
  } | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.116",
3
+ "version": "0.1.118",
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.116",
52
+ "@columenlabs/lumen-ui-types": "0.1.118",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",