@columenlabs/lumen-ui 0.1.97 → 0.1.99

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.
@@ -0,0 +1,3 @@
1
+ export declare function EmojiTab({ onPick }: {
2
+ onPick: (emoji: string) => void;
3
+ }): import("react").JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The icon picker's Emoji tab, loaded the first time it opens (the emoji set and its search).
3
+ import { EmojiPicker, EmojiPickerContent, EmojiPickerSearch } from './ui/emoji-picker.js';
4
+ export function EmojiTab({ onPick }) {
5
+ return (_jsxs(EmojiPicker, { className: "h-80 w-full", columns: 9, onEmojiSelect: ({ emoji }) => onPick(emoji), children: [_jsx(EmojiPickerSearch, { placeholder: "Search emoji" }), _jsx(EmojiPickerContent, {})] }));
6
+ }
@@ -2,13 +2,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { RotateCcwIcon, SearchIcon, ShapesIcon, SmileIcon } from 'lucide-react';
3
3
  import { useState } from 'react';
4
4
  import { IconButton } from './icon-button.js';
5
+ import { lazyPart } from './lazy-part.js';
5
6
  import { Button } from './ui/button.js';
6
- import { EmojiPicker, EmojiPickerContent, EmojiPickerSearch } from './ui/emoji-picker.js';
7
7
  import { InputGroup, InputGroupAddon, InputGroupInput } from './ui/input-group.js';
8
8
  import { Popover, PopoverContent, PopoverTrigger } from './ui/popover.js';
9
9
  import { Separator } from './ui/separator.js';
10
10
  import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
11
- import { VIEW_ICONS, iconOf, lucideIconNames } from './view-icons.js';
11
+ import { VIEW_ICONS, iconOf, useLucideIconNames } from './view-icons.js';
12
+ const EmojiTab = lazyPart(() => import('./emoji-tab.js').then((m) => m.EmojiTab), _jsx("div", { className: "h-80 w-full" }));
12
13
  /** Icon matches shown at once; search narrows the rest of lucide's set. */
13
14
  const ICON_MATCHES = 120;
14
15
  export function IconPicker({ value, onChange, fallback, label, resetLabel = 'Use the default icon', variant, id, }) {
@@ -16,6 +17,8 @@ export function IconPicker({ value, onChange, fallback, label, resetLabel = 'Use
16
17
  const [kind, setKind] = useState('icons');
17
18
  const [q, setQ] = useState('');
18
19
  const Icon = iconOf(value, fallback);
20
+ // The full list loads when the picker first opens.
21
+ const lucideIconNames = useLucideIconNames(open);
19
22
  const pick = (icon) => {
20
23
  onChange(icon);
21
24
  setOpen(false);
@@ -29,8 +32,10 @@ export function IconPicker({ value, onChange, fallback, label, resetLabel = 'Use
29
32
  setOpen(o);
30
33
  if (o)
31
34
  setQ('');
32
- }, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx(IconButton, { id: id, icon: Icon, label: label, variant: variant }) }), _jsxs(PopoverContent, { align: "start", collisionPadding: 8, className: "flex w-80 max-w-[calc(100vw-1rem)] flex-col gap-0 p-0", children: [_jsx("div", { className: "p-2", children: _jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, className: "w-full", "aria-label": "Icons or emoji", value: kind, onValueChange: (v) => v && setKind(v), children: [_jsxs(ToggleGroupItem, { value: "icons", className: "flex-1", children: [_jsx(ShapesIcon, {}), "Icons"] }), _jsxs(ToggleGroupItem, { value: "emoji", className: "flex-1", children: [_jsx(SmileIcon, {}), "Emoji"] })] }) }), _jsx(Separator, {}), kind === 'icons' ? (_jsxs("div", { className: "flex h-80 flex-col", children: [_jsx("div", { className: "border-b p-2", children: _jsxs(InputGroup, { children: [_jsx(InputGroupAddon, { children: _jsx(SearchIcon, {}) }), _jsx(InputGroupInput, { "aria-label": "Search icons", placeholder: `Search ${lucideIconNames.length.toLocaleString()} icons`, value: q, onChange: (e) => setQ(e.target.value) })] }) }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-2", children: names.length ? (_jsx("div", { className: "grid grid-cols-8 gap-1", children: names.map((n) => {
35
+ }, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx(IconButton, { id: id, icon: Icon, label: label, variant: variant }) }), _jsxs(PopoverContent, { align: "start", collisionPadding: 8, className: "flex w-80 max-w-[calc(100vw-1rem)] flex-col gap-0 p-0", children: [_jsx("div", { className: "p-2", children: _jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, className: "w-full", "aria-label": "Icons or emoji", value: kind, onValueChange: (v) => v && setKind(v), children: [_jsxs(ToggleGroupItem, { value: "icons", className: "flex-1", children: [_jsx(ShapesIcon, {}), "Icons"] }), _jsxs(ToggleGroupItem, { value: "emoji", className: "flex-1", children: [_jsx(SmileIcon, {}), "Emoji"] })] }) }), _jsx(Separator, {}), kind === 'icons' ? (_jsxs("div", { className: "flex h-80 flex-col", children: [_jsx("div", { className: "border-b p-2", children: _jsxs(InputGroup, { children: [_jsx(InputGroupAddon, { children: _jsx(SearchIcon, {}) }), _jsx(InputGroupInput, { "aria-label": "Search icons", placeholder: lucideIconNames.length
36
+ ? `Search ${lucideIconNames.length.toLocaleString()} icons`
37
+ : 'Search icons', value: q, onChange: (e) => setQ(e.target.value) })] }) }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-2", children: names.length ? (_jsx("div", { className: "grid grid-cols-8 gap-1", children: names.map((n) => {
33
38
  const I = iconOf(n, fallback);
34
39
  return (_jsx(Button, { variant: value === n ? 'secondary' : 'ghost', size: "icon-sm", title: n, "aria-label": `Icon ${n}`, "aria-pressed": value === n, onClick: () => pick(n), children: _jsx(I, {}) }, n));
35
- }) })) : (_jsx("p", { className: "py-8 text-center text-sm text-muted-foreground", children: "No icon found." })) })] })) : (_jsxs(EmojiPicker, { className: "h-80 w-full", columns: 9, onEmojiSelect: ({ emoji }) => pick(emoji), children: [_jsx(EmojiPickerSearch, { placeholder: "Search emoji" }), _jsx(EmojiPickerContent, {})] })), _jsx(Separator, {}), _jsx(IconButton, { icon: RotateCcwIcon, label: resetLabel, className: "m-1", disabled: !value, onClick: () => pick(null) })] })] }));
40
+ }) })) : (_jsx("p", { className: "py-8 text-center text-sm text-muted-foreground", children: "No icon found." })) })] })) : (_jsx(EmojiTab, { onPick: pick })), _jsx(Separator, {}), _jsx(IconButton, { icon: RotateCcwIcon, label: resetLabel, className: "m-1", disabled: !value, onClick: () => pick(null) })] })] }));
36
41
  }
package/dist/index.d.ts CHANGED
@@ -55,7 +55,7 @@ export { LumenChatgptSignin, type LumenChatgptSigninProps } from './chatgpt-sign
55
55
  export { LumenExpandList, LumenExpandRow } from './expand-list.js';
56
56
  export { type Fact, FactList, type FactListProps } from './fact-list.js';
57
57
  export { LumenPager } from './pager.js';
58
- export { createDeskQueryClient } from './query-client.js';
58
+ export { clearPersistedQueries, createDeskQueryClient, type PersistOptions } from './query-client.js';
59
59
  export { type ClientPoint, type ClientPointKind, type LumenTelemetryOptions, apiRoute, reportClient, reportClientError, reportFailedFetch, stackFingerprint, useLumenTelemetry, } from './telemetry.js';
60
60
  export { refreshHeaders, withRefreshToken } from './refresh.js';
61
61
  export { type LumenLoad, LumenError, LumenLazy, LumenLoadFooter, LumenLoadTime, LumenProgress, LumenRefresh, LumenSkeleton, LumenStates, marketTrades, useLumenLoad, useLumenSource, useLiveLoad, } from './lumen-source.js';
package/dist/index.js CHANGED
@@ -62,7 +62,7 @@ export { LumenChatgptSignin } from './chatgpt-signin.js';
62
62
  export { LumenExpandList, LumenExpandRow } from './expand-list.js';
63
63
  export { FactList } from './fact-list.js';
64
64
  export { LumenPager } from './pager.js';
65
- export { createDeskQueryClient } from './query-client.js';
65
+ export { clearPersistedQueries, createDeskQueryClient } from './query-client.js';
66
66
  export { apiRoute, reportClient, reportClientError, reportFailedFetch, stackFingerprint, useLumenTelemetry, } from './telemetry.js';
67
67
  export { refreshHeaders, withRefreshToken } from './refresh.js';
68
68
  export { LumenError, LumenLazy, LumenLoadFooter, LumenLoadTime, LumenProgress, LumenRefresh, LumenSkeleton, LumenStates, marketTrades, useLumenLoad, useLumenSource, useLiveLoad, } from './lumen-source.js';
@@ -11,7 +11,6 @@ import { DndContext, closestCenter } from '@dnd-kit/core';
11
11
  import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
12
12
  import { createPaginatedRowModel, createSortedRowModel, flexRender, rowPaginationFeature, rowSortingFeature, sortFns, tableFeatures, useTable, } from '@tanstack/react-table';
13
13
  import { ArrowDownIcon, ArrowUpIcon, ChartCandlestick, ChartSpline, ChevronLeftIcon, ChevronRightIcon, ChevronsDownIcon, ChevronsDownUpIcon, ChevronsUpDownIcon, Columns3CogIcon, CornerDownRightIcon, DownloadIcon, Grid3x3Icon, LayoutDashboardIcon, ListCollapseIcon, PencilIcon, PlusIcon, Settings2Icon, SigmaIcon, SparklesIcon, TableIcon, UserPenIcon, } from 'lucide-react';
14
- import Papa from 'papaparse';
15
14
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
15
  import { useAssistantNote } from './assistant-context.js';
17
16
  import { ColorChip, ColorDot } from './color-dot.js';
@@ -189,9 +188,9 @@ function ColumnPicker({ domain, columns, onColumns, open, onOpenChange, classNam
189
188
  /**
190
189
  * The view's rows as a CSV file: the filtered rows in the view's sort, its
191
190
  * shown columns (the grouping first when it is not one), plain numbers; amounts stay masked while
192
- * Hide amounts is on.
191
+ * Hide amounts is on. The CSV writer loads on the first download, not with the page.
193
192
  */
194
- function downloadCsv({ name, columns, rows, groupBy, sort, hidden, }) {
193
+ async function downloadCsv({ name, columns, rows, groupBy, sort, hidden, }) {
195
194
  const fields = groupBy && !columns.includes(groupBy) ? [groupBy, ...columns] : columns;
196
195
  const by = sort && fields.find((f) => f.key === sort.key);
197
196
  const sorted = by
@@ -212,6 +211,7 @@ function downloadCsv({ name, columns, rows, groupBy, sort, hidden, }) {
212
211
  return '';
213
212
  return hidden && f.amount ? maskAmount(String(v)) : v;
214
213
  };
214
+ const { default: Papa } = await import('papaparse');
215
215
  const csv = Papa.unparse({
216
216
  fields: fields.map((f) => f.label),
217
217
  data: sorted.map((r) => fields.map((f) => cell(f, r))),
@@ -570,7 +570,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
570
570
  }
571
571
  return [...m];
572
572
  }, [groupBy, sorted, serverGroups]);
573
- const span = shown.length + (selection ? 1 : 0);
573
+ const span = shown.length + (selection ? 1 : 0) + (add ? 1 : 0);
574
574
  const served = new Map((serverGroups?.list ?? []).map((g) => [g.key, g]));
575
575
  const startsOpen = (i) => rows.length <= OPEN_ALL || i === 0;
576
576
  const isOpen = (key, i) => serverGroups ? !!served.get(key)?.open : startsOpen(i) !== flipped.has(key);
@@ -632,7 +632,8 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
632
632
  };
633
633
  const anyFoldClosed = folds && foldKeys().some((k) => !openParts.has(k));
634
634
  const setAllParts = (open) => setOpenParts(new Set(open === !!subgroup?.open ? [] : foldKeys()));
635
- const flatName = (row) => flat && subgroup?.under ? (_jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [subgroup.mark?.(subgroup.under(row), [row]), _jsx("span", { className: "truncate", children: subgroup.under(row) })] })) : null;
635
+ const titleOf = (key, rows) => subgroup?.title?.(key, rows, groupBy?.key ?? null) ?? key;
636
+ const flatName = (row) => flat && subgroup?.under ? (_jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [subgroup.mark?.(subgroup.under(row), [row]), _jsx("span", { className: "truncate", children: titleOf(subgroup.under(row), [row]) })] })) : null;
636
637
  const nests = folds || sorted.some((r) => opens(r.original));
637
638
  const chevron = (k, name) => (_jsx(Button, { variant: "ghost", size: "icon-xs", className: "-ml-1", "aria-expanded": openParts.has(k), "aria-label": `${openParts.has(k) ? 'Hide' : 'Show'} ${name}`, onClick: (e) => {
638
639
  e.stopPropagation();
@@ -644,7 +645,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
644
645
  }, children: _jsx(ChevronRightIcon, { className: cn('transition-transform', openParts.has(k) && 'rotate-90') }) }));
645
646
  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
646
647
  ? name(p)
647
- : cellOf(f, p, chips.some((c) => c.key === f.key)) })] })) : (cellOf(f, p, chips.some((c) => c.key === f.key))) }, f.key)))] }, `part:${keyOf(p)}`));
648
+ : 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)}`));
648
649
  // The whole row opens the details drawer.
649
650
  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
650
651
  ? (e) => {
@@ -655,8 +656,8 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
655
656
  onDetails(r.original);
656
657
  }
657
658
  }
658
- : 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)))] }, domain.rowKey(r.original)), openParts.has(keyOf(r.original)) &&
659
- 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: shown.length + (selection ? 1 : 0), 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)));
659
+ : 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)) &&
660
+ 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)));
660
661
  // Rows sharing the sub-group's `under` key fold into one rolled-up line that opens them; a key
661
662
  // with one row keeps that row (NVDA opens Shares, then each option).
662
663
  const foldRow = (key, members, scope) => {
@@ -666,7 +667,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
666
667
  const n = new Set(o);
667
668
  n.has(k) ? n.delete(k) : n.add(k);
668
669
  return n;
669
- }), children: [selection && _jsx(TableCell, {}), shown.map((f, i) => (_jsx(TableCell, { ...col(f, groupBy && i === 0 && 'pl-8'), children: i === 0 ? (_jsxs("span", { className: "flex items-center gap-1", children: [chevron(k, `${key} ${openLabel()}`), subgroup?.mark?.(key, ms), _jsx("span", { className: "font-medium", children: key }), _jsx("span", { className: "num text-xs text-muted-foreground", children: ms.length })] })) : (rollCell(f, ms, 'fold')) }, f.key)))] }), openParts.has(k) && ms.map((p) => partRow(p, subgroup?.name))] }, k));
670
+ }), children: [selection && _jsx(TableCell, {}), shown.map((f, i) => (_jsx(TableCell, { ...col(f, groupBy && i === 0 && 'pl-8'), children: i === 0 ? (_jsxs("span", { className: "flex items-center gap-1", children: [chevron(k, `${key} ${openLabel()}`), subgroup?.mark?.(key, ms), _jsx("span", { className: "font-medium", title: titleOf(key, ms) === key ? undefined : key, children: titleOf(key, ms) }), _jsx("span", { className: "num text-xs text-muted-foreground", children: ms.length })] })) : (rollCell(f, ms, 'fold')) }, f.key))), add && _jsx(TableCell, {})] }), openParts.has(k) && ms.map((p) => partRow(p, subgroup?.name))] }, k));
670
671
  };
671
672
  const rowsOf = (list, scope) => {
672
673
  const under = folds ? subgroup?.under : undefined;
@@ -745,9 +746,9 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
745
746
  }), children: [_jsx(ChevronRightIcon, { className: cn('transition-transform', open && 'rotate-90') }), _jsx(GroupMark, { field: groupBy, value: key }), key, _jsx("span", { className: "num font-normal text-muted-foreground", children: serverGroups
746
747
  ? (served.get(key)?.n?.toLocaleString() ?? '')
747
748
  : members.length })] }), groupActions?.(key, members.map((m) => m.original))] }, f.key)) : (_jsx(TableCell, { ...col(f, PINNED_GROUP), children: !serverGroups &&
748
- rollCell(f, members.map((m) => m.original), 'group') }, f.key)))] }), open && (serverGroups ? servedRows(members, key) : drawn(members, key))] }, key));
749
+ rollCell(f, members.map((m) => m.original), 'group') }, f.key))), add && _jsx(TableCell, { className: PINNED_GROUP })] }), open && (serverGroups ? servedRows(members, key) : drawn(members, key))] }, key));
749
750
  })
750
- : drawn(sorted, ''), serverGroups?.more && !serverGroups.pages && (_jsx(TableRow, { "data-more": true, children: _jsx(TableCell, { colSpan: span, className: "py-1", children: _jsxs(Button, { variant: "ghost", size: "xs", onClick: serverGroups.more, children: [_jsx(ChevronsDownIcon, {}), "More groups"] }) }) })), hasTotal && rows.length > 0 && (_jsxs(TableRow, { className: cn(ROLLUP, 'border-t-2'), "data-total": true, children: [selection && _jsx(TableCell, {}), rollCells(rows).map((cell, i) => i === 0 && !(shown[0] && rollsUp(shown[0])) ? (_jsx(TableCell, { children: "Total" }, "total")) : (cell))] }))] })] }) }) }));
751
+ : drawn(sorted, ''), serverGroups?.more && !serverGroups.pages && (_jsx(TableRow, { "data-more": true, children: _jsx(TableCell, { colSpan: span, className: "py-1", children: _jsxs(Button, { variant: "ghost", size: "xs", onClick: serverGroups.more, children: [_jsx(ChevronsDownIcon, {}), "More groups"] }) }) })), hasTotal && rows.length > 0 && (_jsxs(TableRow, { className: cn(ROLLUP, 'border-t-2'), "data-total": true, children: [selection && _jsx(TableCell, {}), rollCells(rows).map((cell, i) => i === 0 && !(shown[0] && rollsUp(shown[0])) ? (_jsx(TableCell, { children: "Total" }, "total")) : (cell)), add && _jsx(TableCell, {})] }))] })] }) }) }));
751
752
  }
752
753
  /** One value rolled up into a rows × columns grid, with totals where the roll-up adds. */
753
754
  function PivotTable({ domain, rows, pivot, }) {
@@ -1,7 +1,19 @@
1
1
  import { QueryClient, type QueryClientConfig } from '@tanstack/react-query';
2
2
  type Retry = NonNullable<NonNullable<QueryClientConfig['defaultOptions']>['queries']>['retry'];
3
+ export interface PersistOptions {
4
+ /** The storage key; one per app (`career`), so apps never read each other's shapes. */
5
+ key: string;
6
+ /** Changes when stored shapes would no longer fit; a mismatch drops them. Defaults to the
7
+ * page's entry script address, which has a new hash on every build. */
8
+ buster?: string;
9
+ /** Oldest data shown at load, in ms (a day by default). */
10
+ maxAge?: number;
11
+ }
3
12
  /** The app's one QueryClient; `retry` is the app's own rule for failed requests. */
4
- export declare function createDeskQueryClient({ retry }?: {
13
+ export declare function createDeskQueryClient({ retry, persist, }?: {
5
14
  retry?: Retry;
15
+ persist?: PersistOptions;
6
16
  }): QueryClient;
17
+ /** Signing out anywhere on the site forgets every app's stored reads. */
18
+ export declare function clearPersistedQueries(): void;
7
19
  export {};
@@ -3,10 +3,19 @@
3
3
  // again shows what it had without a request. Data changes reach the cache another way: a save
4
4
  // invalidates its keys, a running job polls (`refetchInterval`) until it ends, an ended Task
5
5
  // invalidates everything (tasks.tsx), and the header's Refresh refetches the page.
6
- import { QueryClient } from '@tanstack/react-query';
6
+ //
7
+ // With `persist`, the cache also outlives the page: what the last visit read (within a day) is
8
+ // shown at once on the next load or in another app of the same site, and anything older than
9
+ // `staleTime` is read again in the background. Signing out (`[data-sign-out]`) clears it. A query
10
+ // with `meta: { persist: false }` is never written.
11
+ import { QueryClient, dehydrate, hydrate, } from '@tanstack/react-query';
12
+ const PREFIX = 'lumen-cache:';
13
+ const DAY = 24 * 60 * 60_000;
14
+ /** Above this a write is skipped rather than filling the origin's storage. */
15
+ const MAX_BYTES = 2_000_000;
7
16
  /** The app's one QueryClient; `retry` is the app's own rule for failed requests. */
8
- export function createDeskQueryClient({ retry } = {}) {
9
- return new QueryClient({
17
+ export function createDeskQueryClient({ retry, persist, } = {}) {
18
+ const qc = new QueryClient({
10
19
  defaultOptions: {
11
20
  queries: {
12
21
  retry: retry ?? 1,
@@ -16,4 +25,80 @@ export function createDeskQueryClient({ retry } = {}) {
16
25
  },
17
26
  },
18
27
  });
28
+ if (persist)
29
+ persistQueries(qc, persist);
30
+ return qc;
31
+ }
32
+ /** Signing out anywhere on the site forgets every app's stored reads. */
33
+ export function clearPersistedQueries() {
34
+ try {
35
+ for (const k of Object.keys(localStorage))
36
+ if (k.startsWith(PREFIX))
37
+ localStorage.removeItem(k);
38
+ }
39
+ catch { }
40
+ }
41
+ function persistQueries(qc, { key, buster = entryScript(), maxAge = DAY }) {
42
+ if (typeof window === 'undefined')
43
+ return;
44
+ const storageKey = PREFIX + key;
45
+ try {
46
+ const raw = localStorage.getItem(storageKey);
47
+ const stored = raw ? JSON.parse(raw) : null;
48
+ if (stored && stored.buster === buster && Date.now() - stored.at < maxAge) {
49
+ hydrate(qc, stored.state);
50
+ }
51
+ else if (raw) {
52
+ localStorage.removeItem(storageKey);
53
+ }
54
+ }
55
+ catch {
56
+ clearKey(storageKey);
57
+ }
58
+ let timer;
59
+ let off = false;
60
+ const save = () => {
61
+ timer = undefined;
62
+ if (off)
63
+ return;
64
+ try {
65
+ const state = dehydrate(qc, {
66
+ shouldDehydrateQuery: (q) => q.state.status === 'success' &&
67
+ q.meta?.persist !== false,
68
+ });
69
+ const text = JSON.stringify({ at: Date.now(), buster, state });
70
+ if (text.length > MAX_BYTES)
71
+ return;
72
+ localStorage.setItem(storageKey, text);
73
+ }
74
+ catch { }
75
+ };
76
+ // One write a second at most, and one more when the tab is hidden (a reload or another app).
77
+ qc.getQueryCache().subscribe((e) => {
78
+ if (e.type !== 'updated' && e.type !== 'removed')
79
+ return;
80
+ if (!timer && !off)
81
+ timer = setTimeout(save, 1000);
82
+ });
83
+ document.addEventListener('visibilitychange', () => {
84
+ if (document.visibilityState === 'hidden')
85
+ save();
86
+ });
87
+ document.addEventListener('click', (e) => {
88
+ if (e.target?.closest?.('[data-sign-out]')) {
89
+ off = true;
90
+ clearPersistedQueries();
91
+ }
92
+ }, true);
93
+ }
94
+ function entryScript() {
95
+ if (typeof document === 'undefined')
96
+ return '';
97
+ return document.querySelector('script[type="module"][src]')?.src ?? '';
98
+ }
99
+ function clearKey(k) {
100
+ try {
101
+ localStorage.removeItem(k);
102
+ }
103
+ catch { }
19
104
  }
package/dist/tasks.js CHANGED
@@ -11,7 +11,7 @@ import { toast } from 'sonner';
11
11
  // ended unseen. It opens a popover on desktop and a bottom sheet on phone with the last 7 days;
12
12
  // opening it marks the ended ones seen. ✕ clears one ended task, the brush every ended one
13
13
  //. A task that ends while the page is open shows a toast with
14
- // Open. The list polls one cheap read every 3 s only while something runs and the tab is visible;
14
+ // Open. The list polls one cheap read every 3 s (15 s after a minute) only while something runs and the tab is visible;
15
15
  // otherwise it reads again on focus. The product mounts `TasksProvider` and `TasksButton` in the
16
16
  // shell's slots; where the API answers 404 nothing shows.
17
17
  import { LumenConfirm } from './confirm.js';
@@ -40,6 +40,7 @@ export function useTaskFor(kind, ref) {
40
40
  return tasks.list.tasks.find((t) => t.kind === kind && t.ref === ref) ?? null;
41
41
  }
42
42
  const POLL_MS = 3000;
43
+ const SLOW_POLL_MS = 15_000;
43
44
  export function TasksProvider({ config, children }) {
44
45
  if (!config)
45
46
  return children;
@@ -70,7 +71,15 @@ function TasksLive({ api, apps, children, }) {
70
71
  const path = base && url.pathname.startsWith(base) ? url.pathname.slice(base.length) : url.pathname;
71
72
  void router.navigate({ href: `${path || '/'}${url.search}${url.hash}` });
72
73
  }, [router, shell]);
73
- const refresh = useCallback(async () => {
74
+ // The newest `open` and query client, read through refs so `refresh` stays one function for
75
+ // the provider's life: a new one would re-run the first-read effect and fetch the list again.
76
+ const openRef = useRef(open);
77
+ openRef.current = open;
78
+ const qcRef = useRef(qc);
79
+ qcRef.current = qc;
80
+ // Reads already on the wire share one request.
81
+ const inflight = useRef(null);
82
+ const read = useCallback(async () => {
74
83
  const res = await fetch(api, { headers: { accept: 'application/json' } }).catch(() => null);
75
84
  if (!res)
76
85
  return;
@@ -85,14 +94,20 @@ function TasksLive({ api, apps, children, }) {
85
94
  if (before) {
86
95
  const ended = next.tasks.filter((t) => before.has(t.id) && !isActive(t));
87
96
  for (const t of ended)
88
- endedToast(t, open);
97
+ endedToast(t, openRef.current);
89
98
  // A task's results are new data: drop the cache once (query-client.ts).
90
99
  if (ended.length)
91
- void qc.invalidateQueries();
100
+ void qcRef.current.invalidateQueries();
92
101
  }
93
102
  activeIds.current = new Set(next.tasks.filter(isActive).map((t) => t.id));
94
103
  setList(next);
95
- }, [api, open, qc]);
104
+ }, [api]);
105
+ const refresh = useCallback(() => {
106
+ inflight.current ??= read().finally(() => {
107
+ inflight.current = null;
108
+ });
109
+ return inflight.current;
110
+ }, [read]);
96
111
  const track = useCallback((t) => {
97
112
  activeIds.current = new Set([...(activeIds.current ?? []), t.id]);
98
113
  setList((l) => {
@@ -147,16 +162,21 @@ function TasksLive({ api, apps, children, }) {
147
162
  document.removeEventListener('visibilitychange', again);
148
163
  };
149
164
  }, [refresh]);
150
- // Every 3 s only while something is queued or running and the tab is visible.
165
+ // Every 3 s while something is queued or running and the tab is visible, easing to every 15 s
166
+ // once it has run a minute (a long search needs no second-by-second count).
151
167
  const active = (list?.active ?? 0) > 0;
152
168
  useEffect(() => {
153
169
  if (!active)
154
170
  return;
155
- const id = window.setInterval(() => {
171
+ const since = Date.now();
172
+ let id;
173
+ const tick = () => {
156
174
  if (document.visibilityState === 'visible')
157
175
  void refresh();
158
- }, POLL_MS);
159
- return () => window.clearInterval(id);
176
+ id = window.setTimeout(tick, Date.now() - since > 60_000 ? SLOW_POLL_MS : POLL_MS);
177
+ };
178
+ id = window.setTimeout(tick, POLL_MS);
179
+ return () => window.clearTimeout(id);
160
180
  }, [active, refresh]);
161
181
  const value = useMemo(() => (gone ? null : { list, refresh, cancel, seen, clear, open, track, apps }), [gone, list, refresh, cancel, seen, clear, open, track, apps]);
162
182
  return _jsx(TasksCtx.Provider, { value: value, children: children });
@@ -46,7 +46,7 @@ import { Textarea } from './ui/textarea.js';
46
46
  import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
47
47
  import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
48
48
  import { AGG_LABELS, FORMAT_LABELS, FormatChip, FormulaForm, MAX_FORMULAS, fieldOptions, formatOptions, formulaIssueTarget, formulaIssues, formulaOptions, } from './view-formulas.js';
49
- import { VIEW_ICONS, lucideIconNames, shapeIcon, viewIcon } from './view-icons.js';
49
+ import { VIEW_ICONS, shapeIcon, viewIcon } from './view-icons.js';
50
50
  import { FORMULAS_GROUP, canFilter, chipsOf, formulaBlanks, formulasOf, groupOf, hiddenOf, subgroupLabel, subgroupOf, subgroupsOf, summaryCash, takesFormulas, tilesOf, totalAggOf, withViewFields, } from './view-model.js';
51
51
  import { WidgetControlsEditor } from './widget-controls.js';
52
52
  import { PAGE_SOURCE, useWidgetDataOptions } from './widget-data.js';
@@ -1,6 +1,5 @@
1
1
  import type { ViewIcon, ViewIconName, ViewShape } from '@columenlabs/lumen-ui-types/views';
2
2
  import { type LucideIcon } from 'lucide-react';
3
- import { iconNames } from 'lucide-react/dynamic';
4
3
  import type { NavIcon } from './nav.js';
5
4
  export declare const VIEW_ICONS: Record<ViewIconName, LucideIcon>;
6
5
  /** A view's sidebar icon: its own pick (a lucide name or an emoji), else its shape's. */
@@ -10,11 +9,11 @@ export declare const viewIcon: (config: {
10
9
  }) => NavIcon;
11
10
  /** A picked icon (a lucide name or an emoji) as a component; `fallback` when none or unknown. */
12
11
  export declare const iconOf: (icon: ViewIcon | null | undefined, fallback: NavIcon) => NavIcon;
12
+ /** Every lucide icon name, for the icon picker's search: empty until `load` asks for the map and it lands. */
13
+ export declare function useLucideIconNames(load?: boolean): readonly string[];
13
14
  /** Any lucide icon by name, loaded on first use (lucide's own DynamicIcon). */
14
- export declare const lucideIcon: (name: string) => NavIcon;
15
+ export declare const lucideIcon: (name: string, fallback?: NavIcon) => NavIcon;
15
16
  /** An emoji drawn where an icon goes. */
16
17
  export declare const emojiIcon: (emoji: string) => NavIcon;
17
- /** Every lucide icon name, for the icon picker's search. */
18
- export { iconNames as lucideIconNames };
19
18
  /** A shape's own icon (the Edit dialog's Shape picker). */
20
19
  export declare const shapeIcon: (shape: ViewShape) => LucideIcon;
@@ -1,6 +1,5 @@
1
1
  import { BanknoteIcon, BriefcaseIcon, CalendarIcon, ChartBarIcon, ChartLineIcon, ChartPieIcon, ClockIcon, CoinsIcon, FlameIcon, Grid3x3Icon, LandmarkIcon, LayersIcon, ListTreeIcon, PiggyBankIcon, ReceiptIcon, ShieldIcon, StarIcon, TableIcon, TagIcon, TargetIcon, TrendingDownIcon, TrendingUpIcon, WalletIcon, ZapIcon, } from 'lucide-react';
2
- import { DynamicIcon, iconNames } from 'lucide-react/dynamic';
3
- import { createElement } from 'react';
2
+ import { createElement, useEffect, useState } from 'react';
4
3
  import { cn } from './lib/utils.js';
5
4
  export const VIEW_ICONS = {
6
5
  table: TableIcon,
@@ -42,10 +41,44 @@ export const iconOf = (icon, fallback) => {
42
41
  if (icon in VIEW_ICONS)
43
42
  return VIEW_ICONS[icon];
44
43
  if (/^[a-z]/.test(icon))
45
- return lucideNames.has(icon) ? lucideIcon(icon) : fallback;
44
+ return lucide && !lucide.names.has(icon) ? fallback : lucideIcon(icon, fallback);
46
45
  return emojiIcon(icon);
47
46
  };
48
- const lucideNames = new Set(iconNames);
47
+ let lucide;
48
+ let loadingLucide;
49
+ function loadLucide() {
50
+ if (!loadingLucide)
51
+ loadingLucide = import('lucide-react/dynamic').then((m) => {
52
+ lucide = { ...m, names: new Set(m.iconNames) };
53
+ return lucide;
54
+ });
55
+ return loadingLucide;
56
+ }
57
+ /** Every lucide icon name, for the icon picker's search: empty until `load` asks for the map and it lands. */
58
+ export function useLucideIconNames(load = true) {
59
+ const [names, setNames] = useState(() => lucide?.iconNames ?? []);
60
+ useEffect(() => {
61
+ if (load && !names.length)
62
+ loadLucide().then((m) => setNames(m.iconNames));
63
+ }, [load, names.length]);
64
+ return names;
65
+ }
66
+ /** A lucide icon by name: an empty slot its size while the map loads, `fallback` when it has no such icon. */
67
+ function LucideByName({ name, fallback, className, }) {
68
+ const [, loaded] = useState(!!lucide);
69
+ useEffect(() => {
70
+ if (!lucide)
71
+ loadLucide().then(() => loaded(true));
72
+ }, []);
73
+ if (!lucide)
74
+ return createElement('span', {
75
+ 'aria-hidden': true,
76
+ className: cn('inline-block size-4 shrink-0', className),
77
+ });
78
+ if (!lucide.names.has(name))
79
+ return fallback ? createElement(fallback, { className }) : null;
80
+ return createElement(lucide.DynamicIcon, { name: name, className });
81
+ }
49
82
  const made = new Map();
50
83
  /** One component per pick, so React keeps it between renders. */
51
84
  const once = (key, make) => {
@@ -57,14 +90,12 @@ const once = (key, make) => {
57
90
  return Icon;
58
91
  };
59
92
  /** Any lucide icon by name, loaded on first use (lucide's own DynamicIcon). */
60
- export const lucideIcon = (name) => once(`lucide:${name}`, () => (props) => createElement(DynamicIcon, { name: name, ...props }));
93
+ export const lucideIcon = (name, fallback) => once(`lucide:${name}`, () => ({ className }) => createElement(LucideByName, { name, fallback, className }));
61
94
  /** An emoji drawn where an icon goes. */
62
95
  export const emojiIcon = (emoji) => once(`emoji:${emoji}`, () => ({ className }) => createElement('span', {
63
96
  'aria-hidden': true,
64
97
  'data-slot': 'view-emoji',
65
98
  className: cn('inline-grid size-4 shrink-0 place-items-center text-sm leading-none', className),
66
99
  }, emoji));
67
- /** Every lucide icon name, for the icon picker's search. */
68
- export { iconNames as lucideIconNames };
69
100
  /** A shape's own icon (the Edit dialog's Shape picker). */
70
101
  export const shapeIcon = (shape) => SHAPE_ICONS[shape];
@@ -318,6 +318,12 @@ export interface ViewSubgroup<T> {
318
318
  open?: boolean;
319
319
  /** Drawn before a folded line's key (a meter's product icon, in its colour). */
320
320
  mark?: (key: string, rows: T[]) => ReactNode;
321
+ /**
322
+ * A folded (or flat) line's name instead of its key, given the field the rows are grouped by:
323
+ * under a group that already names part of the key, say only the rest (a meter's own name
324
+ * under its product's group).
325
+ */
326
+ title?: (key: string, rows: T[], groupBy: string | null) => string;
321
327
  /**
322
328
  * Group fields under which rows do not fold: each row is one line named by its `under` key (and
323
329
  * `mark`) instead of its first column (grouped by label, a label's meters).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.97",
3
+ "version": "0.1.99",
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.97",
52
+ "@columenlabs/lumen-ui-types": "0.1.99",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",