@columenlabs/lumen-ui 0.1.123 → 0.1.125

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.
@@ -110,6 +110,9 @@ export interface LumenLayoutViewProps<T> {
110
110
  selection?: LumenViewProps<T>['selection'];
111
111
  /** A row click opens it (a page of its own) instead of the details drawer. */
112
112
  onOpen?: (row: T) => void;
113
+ /** The key of the row `onOpen` shows in a drawer of the app's own (a docked one), so the table
114
+ * marks it selected; the layout's own details drawer marks its row itself. */
115
+ openKey?: string | null;
113
116
  /** The `rowLink` value tapped when the page opens, so linked cards start narrowed to one row
114
117
  * (a playbook's Results open on its Default variant). */
115
118
  rowPick?: string;
@@ -211,12 +211,12 @@ function CardsLayout({ crumbs, actions, meta, status, intro, cards, lead, fit })
211
211
  * columns, sort and grouping over the view's rows (and formulas), its own chips and tapped row,
212
212
  * shared with the cards of its colour like any card. A Columns change saves into its layout.
213
213
  */
214
- function LayoutTable({ card, base, config, rows, load, state, onDetails, controls, onLayout, }) {
214
+ function LayoutTable({ card, base, config, rows, load, state, onDetails, openKey, controls, onLayout, }) {
215
215
  const own = useMemo(() => tableConfigOf(config, { ...card, table: card.table ?? tableLayoutOf(config) }), [config, card]);
216
216
  const domain = useMemo(() => withViewFields(base, own), [base, own]);
217
- return (_jsx(LumenViewCard, { id: card.id, domain: domain, config: own, rows: rows, load: load, state: state, title: card.title ?? config.name, onDetails: onDetails, controls: controls, shown: card.controls, onColumns: onLayout && ((columns) => onLayout(tableLayoutOf({ ...own, columns }))) }));
217
+ return (_jsx(LumenViewCard, { id: card.id, domain: domain, config: own, rows: rows, load: load, state: state, title: card.title ?? config.name, onDetails: onDetails, openKey: openKey, controls: controls, shown: card.controls, onColumns: onLayout && ((columns) => onLayout(tableLayoutOf({ ...own, columns }))) }));
218
218
  }
219
- function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead, domain: base, config, rows, onConfigChange: saveConfig, editable: canEdit, editor, onDuplicate, onDelete, onReset, load, state, tile, rowTitle, viewId, onShownRows, onSummaryRows, selection, onOpen, rowPick: initialPick, groupActions, onTokens, paging, viewTools, columnPicker, hideFilter, save: saveMode = 'document', }) {
219
+ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead, domain: base, config, rows, onConfigChange: saveConfig, editable: canEdit, editor, onDuplicate, onDelete, onReset, load, state, tile, rowTitle, viewId, onShownRows, onSummaryRows, selection, onOpen, openKey: openedKey, rowPick: initialPick, groupActions, onTokens, paging, viewTools, columnPicker, hideFilter, save: saveMode = 'document', }) {
220
220
  const onConfigChange = canEdit === false ? undefined : saveConfig;
221
221
  // The registry's join fields for the keys the rows carry (Symbol, Account), then the view's own
222
222
  // formulas and formats.
@@ -260,6 +260,7 @@ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead,
260
260
  useEffect(() => onServerSort?.(heldSort ?? null), [onServerSort, heldSort]);
261
261
  const [details, setDetails] = useState(null);
262
262
  const onDetails = onOpen ?? (domain.details?.length ? setDetails : undefined);
263
+ const openKey = onOpen ? (openedKey ?? null) : details === null ? null : domain.rowKey(details);
263
264
  // The assistant sees the view, its data domain, its filter, how many rows show, the first of them and the
264
265
  // row open in the drawer (it saw only a row count and could not answer).
265
266
  useAssistantNote('view', {
@@ -551,7 +552,7 @@ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead,
551
552
  return {
552
553
  ...place,
553
554
  wide: isTableCard(c) && c.w === GRID_COLS && c.h === undefined,
554
- node: c.source ? (_jsx(SourcedCard, { card: c, name: config.name, controls: controls })) : isTableCard(c) && c.id !== viewCardId ? (_jsx(LayoutTable, { card: c, base: joins.domain, config: config, rows: c.filter.length ? filterByTokens(rows, widgetFields, c.filter) : rows, load: scope(c.id, load), state: state, onDetails: onDetails, controls: controls, onLayout: onConfigChange ? (table) => putCard({ ...c, table }) : undefined })) : isTableCard(c) ? (_jsx(LumenView, { domain: domain, config: config, rows: viewRows, total: rows.length, paging: paging, groupBy: group.groupField, pivot: pivot, subgroup: group.sub, load: scope(c.id, load), state: state, title: c.title ?? config.name, selection: selection, onDetails: rowPick.onTap ?? onDetails, tapped: rowPick.tapped, groupActions: groupActions, controls: controls, inLayout: true, tools: viewTools, filter: viewFilter, filterCount: filter.tokens.length, onClearFilter: filter.clear, strip: _jsx(ConnectOffer, { domain: domain, rows: rows, config: config, read: joins.read, cardKey: viewId ?? config.name, onConnect: onConfigChange ? connect : undefined }), link: cardLink(c.id, viewFollows, viewColor ? filter.tokens.length : 0), shown: c.controls, onColumns: onConfigChange && columnPicker !== false
555
+ node: c.source ? (_jsx(SourcedCard, { card: c, name: config.name, controls: controls })) : isTableCard(c) && c.id !== viewCardId ? (_jsx(LayoutTable, { card: c, base: joins.domain, config: config, rows: c.filter.length ? filterByTokens(rows, widgetFields, c.filter) : rows, load: scope(c.id, load), state: state, onDetails: onDetails, openKey: openKey, controls: controls, onLayout: onConfigChange ? (table) => putCard({ ...c, table }) : undefined })) : isTableCard(c) ? (_jsx(LumenView, { domain: domain, config: config, rows: viewRows, total: rows.length, paging: paging, groupBy: group.groupField, pivot: pivot, subgroup: group.sub, load: scope(c.id, load), state: state, title: c.title ?? config.name, selection: selection, onDetails: rowPick.onTap ?? onDetails, tapped: rowPick.tapped, openKey: openKey, groupActions: groupActions, controls: controls, inLayout: true, tools: viewTools, filter: viewFilter, filterCount: filter.tokens.length, onClearFilter: filter.clear, strip: _jsx(ConnectOffer, { domain: domain, rows: rows, config: config, read: joins.read, cardKey: viewId ?? config.name, onConnect: onConfigChange ? connect : undefined }), link: cardLink(c.id, viewFollows, viewColor ? filter.tokens.length : 0), shown: c.controls, onColumns: onConfigChange && columnPicker !== false
555
556
  ? (columns) => onConfigChange({ ...config, columns })
556
557
  : undefined, onAddColumn: addColumn, sort: heldSort, onSort: setHeldSort })) : (_jsx(LumenSavedWidget, { widget: c, title: cardTitle(c, config.name), domain: domain, rows: narrow(rows, followed(c.id)), link: cardLink(c.id, followed(c.id)), fields: widgetFields, merge: mergeFlat, onDetails: onDetails, load: scope(c.id, load), tiles: 'tile' in c
557
558
  ? cardOf(c)
@@ -1,10 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Trend widgets: up to 4 number fields over a date field of the view's rows, rolled up per day, week,
3
- // month or year, drawn as a line, area, stacked area, bars over time, combo, a spark or a tracker.
3
+ // month or year, drawn as a line, area, stacked area, bars over time, stacked bars, combo, a spark
4
+ // or a tracker.
4
5
  // With the allocation, donut, bars and card styles they cover every Tremor visualization, drawn on
5
6
  // our own Recharts in ChartContainer. Worked out in the browser from the rows; nothing is fetched.
6
7
  import { formatBytes, isDollarFormat, } from '@columenlabs/lumen-ui-types/views';
7
- import { useMemo } from 'react';
8
+ import { useMemo, useState } from 'react';
8
9
  import { Area, AreaChart, Bar, BarChart, CartesianGrid, ComposedChart, Line, LineChart, XAxis, YAxis, } from 'recharts';
9
10
  import { cn } from './lib/utils.js';
10
11
  import { Headline } from './lumen-widget-charts.js';
@@ -115,6 +116,8 @@ function tick(at, bucket) {
115
116
  export function TrendBody({ style, date, values, bucket, agg, rows, read, legend = true, }) {
116
117
  const hidden = useAmountsHidden();
117
118
  const points = useMemo(() => trendPoints(rows, date, values, bucket, agg, read), [rows, date, values, bucket, agg, read]);
119
+ // Series hidden by a tap on the legend; the last one shown can't be hidden.
120
+ const [off, setOff] = useState([]);
118
121
  const first = values[0];
119
122
  if (!points.length || !first)
120
123
  return _jsx("p", { className: "py-6 text-center text-sm text-muted-foreground", children: "No dated rows to draw." });
@@ -124,6 +127,12 @@ export function TrendBody({ style, date, values, bucket, agg, rows, read, legend
124
127
  `v${i}`,
125
128
  { label: agg === 'count' ? `${f.label} (count)` : f.label, color: COLORS[i] },
126
129
  ]));
130
+ const keys = values.map((_, i) => `v${i}`);
131
+ const on = keys.filter((k) => !off.includes(k));
132
+ const toggle = (k) => setOff((o) => o.includes(k) ? o.filter((x) => x !== k) : o.length + 1 < keys.length ? [...o, k] : o);
133
+ // Stacked parts add up: the headline and tooltip read their total.
134
+ const stacks = (style === 'stacked' || style === 'stacked_bars') && on.length > 1;
135
+ const totalOf = (p) => on.reduce((t, k) => t + Number(p?.[k] ?? 0), 0);
127
136
  const last = Number(points[points.length - 1]?.v0 ?? 0);
128
137
  const start = Number(points[0]?.v0 ?? 0);
129
138
  if (style === 'tracker') {
@@ -143,17 +152,23 @@ export function TrendBody({ style, date, values, bucket, agg, rows, read, legend
143
152
  _jsx(CartesianGrid, { vertical: false, strokeDasharray: "3 3" }, "g"),
144
153
  _jsx(XAxis, { dataKey: "at", tickLine: false, axisLine: false, minTickGap: 24, tickFormatter: (v) => tick(String(v), bucket), fontSize: 11 }, "x"),
145
154
  _jsx(YAxis, { tickLine: false, axisLine: false, width: 48, tickFormatter: (v) => fmt(Number(v)), fontSize: 11 }, "y"),
146
- _jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { labelFormatter: (v) => tick(String(v), bucket), formatter: (v, name) => {
155
+ _jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { labelFormatter: (v) => tick(String(v), bucket), formatter: (v, name, item) => {
147
156
  const i = Number(String(name).slice(1));
148
157
  const f = values[i] ?? first;
149
- return (_jsxs("span", { className: "flex w-full items-center gap-2", children: [_jsx("span", { className: "size-2 shrink-0 rounded-full", style: { background: COLORS[i] } }), _jsx("span", { className: "truncate", children: config[String(name)]?.label }), _jsx("span", { className: "num ml-auto pl-3", children: fmt(Number(v), f) })] }));
158
+ return (_jsxs(_Fragment, { children: [_jsxs("span", { className: "flex w-full items-center gap-2", children: [_jsx("span", { className: "size-2 shrink-0 rounded-full", style: { background: COLORS[i] } }), _jsx("span", { className: "truncate", children: config[String(name)]?.label }), _jsx("span", { className: "num ml-auto pl-3", children: fmt(Number(v), f) })] }), stacks && String(name) === on[on.length - 1] && (_jsxs("span", { className: "flex w-full items-center gap-2 border-t pt-1.5 font-medium", children: [_jsx("span", { className: "size-2 shrink-0" }), _jsx("span", { className: "truncate", children: "Total" }), _jsx("span", { className: "num ml-auto pl-3", children: fmt(totalOf(item.payload), f) })] }))] }));
150
159
  } }) }, "t"),
151
160
  ...(legend && values.length > 1
152
- ? [_jsx(ChartLegend, { content: _jsx(ChartLegendContent, {}) }, "l")]
161
+ ? [_jsx(ChartLegend, { content: _jsx(ChartLegendContent, { off: off, onSeries: toggle }) }, "l")]
153
162
  : []),
154
163
  ];
155
- const lead = (_jsx(Headline, { value: fmt(last), caption: `${config.v0?.label} · ${tick(String(points[points.length - 1]?.at), bucket)}` }));
164
+ const end = points[points.length - 1];
165
+ const lead0 = on[0] ?? 'v0';
166
+ const lead = (_jsx(Headline, { value: fmt(stacks ? totalOf(end) : Number(end?.[lead0] ?? 0)), caption: `${stacks ? 'Total' : config[lead0]?.label} · ${tick(String(end?.at), bucket)}` }));
156
167
  const box = 'aspect-auto h-56 w-full';
157
- const keys = values.map((_, i) => `v${i}`);
158
- return (_jsxs("div", { className: "flex flex-col", children: [lead, _jsx(ChartContainer, { config: config, className: box, children: style === 'bars' ? (_jsxs(BarChart, { data: points, margin: { left: 0, right: 4 }, children: [axes, keys.map((k) => (_jsx(Bar, { dataKey: k, fill: `var(--color-${k})`, radius: 2, isAnimationActive: false }, k)))] })) : style === 'combo' ? (_jsxs(ComposedChart, { data: points, margin: { left: 0, right: 4 }, children: [axes, keys.map((k, i) => i === 0 ? (_jsx(Bar, { dataKey: k, fill: `var(--color-${k})`, radius: 2, isAnimationActive: false }, k)) : (_jsx(Line, { dataKey: k, type: "monotone", stroke: `var(--color-${k})`, strokeWidth: 1.5, dot: false, isAnimationActive: false }, k)))] })) : style === 'area' || style === 'stacked' ? (_jsxs(AreaChart, { data: points, margin: { left: 0, right: 4 }, children: [axes, keys.map((k) => (_jsx(Area, { dataKey: k, type: "monotone", stackId: style === 'stacked' ? 'a' : undefined, stroke: `var(--color-${k})`, fill: `var(--color-${k})`, fillOpacity: 0.15, strokeWidth: 1.5, isAnimationActive: false }, k)))] })) : (_jsxs(LineChart, { data: points, margin: { left: 0, right: 4 }, children: [axes, keys.map((k) => (_jsx(Line, { dataKey: k, type: "monotone", stroke: `var(--color-${k})`, strokeWidth: 1.5, dot: false, isAnimationActive: false }, k)))] })) })] }));
168
+ // Room above the top gridline, so its label sits on the line rather than below it (a bar near
169
+ // the top read as reaching the label's value).
170
+ const margin = { top: 8, left: 0, right: 4 };
171
+ // A stack's top part rounds its data end; a 2px gap of the card's colour parts the segments.
172
+ const top = on[on.length - 1];
173
+ return (_jsxs("div", { className: "flex flex-col", children: [lead, _jsx(ChartContainer, { config: config, className: box, children: style === 'bars' || style === 'stacked_bars' ? (_jsxs(BarChart, { data: points, margin: margin, children: [axes, keys.map((k) => style === 'stacked_bars' ? (_jsx(Bar, { dataKey: k, hide: off.includes(k), stackId: "a", fill: `var(--color-${k})`, stroke: "var(--card)", strokeWidth: on.length > 1 ? 2 : 0, radius: k === top ? [2, 2, 0, 0] : 0, isAnimationActive: false }, k)) : (_jsx(Bar, { dataKey: k, hide: off.includes(k), fill: `var(--color-${k})`, radius: 2, isAnimationActive: false }, k)))] })) : style === 'combo' ? (_jsxs(ComposedChart, { data: points, margin: margin, children: [axes, keys.map((k, i) => i === 0 ? (_jsx(Bar, { dataKey: k, hide: off.includes(k), fill: `var(--color-${k})`, radius: 2, isAnimationActive: false }, k)) : (_jsx(Line, { dataKey: k, hide: off.includes(k), type: "monotone", stroke: `var(--color-${k})`, strokeWidth: 1.5, dot: false, isAnimationActive: false }, k)))] })) : style === 'area' || style === 'stacked' ? (_jsxs(AreaChart, { data: points, margin: margin, children: [axes, keys.map((k) => (_jsx(Area, { dataKey: k, hide: off.includes(k), type: "monotone", stackId: style === 'stacked' ? 'a' : undefined, stroke: `var(--color-${k})`, fill: `var(--color-${k})`, fillOpacity: 0.15, strokeWidth: 1.5, isAnimationActive: false }, k)))] })) : (_jsxs(LineChart, { data: points, margin: margin, children: [axes, keys.map((k) => (_jsx(Line, { dataKey: k, hide: off.includes(k), type: "monotone", stroke: `var(--color-${k})`, strokeWidth: 1.5, dot: false, isAnimationActive: false }, k)))] })) })] }));
159
174
  }
@@ -22,6 +22,8 @@ export interface LumenViewCardProps<T> {
22
22
  state?: ReactNode;
23
23
  /** A row click (pick a version, open a drawer). */
24
24
  onDetails?: (row: T) => void;
25
+ /** The row whose details are open, marked selected (LumenView `openKey`). */
26
+ openKey?: string | null;
25
27
  /** Its place in the layout: the layout's `frame` hands these over. */
26
28
  controls?: CardControls;
27
29
  /** Its saved controls (Edit widget ▸ Controls), on a saved page's table. */
@@ -32,4 +34,4 @@ export interface LumenViewCardProps<T> {
32
34
  * header picked (`{key: 'book', value: 'Live'}`), like a code card's `publishes`. */
33
35
  publishes?: FilterToken[];
34
36
  }
35
- export declare function LumenViewCard<T extends RowData>({ id, domain: base, config: saved, rows, load, title, tools, note, state, onDetails, controls, shown: savedControls, onColumns, publishes, }: LumenViewCardProps<T>): import("react").JSX.Element;
37
+ export declare function LumenViewCard<T extends RowData>({ id, domain: base, config: saved, rows, load, title, tools, note, state, onDetails, openKey, controls, shown: savedControls, onColumns, publishes, }: LumenViewCardProps<T>): import("react").JSX.Element;
@@ -8,7 +8,7 @@ import { followedBy, followsText, keyFilterFields, useLinks, usePublishLinks, us
8
8
  import { LumenView } from './lumen-view.js';
9
9
  import { cardFilterFields } from './lumen-widget.js';
10
10
  import { filterFieldsOf } from './view-model.js';
11
- export function LumenViewCard({ id, domain: base, config: saved, rows, load, title, tools, note, state, onDetails, controls, shown: savedControls, onColumns, publishes, }) {
11
+ export function LumenViewCard({ id, domain: base, config: saved, rows, load, title, tools, note, state, onDetails, openKey, controls, shown: savedControls, onColumns, publishes, }) {
12
12
  const [config, setConfig] = useState(saved);
13
13
  // A saved layout that changes (an edit, the assistant move) is drawn at once.
14
14
  const savedKey = JSON.stringify(saved);
@@ -43,7 +43,7 @@ export function LumenViewCard({ id, domain: base, config: saved, rows, load, tit
43
43
  linked: color ? filter.tokens.length : 0,
44
44
  };
45
45
  const on = (k) => controlOn({ kind: 'table', controls: savedControls }, k);
46
- return (_jsx(LumenView, { domain: domain, config: config, rows: shown, total: rows.length, groupBy: group.groupField, pivot: group.pivot, subgroup: group.sub, load: load, state: state, onDetails: pick.onTap ?? onDetails, tapped: pick.tapped, title: title ?? config.name, inLayout: true, cardId: cardId, controls: controls, tools: tools, note: note, filter: _jsxs(_Fragment, { children: [on('filter') && (_jsx(LumenTokenizedFilter, { filter: filter, label: `Filter ${config.name}`, className: "min-w-0 flex-1", link: link || undefined })), on('group') && group.button] }), filterCount: filter.tokens.length, onClearFilter: filter.clear, link: link || undefined, shown: savedControls, onColumns: (columns) => {
46
+ return (_jsx(LumenView, { domain: domain, config: config, rows: shown, total: rows.length, groupBy: group.groupField, pivot: group.pivot, subgroup: group.sub, load: load, state: state, onDetails: pick.onTap ?? onDetails, tapped: pick.tapped, openKey: openKey, title: title ?? config.name, inLayout: true, cardId: cardId, controls: controls, tools: tools, note: note, filter: _jsxs(_Fragment, { children: [on('filter') && (_jsx(LumenTokenizedFilter, { filter: filter, label: `Filter ${config.name}`, className: "min-w-0 flex-1", link: link || undefined })), on('group') && group.button] }), filterCount: filter.tokens.length, onClearFilter: filter.clear, link: link || undefined, shown: savedControls, onColumns: (columns) => {
47
47
  setConfig((c) => ({ ...c, columns }));
48
48
  onColumns?.(columns);
49
49
  } }));
@@ -132,6 +132,10 @@ export interface LumenViewProps<T> {
132
132
  onDetails?: (row: T) => void;
133
133
  /** Rows picked by a tap (a linked table's row filter): a muted fill, no border. */
134
134
  tapped?: (row: T) => boolean;
135
+ /** The key of the row whose details are open (a docked drawer beside the table): the row reads
136
+ * as selected (an accent fill and edge bar, `aria-selected`) and scrolls into view when it
137
+ * shows. ↑ and ↓ on a focused row move to the next one, and open it while a row is open. */
138
+ openKey?: string | null;
135
139
  /** Buttons at the end of a group row's label (pin a group), given its key and rows. */
136
140
  groupActions?: (key: string, rows: T[]) => ReactNode;
137
141
  /** Its place in a LumenLayout: the layout's edit state and the card's ⋯ menu. */
@@ -184,7 +188,7 @@ export interface AddColumn {
184
188
  * and ⋯, and its own filter and Group (they narrow this table only); its body
185
189
  * the table (grouped one level, or the pivot) with the loading states.
186
190
  */
187
- export declare function LumenView<T extends RowData>({ domain, config, rows, total, paging: server, groupBy: pickedGroup, pivot: pickedPivot, subgroup, load: sourceLoad, state, title, selection, onDetails, tapped, groupActions, controls, inLayout: inScroller, cardId, tools, filter, filterCount, onClearFilter, strip, link, note, onColumns, onAddColumn, sort, onSort, shown, }: LumenViewProps<T>): import("react").JSX.Element;
191
+ export declare function LumenView<T extends RowData>({ domain, config, rows, total, paging: server, groupBy: pickedGroup, pivot: pickedPivot, subgroup, load: sourceLoad, state, title, selection, onDetails, tapped, openKey, groupActions, controls, inLayout: inScroller, cardId, tools, filter, filterCount, onClearFilter, strip, link, note, onColumns, onAddColumn, sort, onSort, shown, }: LumenViewProps<T>): import("react").JSX.Element;
188
192
  /** A field's colour for its header: its topic's source (a broker green, an app brown) where
189
193
  * the group names one, else its group's (Holding, Symbol, Account, Formulas). */
190
194
  export declare function colorOf<T>(domain: ViewDomain<T>, f: ViewField<T> | undefined): string | null;
@@ -211,6 +215,7 @@ export interface LumenTableProps<T> {
211
215
  fit?: boolean;
212
216
  onDetails?: (row: T) => void;
213
217
  tapped?: LumenViewProps<T>['tapped'];
218
+ openKey?: LumenViewProps<T>['openKey'];
214
219
  selection?: LumenViewProps<T>['selection'];
215
220
  groupActions?: LumenViewProps<T>['groupActions'];
216
221
  /** The view's default sort; a header click changes it for the visit. */
@@ -224,7 +224,7 @@ async function downloadCsv({ name, columns, rows, groupBy, sort, hidden, }) {
224
224
  * and ⋯, and its own filter and Group (they narrow this table only); its body
225
225
  * the table (grouped one level, or the pivot) with the loading states.
226
226
  */
227
- export function LumenView({ domain, config, rows, total = rows.length, paging: server, groupBy: pickedGroup = null, pivot: pickedPivot = false, subgroup, load: sourceLoad, state, title = config.name, selection, onDetails, tapped, groupActions, controls, inLayout: inScroller = false, cardId = 'view', tools, filter, filterCount, onClearFilter, strip, link, note, onColumns, onAddColumn, sort = config.sort, onSort, shown, }) {
227
+ export function LumenView({ domain, config, rows, total = rows.length, paging: server, groupBy: pickedGroup = null, pivot: pickedPivot = false, subgroup, load: sourceLoad, state, title = config.name, selection, onDetails, tapped, openKey, groupActions, controls, inLayout: inScroller = false, cardId = 'view', tools, filter, filterCount, onClearFilter, strip, link, note, onColumns, onAddColumn, sort = config.sort, onSort, shown, }) {
228
228
  // Its controls (Edit widget ▸ Controls): Pager, Columns and Download here.
229
229
  const on = (k) => controlOn({ kind: 'table', controls: shown }, k);
230
230
  const cell = useGridCell();
@@ -338,7 +338,7 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
338
338
  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 }));
339
339
  // The page footer (`pagination.footer`): under the table while there is more than one page.
340
340
  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;
341
- const table = pivot && config.pivot && !state ? (_jsx(PivotTable, { domain: domain, rows: rows, pivot: config.pivot })) : (_jsx(LumenTable, { domain: domain, columns: columns, rows: state ? [] : 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: state ? null : served, emptyState: state, onClearFilter: filtered ? onClearFilter : undefined, total: config.total !== false, add: onAddColumn || onColumns
341
+ const table = pivot && config.pivot && !state ? (_jsx(PivotTable, { domain: domain, rows: rows, pivot: config.pivot })) : (_jsx(LumenTable, { domain: domain, columns: columns, rows: state ? [] : tableRows, groupBy: groupBy, subgroup: sub, chips: chips, page: paging ? { index: page, size: pageSize } : null, inView: inLayout, fit: fit, onDetails: onDetails, tapped: tapped, openKey: openKey, selection: selection, groupActions: groupActions, sort: sort, onSort: sorted, serverSorts: server ? (server.sorts ?? []) : undefined, serverGroups: state ? null : served, emptyState: state, onClearFilter: filtered ? onClearFilter : undefined, total: config.total !== false, add: onAddColumn || onColumns
342
342
  ? { ...onAddColumn, field: onColumns && { domain, columns, onColumns } }
343
343
  : undefined }));
344
344
  return (_jsx(LumenWidget, { id: cardId, kind: "table", style: "table", title: title, phoneTitle: false, controls: controls, tools: blank ? (tools) : (_jsxs(_Fragment, { children: [tools, compare, pager, onColumns && !onAddColumn && on('columns') && (_jsx(ColumnPicker, { domain: domain, columns: columns, onColumns: onColumns })), !pivot && on('download') && (_jsx(IconButton, { icon: DownloadIcon, label: "Download CSV",
@@ -445,7 +445,7 @@ function sortingOf(sort, columns, serverSorts) {
445
445
  return [];
446
446
  return columns.some((f) => f.key === sort.key) ? [{ id: sort.key, desc: sort.desc }] : [];
447
447
  }
448
- function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null, chips = [], page = null, inView = false, fit = false, onDetails, tapped, selection, groupActions, sort, onSort, serverSorts, total = true, subgroup = null, add, serverGroups = null, emptyState, onClearFilter, }) {
448
+ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null, chips = [], page = null, inView = false, fit = false, onDetails, tapped, openKey = null, selection, groupActions, sort, onSort, serverSorts, total = true, subgroup = null, add, serverGroups = null, emptyState, onClearFilter, }) {
449
449
  const isMobile = useIsMobile();
450
450
  const [sorting, setSorting] = useState(() => sortingOf(sort, columns, serverSorts));
451
451
  // A saved sort patched while the table is open (the assistant's card move) applies at once.
@@ -734,8 +734,30 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
734
734
  const partRow = (p, name) => (_jsxs(TableRow, { className: cn('bg-muted/30 text-muted-foreground hover:bg-muted', onDetails && 'cursor-pointer'), onClick: onDetails ? () => onDetails(p) : undefined, children: [selection && _jsx(TableCell, {}), shown.map((f, i) => (_jsx(TableCell, { ...col(f, i === 0 && 'pl-12'), children: i === 0 ? (_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(CornerDownRightIcon, { className: "size-3.5 shrink-0" }), _jsx(CappedCell, { max: capOf(f, 0), children: name
735
735
  ? name(p)
736
736
  : cellOf(f, p, chips.some((c) => c.key === f.key)) })] })) : (cellOf(f, p, chips.some((c) => c.key === f.key))) }, f.key))), add && _jsx(TableCell, {})] }, `part:${keyOf(p)}`));
737
+ // ↑ and ↓ step to the next data row (past part and panel rows); while a row's details are
738
+ // open, the step opens that row too, so the docked drawer follows the focus.
739
+ const step = (from, down) => {
740
+ let n = down ? from.nextElementSibling : from.previousElementSibling;
741
+ while (n && !(n instanceof HTMLElement && n.dataset.row !== undefined))
742
+ n = down ? n.nextElementSibling : n.previousElementSibling;
743
+ if (!(n instanceof HTMLElement))
744
+ return;
745
+ n.focus();
746
+ const next = openKey === null ? undefined : sorted.find((x) => keyOf(x.original) === n.dataset.row);
747
+ if (next && onDetails)
748
+ onDetails(next.original);
749
+ };
750
+ // The open row scrolls into view when it shows: opened by keyboard, or a page that holds it.
751
+ const pageKeys = sorted.map((r) => keyOf(r.original)).join('\n');
752
+ // biome-ignore lint/correctness/useExhaustiveDependencies: scrolls when the open row or the shown rows change, not on every render.
753
+ useEffect(() => {
754
+ if (openKey === null)
755
+ return;
756
+ const el = box.current?.querySelector(`tr[data-row="${CSS.escape(openKey)}"]`);
757
+ el?.scrollIntoView({ block: 'nearest' });
758
+ }, [openKey, pageKeys, page?.index]);
737
759
  // The whole row opens the details drawer.
738
- const dataRow = (r) => (_jsxs(Fragment, { children: [_jsxs(TableRow, { "data-row": domain.rowKey(r.original), "data-state": tapped?.(r.original) ? 'selected' : undefined, className: cn('group/row', onDetails && 'cursor-pointer'), tabIndex: onDetails ? 0 : undefined, onClick: onDetails ? () => onDetails(r.original) : undefined, onKeyDown: onDetails
760
+ const dataRow = (r) => (_jsxs(Fragment, { children: [_jsxs(TableRow, { "data-row": domain.rowKey(r.original), "data-state": tapped?.(r.original) ? 'selected' : undefined, "aria-selected": openKey === null ? undefined : openKey === keyOf(r.original), className: cn('group/row', onDetails && 'cursor-pointer'), tabIndex: onDetails ? 0 : undefined, onClick: onDetails ? () => onDetails(r.original) : undefined, onKeyDown: onDetails
739
761
  ? (e) => {
740
762
  if (e.target !== e.currentTarget)
741
763
  return;
@@ -743,6 +765,10 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
743
765
  e.preventDefault();
744
766
  onDetails(r.original);
745
767
  }
768
+ else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
769
+ e.preventDefault();
770
+ step(e.currentTarget, e.key === 'ArrowDown');
771
+ }
746
772
  }
747
773
  : undefined, children: [selectCell(keyOf(r.original), keyOf(r.original)), r.getAllCells().map((c, i) => (_jsx(TableCell, { ...col(shown[i], groupBy && i === 0 && 'pl-8'), style: { background: shown[i]?.tint?.(r.original) }, children: i === 0 && nests ? (_jsxs("span", { className: "flex items-center gap-1", children: [opens(r.original) ? (chevron(keyOf(r.original), openLabel())) : (_jsx("span", { className: "-ml-1 size-6 shrink-0" })), flexRender(c.column.columnDef.cell, c.getContext())] })) : i === 0 && flat ? (flatName(r.original)) : (flexRender(c.column.columnDef.cell, c.getContext())) }, c.id))), add && _jsx(TableCell, {})] }, domain.rowKey(r.original)), openParts.has(keyOf(r.original)) &&
748
774
  partsOf(r.original).map((p) => partRow(p, subgroup?.name)), openParts.has(keyOf(r.original)) && domain.panel ? (_jsx(TableRow, { "data-slot": "row-panel", className: "bg-muted/20 hover:bg-muted/20", children: _jsx(TableCell, { colSpan: span, className: "p-0 whitespace-normal", children: _jsx("div", { className: "sticky left-0 w-[min(56rem,calc(100vw-2rem))] max-w-full px-4 py-3 md:pl-10", children: panelOf(r.original) }) }) }, `panel:${keyOf(r.original)}`)) : null] }, domain.rowKey(r.original)));
@@ -690,7 +690,7 @@ export declare function cardsAfter(cards: ViewCard[], i: number, action: CardAct
690
690
  bucket: "day" | "month" | "week" | "year";
691
691
  agg: "count" | "last" | "mean" | "sum";
692
692
  kind: "chart";
693
- style: "area" | "bars" | "combo" | "line" | "stacked";
693
+ style: "area" | "bars" | "combo" | "line" | "stacked" | "stacked_bars";
694
694
  } | {
695
695
  id: string;
696
696
  title: string | null;
package/dist/theme.css CHANGED
@@ -118,6 +118,9 @@
118
118
  --popover: #fbfcfe;
119
119
  --popover-foreground: #121826;
120
120
  --primary: #2563eb;
121
+ /* The open row's fill (LumenView `openKey`). */
122
+ --row-open: color-mix(in oklab, var(--primary) 14%, var(--card));
123
+ --row-open-hover: color-mix(in oklab, var(--primary) 20%, var(--card));
121
124
  --primary-foreground: #f5f8ff;
122
125
  --secondary: #e9edf5;
123
126
  --secondary-foreground: #121826;
@@ -225,6 +228,8 @@
225
228
  --popover: #121826;
226
229
  --popover-foreground: #e6eaf2;
227
230
  --primary: #5b8cff;
231
+ --row-open: color-mix(in oklab, var(--primary) 24%, var(--card));
232
+ --row-open-hover: color-mix(in oklab, var(--primary) 30%, var(--card));
228
233
  --primary-foreground: #0b0f17;
229
234
  --secondary: #1c2435;
230
235
  --secondary-foreground: #e6eaf2;
@@ -535,6 +540,34 @@
535
540
  }
536
541
  }
537
542
 
543
+ /* The row whose details are open (LumenView `openKey`, a docked drawer beside the table): an
544
+ accent fill and an accent bar on its leading edge, readable in light and dark. It scrolls clear
545
+ of the pinned header and the page footer when it comes into view. */
546
+ tr[aria-selected="true"] {
547
+ scroll-margin-top: calc(var(--pin-top, 0px) + var(--row-h));
548
+ scroll-margin-bottom: var(--row-h);
549
+ }
550
+ tr[aria-selected="true"] > td {
551
+ background-color: var(--row-open);
552
+ }
553
+ tr[aria-selected="true"]:hover > td {
554
+ background-color: var(--row-open-hover);
555
+ }
556
+ tr[aria-selected="true"] > td:first-child {
557
+ box-shadow: inset 3px 0 0 var(--primary);
558
+ }
559
+ /* A phone's card row is one flex box: the fill and bar go on the row itself. */
560
+ @media (max-width: 639px) {
561
+ table[data-cards] tr[aria-selected="true"] {
562
+ background-color: var(--row-open);
563
+ box-shadow: inset 3px 0 0 var(--primary);
564
+ }
565
+ table[data-cards] tr[aria-selected="true"] > td {
566
+ background-color: transparent;
567
+ box-shadow: none;
568
+ }
569
+ }
570
+
538
571
  @layer utilities {
539
572
  .bg-gap-hatch {
540
573
  background: repeating-linear-gradient(135deg, var(--gap) 0 3px, var(--gap-soft) 3px 6px);
@@ -37,8 +37,12 @@ declare function ChartTooltipContent({ active, payload, className, indicator, hi
37
37
  labelKey?: string;
38
38
  } & Omit<RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>, 'accessibilityLayer'>): React.JSX.Element | null;
39
39
  declare const ChartLegend: React.MemoExoticComponent<(outsideProps: RechartsPrimitive.LegendProps) => React.ReactPortal | null>;
40
- declare function ChartLegendContent({ className, hideIcon, payload, verticalAlign, nameKey, }: React.ComponentProps<'div'> & {
40
+ declare function ChartLegendContent({ className, hideIcon, payload, verticalAlign, nameKey, off, onSeries, }: React.ComponentProps<'div'> & {
41
41
  hideIcon?: boolean;
42
42
  nameKey?: string;
43
+ /** Series keys hidden by a tap on the legend; they stay listed, dimmed. */
44
+ off?: string[];
45
+ /** Given, each item is a button that shows or hides its series. */
46
+ onSeries?: (key: string) => void;
43
47
  } & RechartsPrimitive.DefaultLegendContentProps): React.JSX.Element | null;
44
48
  export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle, };
package/dist/ui/chart.js CHANGED
@@ -81,7 +81,7 @@ function ChartTooltipContent({ active, payload, className, indicator = 'dot', hi
81
81
  }) })] }));
82
82
  }
83
83
  const ChartLegend = RechartsPrimitive.Legend;
84
- function ChartLegendContent({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey, }) {
84
+ function ChartLegendContent({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey, off, onSeries, }) {
85
85
  const { config } = useChart();
86
86
  if (!payload?.length) {
87
87
  return null;
@@ -91,8 +91,16 @@ function ChartLegendContent({ className, hideIcon = false, payload, verticalAlig
91
91
  .map((item, index) => {
92
92
  const key = `${nameKey ?? item.dataKey ?? 'value'}`;
93
93
  const itemConfig = getPayloadConfigFromPayload(config, item, key);
94
- return (_jsxs("div", { title: typeof itemConfig?.label === 'string' ? itemConfig.label : undefined, className: cn('flex max-w-full min-w-0 items-center gap-1.5 sm:max-w-64 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground'), children: [itemConfig?.icon && !hideIcon ? (_jsx(itemConfig.icon, {})) : (_jsx("div", { className: "h-2 w-2 shrink-0 rounded-[2px]", style: {
95
- backgroundColor: item.color,
94
+ const series = String(item.dataKey ?? key);
95
+ const hidden = off?.includes(series) ?? false;
96
+ const Item = onSeries ? 'button' : 'div';
97
+ return (_jsxs(Item, { title: typeof itemConfig?.label === 'string' ? itemConfig.label : undefined, ...(onSeries && {
98
+ type: 'button',
99
+ 'aria-pressed': !hidden,
100
+ onClick: () => onSeries(series),
101
+ }), className: cn('flex max-w-full min-w-0 items-center gap-1.5 sm:max-w-64 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground', onSeries &&
102
+ 'min-h-6 cursor-pointer rounded-sm px-1 hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none', hidden && 'text-muted-foreground line-through'), children: [itemConfig?.icon && !hideIcon ? (_jsx(itemConfig.icon, {})) : (_jsx("div", { className: "h-2 w-2 shrink-0 rounded-[2px]", style: {
103
+ backgroundColor: hidden ? 'var(--muted-foreground)' : item.color,
96
104
  } })), _jsx("span", { className: "truncate", children: itemConfig?.label })] }, index));
97
105
  }) }));
98
106
  }
@@ -514,5 +514,5 @@ export declare const readsRows: (card: ViewWidget) => card is {
514
514
  bucket: "day" | "month" | "week" | "year";
515
515
  agg: "count" | "last" | "mean" | "sum";
516
516
  kind: "chart";
517
- style: "area" | "bars" | "combo" | "line" | "stacked";
517
+ style: "area" | "bars" | "combo" | "line" | "stacked" | "stacked_bars";
518
518
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.123",
3
+ "version": "0.1.125",
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.123",
52
+ "@columenlabs/lumen-ui-types": "0.1.125",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",