@columenlabs/lumen-ui 0.1.119 → 0.1.121

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
@@ -76,6 +76,7 @@ export { type McpServer, McpSettings } from './mcp-settings.js';
76
76
  export { lumenToast } from './lumen-toast.js';
77
77
  export { LumenShell, type LumenShellProps, type ShellUser, StatusItem, firstName, initials, maskAmount, useAmountsHidden, useApp, useShell, } from './shell.js';
78
78
  export { apiJson } from './api-fetch.js';
79
+ export { savedAnswers, type SavedAnswersOptions, type SavedAnswersPersister, } from './saved-answers.js';
79
80
  export { cn } from './lib/utils.js';
80
81
  export { type HealthProbe, StatusHealth } from './status-health.js';
81
82
  export { type StatusMessage, type StatusMessageTone, StatusMessages, groupStatusMessages, } from './status-messages.js';
package/dist/index.js CHANGED
@@ -83,6 +83,7 @@ export { McpSettings } from './mcp-settings.js';
83
83
  export { lumenToast } from './lumen-toast.js';
84
84
  export { LumenShell, StatusItem, firstName, initials, maskAmount, useAmountsHidden, useApp, useShell, } from './shell.js';
85
85
  export { apiJson } from './api-fetch.js';
86
+ export { savedAnswers, } from './saved-answers.js';
86
87
  export { cn } from './lib/utils.js';
87
88
  export { StatusHealth } from './status-health.js';
88
89
  export { StatusMessages, groupStatusMessages, } from './status-messages.js';
@@ -36,11 +36,14 @@ export declare function filterByTokens<T>(rows: T[], fields: FilterField<T>[], t
36
36
  /** The row's text, for `contains` tokens. */
37
37
  text?: (row: T) => string[]): T[];
38
38
  /** Keeps the tokens and text, and filters `rows` by them. */
39
- export declare function useLumenFilter<T>(rows: T[], fields: FilterField<T>[], { text: textOf, initial, }?: {
39
+ export declare function useLumenFilter<T>(rows: T[], fields: FilterField<T>[], { text: textOf, initial, partial, }?: {
40
40
  /** Free-text haystack per row (a name, an id), besides the field values. */
41
41
  text?: (row: T) => string[];
42
42
  /** Tokens applied when the view opens (a view's default filters). */
43
43
  initial?: FilterToken[];
44
+ /** `rows` are one page of a server's rows: a count over them is not the real count, so the
45
+ * menu shows none. */
46
+ partial?: boolean;
44
47
  }): {
45
48
  fields: FilterField<T>[];
46
49
  rows: T[];
@@ -55,6 +58,8 @@ export declare function useLumenFilter<T>(rows: T[], fields: FilterField<T>[], {
55
58
  setText: import("react").Dispatch<import("react").SetStateAction<string>>;
56
59
  results: T[];
57
60
  hasText: boolean;
61
+ partial: boolean;
62
+ clear: () => void;
58
63
  };
59
64
  export type LumenFilterState<T> = ReturnType<typeof useLumenFilter<T>>;
60
65
  export declare function LumenTokenizedFilter<T>({ filter, placeholder, label, className, saved, onSave, changed, link: given, }: {
@@ -114,7 +114,7 @@ function onePerField(tokens) {
114
114
  /** How many values a field lists in the menu before typing narrows it. */
115
115
  const PER_FIELD = 8;
116
116
  /** Keeps the tokens and text, and filters `rows` by them. */
117
- export function useLumenFilter(rows, fields, { text: textOf, initial = [], } = {}) {
117
+ export function useLumenFilter(rows, fields, { text: textOf, initial = [], partial = false, } = {}) {
118
118
  const [tokens, setRaw] = useState(() => onePerField(initial));
119
119
  const setTokens = useCallback((next) => setRaw(onePerField(next)), []);
120
120
  const [text, setText] = useState('');
@@ -146,7 +146,24 @@ export function useLumenFilter(rows, fields, { text: textOf, initial = [], } = {
146
146
  }, [indexed, fields, tokens, text]);
147
147
  /** Whether rows have text of their own, so the box offers "Text contains". */
148
148
  const hasText = !!textOf;
149
- return { fields, rows, indexed, tokens, setTokens, text, setText, results, hasText };
149
+ /** Takes every token and the typed text off. */
150
+ const clear = useCallback(() => {
151
+ setRaw([]);
152
+ setText('');
153
+ }, []);
154
+ return {
155
+ fields,
156
+ rows,
157
+ indexed,
158
+ tokens,
159
+ setTokens,
160
+ text,
161
+ setText,
162
+ results,
163
+ hasText,
164
+ partial,
165
+ clear,
166
+ };
150
167
  }
151
168
  /** A chip's widest (max-w-60), and the gap between chips (gap-1). */
152
169
  const CHIP_MAX = 240;
@@ -251,10 +268,12 @@ function NumberCondition({ label, current, tall, count, onSet, }) {
251
268
  } }), op === 'between' && (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-muted-foreground text-sm", children: "and" }), _jsx(Input, { type: "number", inputMode: "decimal", className: box, "aria-label": `${label} to`, value: b, onFocus: (e) => e.target.select(), onChange: (e) => {
252
269
  setB(e.target.value);
253
270
  set(op, a, e.target.value);
254
- } })] })), _jsx("span", { className: "w-16 shrink-0 text-right text-muted-foreground text-xs tabular-nums", children: current && ok ? `${count(op, value(op, a, b))} rows` : '' })] })] }));
271
+ } })] })), _jsx("span", { className: "w-16 shrink-0 text-right text-muted-foreground text-xs tabular-nums", children: current && ok && count ? `${count(op, value(op, a, b))} rows` : '' })] })] }));
255
272
  }
256
273
  export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label = 'Filter', className, saved, onSave, changed, link: given, }) {
257
- const { fields, indexed, tokens, setTokens, text, setText, hasText } = filter;
274
+ const { fields, indexed, tokens, setTokens, text, setText, hasText, partial } = filter;
275
+ // Over one page of a server's rows a count is not the real one: show none.
276
+ const tally = (n) => (partial ? '' : n);
258
277
  // A box its card passes no link takes the card's own (every card carries a square).
259
278
  const link = useAutoFilterLink(tokens, given);
260
279
  // Phone: the box is one tappable row and the menu is a bottom sheet.
@@ -493,14 +512,14 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
493
512
  return (_jsxs(CommandItem, { value: `${key}:${value}`, onSelect: () => toggle(key, value), className: tall, children: [_jsx(Checkbox, { checked: on, tabIndex: -1, "aria-hidden": true, className: "pointer-events-none" }), prefix && _jsx("span", { className: "shrink-0 text-muted-foreground", children: prefix }), _jsx("span", { className: "min-w-0 truncate", children: value }), _jsx(Button, { variant: "ghost", size: "xs", className: "-my-1 ml-auto h-6 px-1.5 font-normal text-muted-foreground opacity-0 group-data-selected/command-item:opacity-100 max-md:opacity-100", "aria-label": `Only ${value}`, onMouseDown: (e) => e.preventDefault(), onClick: (e) => {
494
513
  e.stopPropagation();
495
514
  only(key, value);
496
- }, children: "Only" }), _jsx(CommandShortcut, { className: cn('ml-0 w-8 text-right', shortcut), children: count })] }, `${key}:${value}`));
515
+ }, children: "Only" }), _jsx(CommandShortcut, { className: cn('ml-0 w-8 text-right', shortcut), children: tally(count) })] }, `${key}:${value}`));
497
516
  };
498
517
  // A field's "No <field>" row, pinned above its values: the rows with no value (Exclude: any).
499
518
  const emptyRow = (f, include) => {
500
519
  const n = empties.get(f.key) ?? 0;
501
520
  if (!n && !hasEmpty(f.key))
502
521
  return null;
503
- return (_jsxs(CommandItem, { value: `${f.key}:${EMPTY_VALUE}`, onSelect: () => toggleEmpty(f.key, include ? false : undefined), className: tall, children: [_jsx(Checkbox, { checked: hasEmpty(f.key), tabIndex: -1, "aria-hidden": true, className: "pointer-events-none" }), _jsx("span", { className: "min-w-0 truncate text-muted-foreground italic", children: noneOf(f.label) }), _jsx(CommandShortcut, { className: cn('ml-auto w-8 text-right', shortcut), children: n })] }, `${f.key}:${EMPTY_VALUE}`));
522
+ return (_jsxs(CommandItem, { value: `${f.key}:${EMPTY_VALUE}`, onSelect: () => toggleEmpty(f.key, include ? false : undefined), className: tall, children: [_jsx(Checkbox, { checked: hasEmpty(f.key), tabIndex: -1, "aria-hidden": true, className: "pointer-events-none" }), _jsx("span", { className: "min-w-0 truncate text-muted-foreground italic", children: noneOf(f.label) }), _jsx(CommandShortcut, { className: cn('ml-auto w-8 text-right', shortcut), children: tally(n) })] }, `${f.key}:${EMPTY_VALUE}`));
504
523
  };
505
524
  const summary = (c) => {
506
525
  const w = chipWords(c, fieldOf(c.key), 'full');
@@ -512,7 +531,7 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
512
531
  setField(null);
513
532
  setText('');
514
533
  focus();
515
- }, children: _jsx(ChevronLeftIcon, {}) }), _jsx(activeField.icon, { className: "size-4 text-muted-foreground" }), _jsx("span", { className: "font-medium", children: activeField.label }), !activeField.number && (_jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, className: "ml-auto", value: notFor(activeField.key) ? 'exclude' : 'include', onValueChange: (v) => v && setNot(activeField.key, v === 'exclude'), "aria-label": `${activeField.label}: include or exclude`, children: [_jsx(ToggleGroupItem, { value: "include", className: "h-7 px-2 text-xs font-normal", children: "Include" }), _jsx(ToggleGroupItem, { value: "exclude", className: "h-7 px-2 text-xs font-normal", children: "Exclude" })] }))] }), activeField.number && (_jsx(NumberCondition, { label: activeField.label, current: tokens.find((t) => t.key === activeField.key), tall: mobile, count: (op, value) => numberCount(activeField.key, op, value), onSet: (op, value) => setCondition(activeField.key, op, value) }, activeField.key))] }), active === CONTAINS_KEY ? (_jsxs(CommandGroup, { className: "px-1 py-0.5", children: [containsWord && !picked(CONTAINS_KEY, containsWord) && (_jsxs(CommandItem, { value: `${CONTAINS_KEY}:${containsWord}`, onSelect: () => toggle(CONTAINS_KEY, containsWord), className: tall, children: [_jsx(TextSearchIcon, { className: "text-muted-foreground" }), _jsxs("span", { className: "min-w-0 truncate", children: ["\u201C", containsWord, "\u201D"] }), _jsx(CommandShortcut, { className: shortcut, children: wordOf(containsWord, notFor(CONTAINS_KEY)) })] })), tokens
534
+ }, children: _jsx(ChevronLeftIcon, {}) }), _jsx(activeField.icon, { className: "size-4 text-muted-foreground" }), _jsx("span", { className: "font-medium", children: activeField.label }), !activeField.number && (_jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, className: "ml-auto", value: notFor(activeField.key) ? 'exclude' : 'include', onValueChange: (v) => v && setNot(activeField.key, v === 'exclude'), "aria-label": `${activeField.label}: include or exclude`, children: [_jsx(ToggleGroupItem, { value: "include", className: "h-7 px-2 text-xs font-normal", children: "Include" }), _jsx(ToggleGroupItem, { value: "exclude", className: "h-7 px-2 text-xs font-normal", children: "Exclude" })] }))] }), activeField.number && (_jsx(NumberCondition, { label: activeField.label, current: tokens.find((t) => t.key === activeField.key), tall: mobile, count: partial ? undefined : (op, value) => numberCount(activeField.key, op, value), onSet: (op, value) => setCondition(activeField.key, op, value) }, activeField.key))] }), active === CONTAINS_KEY ? (_jsxs(CommandGroup, { className: "px-1 py-0.5", children: [containsWord && !picked(CONTAINS_KEY, containsWord) && (_jsxs(CommandItem, { value: `${CONTAINS_KEY}:${containsWord}`, onSelect: () => toggle(CONTAINS_KEY, containsWord), className: tall, children: [_jsx(TextSearchIcon, { className: "text-muted-foreground" }), _jsxs("span", { className: "min-w-0 truncate", children: ["\u201C", containsWord, "\u201D"] }), _jsx(CommandShortcut, { className: shortcut, children: tally(wordOf(containsWord, notFor(CONTAINS_KEY))) })] })), tokens
516
535
  .filter((t) => t.key === CONTAINS_KEY)
517
536
  .map((t) => valueRow(CONTAINS_KEY, t.value, wordOf(t.value, !!t.not)))] })) : (!activeField.number && (_jsxs(CommandGroup, { className: "px-1 py-0.5", children: [(!needle || noneOf(activeField.label).toLowerCase().includes(needle)) &&
518
537
  emptyRow(activeField), valuesOf(activeField.key, needle, 200).map(([v, n]) => valueRow(activeField.key, v, n))] }))), _jsx(CommandEmpty, { children: "No matching values." })] })) : q ? (_jsxs(_Fragment, { children: [_jsx(CommandEmpty, { children: "No matching values." }), word && (_jsx(CommandGroup, { className: "px-1 py-0.5", children: _jsxs(CommandItem, { value: `${CONTAINS_KEY}:${word}`, onSelect: () => {
@@ -521,7 +540,7 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
521
540
  token(CONTAINS_KEY, word, chipOf(CONTAINS_KEY)?.not ?? negate),
522
541
  ]);
523
542
  setText('');
524
- }, className: tall, children: [_jsx(TextSearchIcon, { className: "text-muted-foreground" }), _jsxs("span", { className: "min-w-0 truncate", children: [_jsxs("span", { className: "text-muted-foreground", children: ["Text ", negate ? 'not contains' : 'contains', ' '] }), "\u201C", word, "\u201D"] }), _jsx(CommandShortcut, { className: shortcut, children: wordOf(word, negate) })] }) })), fields.some((f) => f.label.toLowerCase().startsWith(q) || f.key.startsWith(q)) && (_jsx(CommandGroup, { heading: "Fields", className: "px-1 py-0.5", children: fields
543
+ }, className: tall, children: [_jsx(TextSearchIcon, { className: "text-muted-foreground" }), _jsxs("span", { className: "min-w-0 truncate", children: [_jsxs("span", { className: "text-muted-foreground", children: ["Text ", negate ? 'not contains' : 'contains', ' '] }), "\u201C", word, "\u201D"] }), _jsx(CommandShortcut, { className: shortcut, children: tally(wordOf(word, negate)) })] }) })), fields.some((f) => f.label.toLowerCase().startsWith(q) || f.key.startsWith(q)) && (_jsx(CommandGroup, { heading: "Fields", className: "px-1 py-0.5", children: fields
525
544
  .filter((f) => f.label.toLowerCase().startsWith(q) || f.key.startsWith(q))
526
545
  .map((f) => (_jsxs(CommandItem, { value: `field:${f.key}`, onSelect: () => openField(f.key), className: tall, children: [_jsx(f.icon, { className: "text-muted-foreground" }), f.label, _jsx(ChevronRightIcon, { className: "ml-auto text-muted-foreground" })] }, f.key))) })), (q.startsWith('no') || (q.length > 1 && 'empty'.startsWith(q))) &&
527
546
  fields.some((f) => !f.number &&
@@ -543,7 +562,7 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
543
562
  }, children: _jsx(XIcon, {}) })] }, c.key));
544
563
  }) })), _jsx(CommandGroup, { heading: chips.length ? 'Add' : undefined, className: "px-1 py-0.5", children: fields
545
564
  .filter((f) => !chipOf(f.key))
546
- .map((f) => (_jsxs(CommandItem, { value: `field:${f.key}`, onSelect: () => openField(f.key), className: tall, children: [_jsx(f.icon, { className: "text-muted-foreground" }), f.label, _jsx(CommandShortcut, { className: shortcut, children: f.number ? '' : (counts.get(f.key)?.size ?? 0) })] }, f.key))) })] }));
565
+ .map((f) => (_jsxs(CommandItem, { value: `field:${f.key}`, onSelect: () => openField(f.key), className: tall, children: [_jsx(f.icon, { className: "text-muted-foreground" }), f.label, _jsx(CommandShortcut, { className: shortcut, children: f.number ? '' : tally(counts.get(f.key)?.size ?? 0) })] }, f.key))) })] }));
547
566
  // A field's last row: the card's colour (every chip of a coloured card is shared) and Remove.
548
567
  const footer = activeChip && (_jsxs("div", { className: "flex items-center gap-1 border-t px-2 py-1.5", children: [link && (_jsxs("span", { className: "flex items-center gap-1 text-muted-foreground text-xs", children: [_jsx(LinkPicker, { color: link.color, label: activeField?.label ?? activeChip.key, onPick: link.onPick }), link.color
549
568
  ? `Shared with ${LINK_NAMES[link.color].toLowerCase()} cards`
@@ -228,6 +228,8 @@ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead,
228
228
  const filter = useLumenFilter(rows, filterFields, {
229
229
  text: domain.text,
230
230
  initial: config.filters.default,
231
+ // A server's page is not every row: the menu's counts would be over that page only.
232
+ partial: !!paging && (paging.more || paging.page > 0 || (paging.total ?? 0) > rows.length),
231
233
  });
232
234
  // A view patched while open (the assistant's card move, a save from another tab) applies its new saved
233
235
  // filters at once; the person's own unsaved tokens stay while the saved ones
@@ -549,7 +551,7 @@ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead,
549
551
  return {
550
552
  ...place,
551
553
  wide: isTableCard(c) && c.w === GRID_COLS && c.h === undefined,
552
- 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, 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
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
553
555
  ? (columns) => onConfigChange({ ...config, columns })
554
556
  : 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
555
557
  ? cardOf(c)
@@ -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, 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, 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
  } }));
@@ -116,7 +116,9 @@ export interface LumenViewProps<T> {
116
116
  /** The rows' loading state (`useLumenLoad`): the view shows its skeleton, progress bar, ↻, a
117
117
  * failure with Retry and, with Observability details on, its load time. */
118
118
  load?: LumenLoad;
119
- /** Shown in place of the table instead of the rows, e.g. an empty state ("No accounts yet"). */
119
+ /** Shown in place of the table instead of the rows, e.g. an empty state ("No accounts yet").
120
+ * While a filter is on it shows in the table's body instead, under the headers, so the
121
+ * filter stays in reach. */
120
122
  state?: ReactNode;
121
123
  /** The card's title; left out: the view's name. */
122
124
  title?: string;
@@ -144,6 +146,8 @@ export interface LumenViewProps<T> {
144
146
  * not the page's), and how many tokens it holds. */
145
147
  filter?: ReactNode;
146
148
  filterCount?: number;
149
+ /** Takes every filter off: an empty table under a filter offers Clear filters. */
150
+ onClearFilter?: () => void;
147
151
  /** A line under the filter: the card's Link offer. */
148
152
  strip?: ReactNode;
149
153
  /** Its link colour on the layout. */
@@ -180,7 +184,7 @@ export interface AddColumn {
180
184
  * and ⋯, and its own filter and Group (they narrow this table only); its body
181
185
  * the table (grouped one level, or the pivot) with the loading states.
182
186
  */
183
- 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, strip, link, note, onColumns, onAddColumn, sort, onSort, shown, }: LumenViewProps<T>): import("react").JSX.Element;
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;
184
188
  /** A field's colour for its header: its topic's source (a broker green, an app brown) where
185
189
  * the group names one, else its group's (Holding, Symbol, Account, Formulas). */
186
190
  export declare function colorOf<T>(domain: ViewDomain<T>, f: ViewField<T> | undefined): string | null;
@@ -231,4 +235,8 @@ export interface LumenTableProps<T> {
231
235
  /** The groups come from a server (`ServerPaging.groups`): their order, counts and open state,
232
236
  * and each open group's rows. */
233
237
  serverGroups?: ServerGroups | null;
238
+ /** What the body says when no row shows (an app's empty state); left out: "No rows match." */
239
+ emptyState?: ReactNode;
240
+ /** Takes every filter off: the empty body offers Clear filters. */
241
+ onClearFilter?: () => void;
234
242
  }
@@ -8,7 +8,7 @@ import { bytesParts, controlOn, isCountAgg, isDollarFormat, viewPaginationSchema
8
8
  // details drawer; LumenLayout (lumen-layout.tsx) is the page around it. Rows are the ones the app
9
9
  // already loaded; everything here runs on the client.
10
10
  import { createPaginatedRowModel, createSortedRowModel, flexRender, rowPaginationFeature, rowSortingFeature, sortFns, tableFeatures, useTable, } from '@tanstack/react-table';
11
- import { ArrowDownIcon, ArrowDownWideNarrowIcon, ArrowUpIcon, ArrowUpNarrowWideIcon, ChartCandlestick, ChartSpline, ChevronRightIcon, ChevronsDownIcon, ChevronsDownUpIcon, ChevronsUpDownIcon, Columns3CogIcon, CornerDownRightIcon, DownloadIcon, ListCollapseIcon, PlusIcon, SigmaIcon, UserPenIcon, } from 'lucide-react';
11
+ import { ArrowDownIcon, ArrowDownWideNarrowIcon, ArrowUpIcon, ArrowUpNarrowWideIcon, ChartCandlestick, ChartSpline, ChevronRightIcon, ChevronsDownIcon, ChevronsDownUpIcon, ChevronsUpDownIcon, Columns3CogIcon, CornerDownRightIcon, DownloadIcon, FilterXIcon, ListCollapseIcon, PlusIcon, SigmaIcon, UserPenIcon, } from 'lucide-react';
12
12
  import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
13
13
  import { ColorChip, ColorDot } from './color-dot.js';
14
14
  import { DrawerSection } from './drawer-section.js';
@@ -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, 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, 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();
@@ -258,6 +258,10 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
258
258
  const [pageIndex, setPageIndex] = useState(0);
259
259
  useEffect(() => setPageSize(pagination.size), [pagination.size]);
260
260
  const sub = subgroupOf(domain, subgroup === undefined ? config.subgroup : subgroup);
261
+ // An empty state replaces the table, its filter and tools, unless a filter is on: then it sits
262
+ // in the table's body, under the headers, with the filter still there to change or clear.
263
+ const filtered = !!filterCount;
264
+ const blank = filtered ? null : state;
261
265
  // Folded lines and their rows stay on one page, so a fold shows every row like a grouping.
262
266
  const paging = !server && pagination.on && on('pager') && !state && !groupBy && !pivot && !sub?.under;
263
267
  // A fit table wraps its text to the width there is; the rest scroll sideways.
@@ -334,10 +338,10 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
334
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 }));
335
339
  // The page footer (`pagination.footer`): under the table while there is more than one page.
336
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;
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
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
338
342
  ? { ...onAddColumn, field: onColumns && { domain, columns, onColumns } }
339
343
  : undefined }));
340
- return (_jsx(LumenWidget, { id: cardId, kind: "table", style: "table", title: title, phoneTitle: false, controls: controls, tools: state ? (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",
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",
341
345
  // A phone's card header has room for the pager, filter, refresh and menu only.
342
346
  className: "max-md:hidden", onClick: () => downloadCsv({
343
347
  name: title,
@@ -346,7 +350,7 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
346
350
  groupBy,
347
351
  sort: config.sort,
348
352
  hidden: amountsHidden,
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 ??
353
+ }) }))] })), filter: blank ? undefined : filter, filterCount: filterCount, strip: blank ? 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: blank ??
350
354
  (load ? (_jsx(LumenStates, { load: load, skeleton: _jsx(LumenSkeleton, { rows: 10 }), children: table })) : (table)) }));
351
355
  }
352
356
  /**
@@ -441,7 +445,7 @@ function sortingOf(sort, columns, serverSorts) {
441
445
  return [];
442
446
  return columns.some((f) => f.key === sort.key) ? [{ id: sort.key, desc: sort.desc }] : [];
443
447
  }
444
- 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, }) {
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, }) {
445
449
  const isMobile = useIsMobile();
446
450
  const [sorting, setSorting] = useState(() => sortingOf(sort, columns, serverSorts));
447
451
  // A saved sort patched while the table is open (the assistant's card move) applies at once.
@@ -847,7 +851,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
847
851
  }, children: [_jsx(ListCollapseIcon, {}), " Close every ", subgroup?.label.toLowerCase()] }), _jsxs(DropdownMenuItem, { onSelect: () => setAll(false), children: [_jsx(ChevronsDownUpIcon, {}), " Collapse all"] })] })] })), i === 0 &&
848
852
  groups &&
849
853
  !(folds && !serverGroups) &&
850
- (!serverGroups || serverGroups.onToggleAll) && (_jsx(IconButton, { icon: anyClosed ? ChevronsUpDownIcon : ChevronsDownUpIcon, size: "icon-xs", className: "-ml-2 mr-1 align-middle", label: anyClosed ? 'Expand all groups' : 'Collapse all groups', onClick: () => setAll(anyClosed) })), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "xs", className: cn('font-medium', !(i === 0 && groups) && '-mx-2'), style: { color: colorOf(domain, shown[i]) ?? undefined }, onClick: h.column.getCanSort() ? h.column.getToggleSortingHandler() : undefined, children: [shown[i] ? _jsx(FreshnessMark, { domain: domain, field: shown[i] }) : null, flexRender(h.column.columnDef.header, h.getContext()), _jsx(SortIcon, { dir: h.column.getIsSorted() })] }) }), shown[i] && (domain.groups?.length || freshnessOf(domain, shown[i])) ? (_jsxs(TooltipContent, { children: [domain.groups?.length ? _jsx("div", { children: pathOf(domain, shown[i]) }) : null, freshnessLine(domain, shown[i]) ? (_jsx("div", { children: freshnessLine(domain, shown[i]) })) : null] })) : null] })] }, h.id))), add && (_jsx(TableHead, { className: cn('w-8 pl-1', PINNED_HEAD), children: _jsx(AddColumnMenu, { add: add, fieldOpen: fieldOpen, onFieldOpen: setFieldOpen }) }))] }, hg.id))) }), _jsxs(TableBody, { children: [!rows.length && !serverGroups?.list?.length && (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: span, className: "py-6 text-center text-muted-foreground", children: "No rows match." }) })), groups && groupBy
854
+ (!serverGroups || serverGroups.onToggleAll) && (_jsx(IconButton, { icon: anyClosed ? ChevronsUpDownIcon : ChevronsDownUpIcon, size: "icon-xs", className: "-ml-2 mr-1 align-middle", label: anyClosed ? 'Expand all groups' : 'Collapse all groups', onClick: () => setAll(anyClosed) })), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "xs", className: cn('font-medium', !(i === 0 && groups) && '-mx-2'), style: { color: colorOf(domain, shown[i]) ?? undefined }, onClick: h.column.getCanSort() ? h.column.getToggleSortingHandler() : undefined, children: [shown[i] ? _jsx(FreshnessMark, { domain: domain, field: shown[i] }) : null, flexRender(h.column.columnDef.header, h.getContext()), _jsx(SortIcon, { dir: h.column.getIsSorted() })] }) }), shown[i] && (domain.groups?.length || freshnessOf(domain, shown[i])) ? (_jsxs(TooltipContent, { children: [domain.groups?.length ? _jsx("div", { children: pathOf(domain, shown[i]) }) : null, freshnessLine(domain, shown[i]) ? (_jsx("div", { children: freshnessLine(domain, shown[i]) })) : null] })) : null] })] }, h.id))), add && (_jsx(TableHead, { className: cn('w-8 pl-1', PINNED_HEAD), children: _jsx(AddColumnMenu, { add: add, fieldOpen: fieldOpen, onFieldOpen: setFieldOpen }) }))] }, hg.id))) }), _jsxs(TableBody, { children: [!rows.length && !serverGroups?.list?.length && (_jsx(TableRow, { "data-slot": "table-empty", className: "hover:bg-transparent", children: _jsx(TableCell, { colSpan: span, className: "py-6 text-center whitespace-normal text-muted-foreground", children: _jsxs("div", { className: "flex flex-col items-center gap-3", children: [emptyState ?? 'No rows match.', onClearFilter && (_jsxs(Button, { variant: "outline", size: "sm", onClick: onClearFilter, children: [_jsx(FilterXIcon, {}), "Clear filters"] }))] }) }) })), groups && groupBy
851
855
  ? groups.map(([key, members], gi) => {
852
856
  const open = isOpen(key, gi);
853
857
  return (_jsxs(Fragment, { children: [_jsxs(TableRow, { className: ROLLUP, "data-group": key, "data-rollup": "", children: [selection && (_jsx(TableCell, { "data-card": "select", className: cn('w-8 pr-0', PINNED_GROUP), children: _jsx(Checkbox, { checked: members.every((m) => selection.keys.has(keyOf(m.original)))
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Saved answers: a react-query persister that keeps an app's answers in IndexedDB, so a page
3
+ * paints at once from the last visit. Pass it to `PersistQueryClientProvider` as `persister`.
4
+ *
5
+ * Nothing waits on the device for good: a read that has not answered within `restoreMs` (another
6
+ * tab holding a write, a broken store) gives up and the app asks the server, and an aborted or
7
+ * blocked store fails instead of hanging. The provider shows nothing until the restore settles,
8
+ * so a hung read would otherwise leave the app blank. Writes go out at most once a second, as one
9
+ * record kept as an object (no JSON round trip).
10
+ */
11
+ /** The shape `@tanstack/react-query-persist-client` expects; kept structural so the kit needs no extra package. */
12
+ export interface SavedAnswersPersister<C = unknown> {
13
+ persistClient: (client: C) => void;
14
+ restoreClient: () => Promise<C | undefined>;
15
+ removeClient: () => Promise<void> | void;
16
+ }
17
+ export interface SavedAnswersOptions {
18
+ /** The IndexedDB database name; one per app. */
19
+ name: string;
20
+ /** How long the first paint waits for the saved answers before asking the server (ms). Default 1500. */
21
+ restoreMs?: number;
22
+ /** How long writes wait to batch (ms). Default 1000. */
23
+ writeMs?: number;
24
+ }
25
+ export declare function savedAnswers<C = unknown>({ name, restoreMs, writeMs, }: SavedAnswersOptions): SavedAnswersPersister<C>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Saved answers: a react-query persister that keeps an app's answers in IndexedDB, so a page
3
+ * paints at once from the last visit. Pass it to `PersistQueryClientProvider` as `persister`.
4
+ *
5
+ * Nothing waits on the device for good: a read that has not answered within `restoreMs` (another
6
+ * tab holding a write, a broken store) gives up and the app asks the server, and an aborted or
7
+ * blocked store fails instead of hanging. The provider shows nothing until the restore settles,
8
+ * so a hung read would otherwise leave the app blank. Writes go out at most once a second, as one
9
+ * record kept as an object (no JSON round trip).
10
+ */
11
+ const KEY = 'queries';
12
+ export function savedAnswers({ name, restoreMs = 1500, writeMs = 1000, }) {
13
+ // No IndexedDB (a test runner, a locked-down browser): nothing is saved, every load asks the server.
14
+ if (typeof indexedDB === 'undefined')
15
+ return {
16
+ persistClient: () => { },
17
+ restoreClient: async () => undefined,
18
+ removeClient: () => { },
19
+ };
20
+ let db;
21
+ const open = () => {
22
+ if (!db)
23
+ db = new Promise((resolve, reject) => {
24
+ const req = indexedDB.open(name, 1);
25
+ req.onupgradeneeded = () => req.result.createObjectStore('cache');
26
+ req.onsuccess = () => resolve(req.result);
27
+ req.onerror = () => reject(req.error);
28
+ req.onblocked = () => reject(new Error(`${name}: saved answers blocked`));
29
+ }).catch((e) => {
30
+ // A failed open is tried again on the next read or write.
31
+ db = undefined;
32
+ throw e;
33
+ });
34
+ return db;
35
+ };
36
+ const run = (mode, op) => open().then((d) => new Promise((resolve, reject) => {
37
+ const tx = d.transaction('cache', mode);
38
+ const req = op(tx.objectStore('cache'));
39
+ tx.oncomplete = () => resolve(req.result);
40
+ tx.onerror = () => reject(tx.error);
41
+ // An aborted transaction (storage full, the browser gave up) fires only abort, never error.
42
+ tx.onabort = () => reject(tx.error ?? new Error(`${name}: saved answers aborted`));
43
+ }));
44
+ let next;
45
+ let timer;
46
+ return {
47
+ persistClient: (client) => {
48
+ next = client;
49
+ timer ??= setTimeout(() => {
50
+ timer = undefined;
51
+ void run('readwrite', (s) => s.put(next, KEY)).catch(() => { });
52
+ }, writeMs);
53
+ },
54
+ restoreClient: () => Promise.race([
55
+ run('readonly', (s) => s.get(KEY)).catch(() => undefined),
56
+ new Promise((resolve) => setTimeout(() => resolve(undefined), restoreMs)),
57
+ ]),
58
+ removeClient: () => run('readwrite', (s) => s.delete(KEY)).then(() => { }, () => { }),
59
+ };
60
+ }
package/dist/theme.css CHANGED
@@ -479,9 +479,10 @@
479
479
  table[data-cards] td[colspan] {
480
480
  flex: 1 1 100%;
481
481
  }
482
- /* A row's panel (`row-panel`) is a box of its own under the row, not a row: it keeps its lines. */
483
- table[data-cards] tr[data-slot="row-panel"],
484
- table[data-cards] tr[data-slot="row-panel"] > td {
482
+ /* A row's panel (`row-panel`) and the empty table's message (`table-empty`) are boxes of their
483
+ own, not rows: they keep their lines. */
484
+ table[data-cards] tr:is([data-slot="row-panel"], [data-slot="table-empty"]),
485
+ table[data-cards] tr:is([data-slot="row-panel"], [data-slot="table-empty"]) > td {
485
486
  white-space: normal;
486
487
  }
487
488
  table[data-cards] td:not([data-card]):not([colspan]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.119",
3
+ "version": "0.1.121",
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.119",
52
+ "@columenlabs/lumen-ui-types": "0.1.121",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",