@columenlabs/lumen-ui 0.1.107 → 0.1.108

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.
@@ -9,6 +9,7 @@ import { cn } from './lib/utils.js';
9
9
  import { LumenStates, useLumenSource } from './lumen-source.js';
10
10
  import { LumenWidget } from './lumen-widget.js';
11
11
  import { LumenPager } from './pager.js';
12
+ import { useScrollFade } from './scroll-fade.js';
12
13
  import { Badge } from './ui/badge.js';
13
14
  import { Button } from './ui/button.js';
14
15
  import { Skeleton } from './ui/skeleton.js';
@@ -185,7 +186,9 @@ const MapRow = memo(function MapRow({ r, cols, marks, picks, start, previews, to
185
186
  const link = look.link?.(r.row);
186
187
  return (_jsxs(_Fragment, { children: [_jsxs("span", {
187
188
  // Rows sit indented under their group's name.
188
- className: cn('sticky left-0 z-1 flex min-w-0 flex-col bg-card pr-2', look.groupBy && (look.mobile ? 'pl-1.5' : 'pl-3')), children: [_jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [link ? (_jsx(Link, { ...link, className: "num truncate font-medium hover:text-primary hover:underline", children: text })) : (_jsx("span", { className: "num truncate font-medium", children: text })), r.badge] }), r.sub && !look.mobile && (_jsx("span", { className: "truncate text-[11px] text-muted-foreground", children: r.sub }))] }), cols.map((k, i) => {
189
+ className: cn(look.mobile
190
+ ? 'col-span-full -mb-0.5 flex min-w-0'
191
+ : 'sticky left-0 z-1 flex min-w-0 flex-col bg-card pr-2', look.groupBy && (look.mobile ? 'pl-1.5' : 'pl-3')), children: [_jsxs("span", { className: cn('flex min-w-0 items-center gap-1.5', look.mobile && 'sticky left-0 max-w-[calc(var(--view-w,100vw)-4rem)] text-xs', look.mobile && look.inset && 'left-8'), children: [link ? (_jsx(Link, { ...link, className: "num truncate font-medium hover:text-primary hover:underline", children: text })) : (_jsx("span", { className: "num truncate font-medium", children: text })), r.badge] }), r.sub && !look.mobile && (_jsx("span", { className: "truncate text-[11px] text-muted-foreground", children: r.sub }))] }), cols.map((k, i) => {
189
192
  const m = marks[i];
190
193
  if (!m)
191
194
  return _jsx("span", {}, k);
@@ -223,6 +226,7 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
223
226
  setStart(null);
224
227
  }, [preselect]);
225
228
  const scroller = useRef(null);
229
+ const edge = useScrollFade(scroller);
226
230
  const [pageSize, setPageSize] = useState(initialPageSize ?? 0);
227
231
  const [pageIndex, setPageIndex] = useState(0);
228
232
  const folded = useMemo(() => (data ? fold(data, block) : null), [data, block]);
@@ -243,6 +247,7 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
243
247
  const el = scroller.current;
244
248
  if (el && layout === 'grid')
245
249
  el.scrollLeft = el.scrollWidth;
250
+ edge.update();
246
251
  }, [strip]);
247
252
  const sums = mobile ? summary.filter((s) => s === 'complete') : summary;
248
253
  const rowsHead = `${unit[0]?.toUpperCase() ?? ''}${unit.slice(1)}`;
@@ -444,7 +449,8 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
444
449
  // sideways with the names column pinned, opening at today.
445
450
  const cell = `minmax(${CELL_MIN}px, 1fr)`;
446
451
  const grid = {
447
- gridTemplateColumns: `${label} repeat(${cols.length}, ${cell}) ${sums.map((s) => SUMMARY_WIDTH[s]).join(' ')}`,
452
+ // A phone puts each name on its own line above its blocks, so blocks never run under it.
453
+ gridTemplateColumns: `${mobile ? '' : `${label} `}repeat(${cols.length}, ${cell}) ${sums.map((s) => SUMMARY_WIDTH[s]).join(' ')}`,
448
454
  };
449
455
  const allRows = data.rows.length
450
456
  ? data.rows
@@ -460,9 +466,17 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
460
466
  const row = id.slice(0, cut);
461
467
  pickedBy.set(row, [...(pickedBy.get(row) ?? []), id.slice(cut + 1)]);
462
468
  }
463
- const look = { mobile, groupBy, select, sums, drilled, link: rowSpec.link };
469
+ const look = {
470
+ mobile,
471
+ inset: edge.side === 'start' || edge.side === 'both',
472
+ groupBy,
473
+ select,
474
+ sums,
475
+ drilled,
476
+ link: rowSpec.link,
477
+ };
464
478
  let group;
465
- body = (_jsxs("div", { className: cn('grid min-w-min items-center gap-[3px]', fade), style: grid, ...routes, children: [_jsx("span", { className: "sticky left-0 z-1 bg-card text-xs text-muted-foreground", children: mobile ? '' : rowSpec.title }), cols.map((k) => (_jsx("span", { className: "num h-4 overflow-visible text-[11px] whitespace-nowrap text-muted-foreground", children: headLabel(k, block, cols.length) }, k))), sums.map((s) => (_jsx("span", { className: "pl-3 text-right text-xs text-muted-foreground", children: s === 'rows' || s === 'counts' ? rowsHead : SUMMARY_HEAD[s] }, s))), rowList.flatMap((r) => {
479
+ body = (_jsxs("div", { className: cn('grid min-w-min items-center gap-[3px]', fade), style: grid, ...routes, children: [!mobile && (_jsx("span", { className: "sticky left-0 z-1 bg-card text-xs text-muted-foreground", children: rowSpec.title })), cols.map((k) => (_jsx("span", { className: "num h-4 overflow-visible text-[11px] whitespace-nowrap text-muted-foreground", children: headLabel(k, block, cols.length) }, k))), sums.map((s) => (_jsx("span", { className: "pl-3 text-right text-xs text-muted-foreground", children: s === 'rows' || s === 'counts' ? rowsHead : SUMMARY_HEAD[s] }, s))), rowList.flatMap((r) => {
466
480
  const out = [];
467
481
  const shut = groupBy && folds.has(foldKey + (r.group ?? ''));
468
482
  if (groupBy && r.group !== group) {
@@ -496,6 +510,7 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
496
510
  r.last,
497
511
  JSON.stringify(rowSpec.link?.(r.row) ?? null),
498
512
  mobile,
513
+ look.inset,
499
514
  groupBy,
500
515
  select,
501
516
  sums.join(),
@@ -545,7 +560,7 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
545
560
  : `Tap a ${word}, then another to pick the range` })) }), pickedCount > 0 ? (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
546
561
  setPicked(new Set());
547
562
  setStart(null);
548
- }, children: "Clear" }), removeCount > 0 && (_jsxs(Button, { variant: "outline", size: "sm", disabled: queueing, onClick: () => void queue(true), children: [_jsx(ListXIcon, {}), " Remove from queue \u00B7 ", months(removeCount)] })), queueCount > 0 && (_jsxs(Button, { size: "sm", disabled: queueing || !onQueue, onClick: () => void queue(), children: [_jsx(ListPlusIcon, {}), " ", queueLabel, " \u00B7 ", months(queueCount)] }))] })) : (pickTools)] })), _jsx("div", { ref: scroller, className: "overflow-x-auto overflow-y-hidden pb-1", children: _jsx(CoverageTip, { content: tipFor, className: layout === 'months' ? 'min-w-0' : 'w-max min-w-full', children: body }) }), _jsx("div", { className: "border-t pt-3", children: _jsx(CoverageLegend, { levels: levels, labels: stateLabels, hint: hint ??
563
+ }, children: "Clear" }), removeCount > 0 && (_jsxs(Button, { variant: "outline", size: "sm", disabled: queueing, onClick: () => void queue(true), children: [_jsx(ListXIcon, {}), " Remove from queue \u00B7 ", months(removeCount)] })), queueCount > 0 && (_jsxs(Button, { size: "sm", disabled: queueing || !onQueue, onClick: () => void queue(), children: [_jsx(ListPlusIcon, {}), " ", queueLabel, " \u00B7 ", months(queueCount)] }))] })) : (pickTools)] })), _jsx("div", { ref: scroller, onScroll: edge.update, "data-fade": edge.side, className: "overflow-x-auto overflow-y-hidden pb-1", children: _jsx(CoverageTip, { content: tipFor, className: layout === 'months' ? 'min-w-0' : 'w-max min-w-full', children: body }) }), _jsx("div", { className: "border-t pt-3", children: _jsx(CoverageLegend, { levels: levels, labels: stateLabels, hint: hint ??
549
564
  (select === 'backfill'
550
565
  ? 'Tap a block, then another for a range'
551
566
  : select === 'drill'
@@ -33,6 +33,7 @@ import { LumenViewCard } from './lumen-view-card.js';
33
33
  import { AddWidgetDialog, LayoutActions, LayoutChip, LumenSavedWidget, LumenWidget, cardFilterFields, cardTitle, cardsAfter, } from './lumen-widget.js';
34
34
  import { OriginLegend } from './origin-legend.js';
35
35
  import { IconButton, Page } from './page.js';
36
+ import { useScrollFade } from './scroll-fade.js';
36
37
  import { SourcedCard } from './sourced-card.js';
37
38
  import { LumenCard, SummaryTiles } from './summary-cards.js';
38
39
  import { computedOf, filterFieldsOf, formulaBlanks, pick, subgroupOf, summaryCash, takesFormulas, withSavedFilters, withViewFields, } from './view-model.js';
@@ -109,6 +110,7 @@ function Scroller({ fit, children }) {
109
110
  // it (theme.css): this lines that paint up with the fixed wash and stops pinned headers below its
110
111
  // fade. Nothing here runs on scroll: the row is sticky, so it never moves on screen.
111
112
  const ref = useRef(null);
113
+ const edge = useScrollFade(ref);
112
114
  const watched = useRef(null);
113
115
  useEffect(() => {
114
116
  const el = ref.current;
@@ -163,7 +165,7 @@ function Scroller({ fit, children }) {
163
165
  seen.observe(el.firstElementChild);
164
166
  return () => seen.disconnect();
165
167
  }, []);
166
- return (_jsx("div", { ref: ref, "data-slot": "view-scroll", className: VIEW_SCROLL, children: _jsx("div", { className: cn('flex flex-col gap-(--shell-section-gap)', fit ? 'w-full' : 'w-max min-w-full'), children: children }) }));
168
+ return (_jsx("div", { ref: ref, "data-slot": "view-scroll", "data-fade": edge.side, onScroll: edge.update, className: cn(VIEW_SCROLL, '[--fade-inset:var(--view-sb,0px)]'), children: _jsx("div", { className: cn('flex flex-col gap-(--shell-section-gap)', fit ? 'w-full' : 'w-max min-w-full'), children: children }) }));
167
169
  }
168
170
  const codeSlots = (cards, scope) => (cards ?? []).map((c) => ({
169
171
  id: c.id,
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useScrollFade } from './scroll-fade.js';
2
3
  import { Tabs, TabsList, TabsTrigger } from './ui/tabs.js';
3
4
  export function LumenTabs({ tabs, value, onChange, actions, }) {
4
5
  const shown = tabs.filter((t) => !t.hidden);
5
- return (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("div", { className: "min-w-48 max-w-full flex-1", children: shown.length ? (_jsx(Tabs, { value: value, onValueChange: (v) => onChange(v), children: _jsx(TabsList, { className: "max-w-full overflow-x-auto", children: shown.map(({ value: v, label, icon: Icon, after }) => (_jsxs(TabsTrigger, { value: v, children: [_jsx(Icon, {}), label, after] }, v))) }) })) : null }), actions ? _jsx("div", { className: "ml-auto flex shrink-0 items-center gap-0.5", children: actions }) : null] }));
6
+ const fade = useScrollFade();
7
+ return (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("div", { className: "min-w-48 max-w-full flex-1", children: shown.length ? (_jsx(Tabs, { value: value, onValueChange: (v) => onChange(v), children: _jsx(TabsList, { ref: fade.ref, onScroll: fade.update, "data-fade": fade.side, className: "max-w-full overflow-x-auto", children: shown.map(({ value: v, label, icon: Icon, after }) => (_jsxs(TabsTrigger, { value: v, children: [_jsx(Icon, {}), label, after] }, v))) }) })) : null }), actions ? _jsx("div", { className: "ml-auto flex shrink-0 items-center gap-0.5", children: actions }) : null] }));
6
8
  }
@@ -301,7 +301,9 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
301
301
  const table = pivot && config.pivot ? (_jsx(PivotTable, { domain: domain, rows: rows, pivot: config.pivot })) : (_jsx(LumenTable, { domain: domain, columns: columns, rows: tableRows, groupBy: groupBy, subgroup: sub, chips: chips, page: paging ? { index: page, size: pageSize } : null, inView: inLayout, fit: fit, onDetails: onDetails, tapped: tapped, selection: selection, groupActions: groupActions, sort: sort, onSort: sorted, serverSorts: server ? (server.sorts ?? []) : undefined, serverGroups: served, total: config.total !== false, add: onAddColumn || onColumns
302
302
  ? { ...onAddColumn, field: onColumns && { domain, columns, onColumns } }
303
303
  : undefined }));
304
- return (_jsx(LumenWidget, { id: cardId, kind: "table", style: "table", title: title, 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", onClick: () => downloadCsv({
304
+ 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",
305
+ // A phone's card header has room for the pager, filter, refresh and menu only.
306
+ className: "max-md:hidden", onClick: () => downloadCsv({
305
307
  name: title,
306
308
  columns,
307
309
  rows: tableRows,
@@ -425,8 +427,16 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
425
427
  // The grouped field is the group row's label, so its column hides (a flat lead stays).
426
428
  // Each key once: two columns under one React key can lose a cell on a re-render, and every
427
429
  // row after it slides left by one.
430
+ // A `hideEmpty` column with nothing in any row here leaves the table.
431
+ const empty = (f) => !!f.hideEmpty &&
432
+ rows.length > 0 &&
433
+ rows.every((r) => {
434
+ const v = f.value(r);
435
+ return v == null || v === '' || v === 0;
436
+ });
428
437
  const visible = columns.filter((f, i) => (f.key !== groupBy?.key || (flat && i === 0)) &&
429
- columns.findIndex((x) => x.key === f.key) === i);
438
+ columns.findIndex((x) => x.key === f.key) === i &&
439
+ !empty(f));
430
440
  // Inline chips sit on the first column, next to the row's name, not in their own.
431
441
  const inline = visible.filter((f, i) => i > 0 && chips.some((c) => c.key === f.key && c.placement === 'inline'));
432
442
  const shown = visible.filter((f) => !inline.includes(f));
@@ -440,6 +450,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
440
450
  const col = (f, ...more) => ({
441
451
  ...colProps(f, ...more),
442
452
  'data-card': f ? cardOf(f) : undefined,
453
+ 'data-card-unit': f?.cardUnit,
443
454
  });
444
455
  const computed = useMemo(() => computedOf(domain, rows, groupBy), [domain, rows, groupBy]);
445
456
  // Values per editable field, offered when a cell is edited: the field's own list when it has
@@ -52,7 +52,7 @@ export interface CardLink {
52
52
  /** Its own filter's linked chips (the phone's count beside the funnel). */
53
53
  linked?: number;
54
54
  }
55
- export declare function LumenWidget({ id, kind, icon, title, note, controls, tools, filter, filterCount, load, rows, loadNote, pad, pinned, compact, className, children, link: given, help, assistant, strip, shown, style, }: {
55
+ export declare function LumenWidget({ id, kind, icon, title, note, controls, tools, phoneTitle, filter, filterCount, load, rows, loadNote, pad, pinned, compact, className, children, link: given, help, assistant, strip, shown, style, }: {
56
56
  id: string;
57
57
  /** Its controls (Edit widget ▸ Controls): Ask the assistant, Refresh, Filter box and
58
58
  * Footer show or hide here; missing keys take the type's default. */
@@ -78,6 +78,9 @@ export declare function LumenWidget({ id, kind, icon, title, note, controls, too
78
78
  controls?: CardControls;
79
79
  /** Buttons before the ↻, e.g. a view's pager. */
80
80
  tools?: ReactNode;
81
+ /** False: a phone leaves the title out and gives the header to `tools` (a view whose
82
+ * page header names it already). */
83
+ phoneTitle?: boolean;
81
84
  /** The card's own filter (a view's LumenTokenizedFilter and Group): on the title's line
82
85
  * when the header is wide, on a line under it when narrow, behind a funnel button on phone. */
83
86
  filter?: ReactNode;
@@ -180,7 +180,7 @@ function useWidth() {
180
180
  }, []);
181
181
  return [ref, width];
182
182
  }
183
- export function LumenWidget({ id, kind, icon, title, note, controls, tools, filter, filterCount = 0, load, rows, loadNote, pad = true, pinned = false, compact = false, className, children, link: given, help, assistant, strip, shown, style, }) {
183
+ export function LumenWidget({ id, kind, icon, title, note, controls, tools, phoneTitle, filter, filterCount = 0, load, rows, loadNote, pad = true, pinned = false, compact = false, className, children, link: given, help, assistant, strip, shown, style, }) {
184
184
  const [removing, setRemoving] = useState(false);
185
185
  const cell = useGridCell();
186
186
  const links = useLinks();
@@ -232,7 +232,7 @@ export function LumenWidget({ id, kind, icon, title, note, controls, tools, filt
232
232
  layout &&
233
233
  "relative after:pointer-events-none after:absolute after:inset-0 after:z-30 after:rounded-[inherit] after:border-2 after:border-primary/40 after:border-dashed after:content-['']", flat && 'border-0 bg-transparent shadow-none',
234
234
  // The corner ⋯ of a card with its header off sits over the body's top right.
235
- headless && 'relative', className), children: [!headless && (_jsxs("div", { ref: head, className: cn('flex shrink-0 items-center gap-2 px-3', compact ? 'h-8' : 'h-10 border-b', (folded || below) && 'border-b-0', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: [layout && (_jsx(Button, { variant: "ghost", size: "icon-xs", className: "lumen-drag -ml-1 cursor-grab touch-none text-muted-foreground active:cursor-grabbing", "aria-label": `Drag ${title}`, children: _jsx(GripVerticalIcon, {}) })), link && !bare && (_jsx(LinkPicker, { color: link.color, onPick: link.onPick, label: title, className: "-mr-1" })), !compact && _jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: cn('truncate', compact ? 'text-xs text-muted-foreground' : 'text-sm font-semibold'), children: title }), link?.color && link.follows && !folded && (_jsxs("span", { className: "min-w-0 shrink truncate text-xs", style: { color: linkVar(link.color) }, title: `Follows ${link.follows}`, children: ["\u00B7 ", link.follows] })), layout && headerOff && (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs(Badge, { variant: "secondary", className: "shrink-0 gap-1 text-muted-foreground", children: [_jsx(EyeOffIcon, {}), " Header off"] }) }), _jsx(TooltipContent, { children: "Shown only in Edit layout. Change it in Edit widget \u25B8 Controls." })] })), !layout && !bare && (_jsx(FoldChevron, { folded: folded, label: title, onToggle: toggle, className: "-ml-1" })), folded && rows != null && (_jsxs("span", { className: "shrink-0 text-xs text-muted-foreground num", children: [rows.toLocaleString(), " ", rows === 1 ? 'row' : 'rows'] })), inline && (_jsx("div", { "data-card-filter": true, className: "flex min-w-40 max-w-xl flex-1 items-center gap-2", children: filter })), note && !layout && !folded && (_jsx("span", { className: "hidden truncate text-xs text-muted-foreground sm:inline", children: note })), _jsxs("span", { className: cn('ml-auto flex shrink-0 items-center gap-1', bare && 'hidden'), children: [!layout && load?.updated && _jsx(LumenFreshness, { load: load }), !layout && !folded && tools, showFilter && mobile && link?.color && !!link.linked && (_jsxs("span", { className: "num flex items-center gap-1 text-xs text-muted-foreground", title: `${link.linked} linked`, children: [_jsx(LinkDot, { color: link.color, className: "size-2" }), link.linked] })), showFilter && mobile && (_jsxs("span", { className: "relative", children: [_jsx(IconButton, { icon: FunnelIcon, label: filterOpen ? 'Hide filter' : 'Filter', "aria-expanded": filterOpen, className: cn(filterOpen && 'text-primary'), onClick: () => setFilterOpen(!filterOpen) }), filterCount > 0 && (_jsx(Badge, { className: "num pointer-events-none absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px]", children: filterCount }))] })), !layout && !folded && help && _jsx(CardHelp, { title: title, children: help }), !layout && load && on('refresh') && (_jsx(LumenRefresh, { load: load, label: `Refresh ${title}` })), (controls?.editable || (!layout && (ask || load?.editScript))) && (_jsxs(DropdownMenu, { modal: false, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(IconButton, { icon: MoreHorizontalIcon, label: `${title} menu` }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-44", children: [ask && !layout && (_jsxs(DropdownMenuItem, { onSelect: ask, children: [_jsx(SparklesIcon, {}), " Ask ", assistantName] })), load?.editScript && !layout && (_jsxs(DropdownMenuItem, { onSelect: load.editScript, children: [_jsx(CodeIcon, {}), " Script"] })), (ask || load?.editScript) && !layout && controls?.editable && (_jsx(DropdownMenuSeparator, {})), controls?.editable && (_jsxs(_Fragment, { children: [!controls.fixed && (_jsxs(_Fragment, { children: [_jsxs(DropdownMenuItem, { onSelect: () => controls.onAction('edit'), children: [_jsx(PencilIcon, {}), " Edit widget"] }), _jsxs(DropdownMenuItem, { disabled: !own, onSelect: () => controls.onAction('duplicate'), children: [_jsx(CopyIcon, {}), " Duplicate"] })] })), _jsxs(DropdownMenuItem, { disabled: controls.first, onSelect: () => controls.onAction('up'), children: [_jsx(ArrowUpIcon, {}), " Move up"] }), _jsxs(DropdownMenuItem, { disabled: controls.last, onSelect: () => controls.onAction('down'), children: [_jsx(ArrowDownIcon, {}), " Move down"] }), controls.onFit && (_jsxs(DropdownMenuItem, { onSelect: controls.onFit, children: [_jsx(FoldVerticalIcon, {}), " Fit height"] })), !controls.fixed && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-muted-foreground focus:text-destructive", onSelect: () => setRemoving(true), children: [_jsx(Trash2Icon, {}), " Remove"] })] }))] }))] })] }))] })] })), below && !headless && (_jsx("div", { "data-card-filter": true, className: cn('flex items-center gap-2 border-b px-3 pb-2', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: filter })), strip && !layout && !folded && !headless ? (_jsx("div", { "data-card-strip": true, className: cn('flex min-w-0 items-center gap-2 border-b px-3 py-1.5 empty:hidden', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: strip })) : null, _jsx("div", { hidden: folded, className: cn('relative min-w-0',
235
+ headless && 'relative', className), children: [!headless && (_jsxs("div", { ref: head, className: cn('flex shrink-0 items-center gap-2 px-3', compact ? 'h-8' : 'h-10 border-b', (folded || below) && 'border-b-0', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: [layout && (_jsx(Button, { variant: "ghost", size: "icon-xs", className: "lumen-drag -ml-1 cursor-grab touch-none text-muted-foreground active:cursor-grabbing", "aria-label": `Drag ${title}`, children: _jsx(GripVerticalIcon, {}) })), link && !bare && (_jsx(LinkPicker, { color: link.color, onPick: link.onPick, label: title, className: "-mr-1" })), !compact && _jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: cn('truncate', compact ? 'text-xs text-muted-foreground' : 'text-sm font-semibold', phoneTitle === false && !layout && 'max-md:hidden'), children: title }), link?.color && link.follows && !folded && (_jsxs("span", { className: "min-w-0 shrink truncate text-xs", style: { color: linkVar(link.color) }, title: `Follows ${link.follows}`, children: ["\u00B7 ", link.follows] })), layout && headerOff && (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs(Badge, { variant: "secondary", className: "shrink-0 gap-1 text-muted-foreground", children: [_jsx(EyeOffIcon, {}), " Header off"] }) }), _jsx(TooltipContent, { children: "Shown only in Edit layout. Change it in Edit widget \u25B8 Controls." })] })), !layout && !bare && (_jsx(FoldChevron, { folded: folded, label: title, onToggle: toggle, className: "-ml-1" })), folded && rows != null && (_jsxs("span", { className: "shrink-0 text-xs text-muted-foreground num", children: [rows.toLocaleString(), " ", rows === 1 ? 'row' : 'rows'] })), inline && (_jsx("div", { "data-card-filter": true, className: "flex min-w-40 max-w-xl flex-1 items-center gap-2", children: filter })), note && !layout && !folded && (_jsx("span", { className: "hidden truncate text-xs text-muted-foreground sm:inline", children: note })), _jsxs("span", { className: cn('ml-auto flex shrink-0 items-center gap-1', bare && 'hidden'), children: [!layout && load?.updated && _jsx(LumenFreshness, { load: load }), !layout && !folded && tools, showFilter && mobile && link?.color && !!link.linked && (_jsxs("span", { className: "num flex items-center gap-1 text-xs text-muted-foreground", title: `${link.linked} linked`, children: [_jsx(LinkDot, { color: link.color, className: "size-2" }), link.linked] })), showFilter && mobile && (_jsxs("span", { className: "relative", children: [_jsx(IconButton, { icon: FunnelIcon, label: filterOpen ? 'Hide filter' : 'Filter', "aria-expanded": filterOpen, className: cn(filterOpen && 'text-primary'), onClick: () => setFilterOpen(!filterOpen) }), filterCount > 0 && (_jsx(Badge, { className: "num pointer-events-none absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px]", children: filterCount }))] })), !layout && !folded && help && _jsx(CardHelp, { title: title, children: help }), !layout && load && on('refresh') && (_jsx(LumenRefresh, { load: load, label: `Refresh ${title}` })), (controls?.editable || (!layout && (ask || load?.editScript))) && (_jsxs(DropdownMenu, { modal: false, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(IconButton, { icon: MoreHorizontalIcon, label: `${title} menu` }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-44", children: [ask && !layout && (_jsxs(DropdownMenuItem, { onSelect: ask, children: [_jsx(SparklesIcon, {}), " Ask ", assistantName] })), load?.editScript && !layout && (_jsxs(DropdownMenuItem, { onSelect: load.editScript, children: [_jsx(CodeIcon, {}), " Script"] })), (ask || load?.editScript) && !layout && controls?.editable && (_jsx(DropdownMenuSeparator, {})), controls?.editable && (_jsxs(_Fragment, { children: [!controls.fixed && (_jsxs(_Fragment, { children: [_jsxs(DropdownMenuItem, { onSelect: () => controls.onAction('edit'), children: [_jsx(PencilIcon, {}), " Edit widget"] }), _jsxs(DropdownMenuItem, { disabled: !own, onSelect: () => controls.onAction('duplicate'), children: [_jsx(CopyIcon, {}), " Duplicate"] })] })), _jsxs(DropdownMenuItem, { disabled: controls.first, onSelect: () => controls.onAction('up'), children: [_jsx(ArrowUpIcon, {}), " Move up"] }), _jsxs(DropdownMenuItem, { disabled: controls.last, onSelect: () => controls.onAction('down'), children: [_jsx(ArrowDownIcon, {}), " Move down"] }), controls.onFit && (_jsxs(DropdownMenuItem, { onSelect: controls.onFit, children: [_jsx(FoldVerticalIcon, {}), " Fit height"] })), !controls.fixed && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-muted-foreground focus:text-destructive", onSelect: () => setRemoving(true), children: [_jsx(Trash2Icon, {}), " Remove"] })] }))] }))] })] }))] })] })), below && !headless && (_jsx("div", { "data-card-filter": true, className: cn('flex items-center gap-2 border-b px-3 pb-2', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: filter })), strip && !layout && !folded && !headless ? (_jsx("div", { "data-card-strip": true, className: cn('flex min-w-0 items-center gap-2 border-b px-3 py-1.5 empty:hidden', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: strip })) : null, _jsx("div", { hidden: folded, className: cn('relative min-w-0',
236
236
  // On the grid a card fills its cell: with a height of its own its body scrolls; one as
237
237
  // tall as it draws keeps its body's height, so the grid can measure it.
238
238
  !cell ? 'flex-1' : cell.fixed && 'min-h-0 flex-1 overflow-auto', pad && !table && !flat && (compact ? 'px-3 pb-3' : 'p-3')), children: _jsx(LumenBoundary, { where: id, children: children }) }), headless && (_jsx(HeaderlessMenu, { title: title, ask: ask, askLabel: `Ask ${assistantName}`, load: load && on('refresh') ? load : undefined, editScript: load?.editScript, filter: showFilter ? filter : undefined, filterCount: filterCount })), !layout && !folded && load && on('footer') && (_jsx("div", { className: cn('mt-auto shrink-0', pinned && 'sticky left-0 w-(--view-w) max-w-full'), children: _jsx(LumenLoadFooter, { load: load, rows: rows, note: loadNote }) })), controls && (_jsx(LumenConfirm, { open: removing, onOpenChange: setRemoving, title: `Remove ${title}?`, description: table ? 'Add widget brings a table back.' : 'Add widget brings it back.', action: "Remove", onConfirm: () => controls.onAction('remove') }))] }) }));
@@ -0,0 +1,10 @@
1
+ import { type RefObject } from 'react';
2
+ /**
3
+ * Which edges of a sideways-scrolling row have more beyond them (`start`, `end`, `both`), for
4
+ * theme.css (`data-fade`) to fade there: a cut-off tab or block reads as "scroll for more", not as a clipped label.
5
+ */
6
+ export declare function useScrollFade(given?: RefObject<HTMLDivElement | null>): {
7
+ ref: RefObject<HTMLDivElement | null>;
8
+ side: "both" | "end" | "start" | undefined;
9
+ update: () => void;
10
+ };
@@ -0,0 +1,32 @@
1
+ // Fades a sideways-scrolling row at the edges with more beyond them.
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ /**
4
+ * Which edges of a sideways-scrolling row have more beyond them (`start`, `end`, `both`), for
5
+ * theme.css (`data-fade`) to fade there: a cut-off tab or block reads as "scroll for more", not as a clipped label.
6
+ */
7
+ export function useScrollFade(given) {
8
+ const own = useRef(null);
9
+ const ref = given ?? own;
10
+ const [side, setSide] = useState();
11
+ const update = useCallback(() => {
12
+ const el = ref.current;
13
+ if (!el)
14
+ return;
15
+ const start = el.scrollLeft > 1;
16
+ const end = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
17
+ setSide(start && end ? 'both' : start ? 'start' : end ? 'end' : undefined);
18
+ }, [ref]);
19
+ useEffect(() => {
20
+ const el = ref.current;
21
+ if (!el)
22
+ return;
23
+ update();
24
+ // The row's content can grow while the row keeps its size (a new page of wider rows).
25
+ const seen = new ResizeObserver(update);
26
+ seen.observe(el);
27
+ if (el.firstElementChild)
28
+ seen.observe(el.firstElementChild);
29
+ return () => seen.disconnect();
30
+ }, [ref, update]);
31
+ return { ref, side, update };
32
+ }
package/dist/shell.js CHANGED
@@ -143,5 +143,5 @@ export function LumenShell({ app, appLabel, brand, nav, status, health, statusEn
143
143
  }
144
144
  /** A status bar entry: an optional health dot, a label and its value ("Mail · Up to date"). */
145
145
  export function StatusItem({ label, value, ok, className, }) {
146
- return (_jsxs("span", { className: cn('flex items-center gap-1.5', value === undefined && 'min-w-0', className), children: [ok !== undefined && (_jsx("span", { "aria-hidden": true, className: cn('size-2 shrink-0 rounded-full', ok === null ? 'bg-muted-foreground/40' : ok ? 'bg-gain' : 'bg-destructive') })), _jsx("span", { className: value === undefined ? 'min-w-0 truncate' : 'max-md:hidden', children: label }), value !== undefined && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, className: "max-md:hidden", children: "\u00B7" }), _jsx("span", { className: "shrink-0 tabular-nums text-foreground/80", children: value })] }))] }));
146
+ return (_jsxs("span", { className: cn('flex items-center gap-1.5', value === undefined && 'min-w-0', className), children: [ok !== undefined && (_jsx("span", { "aria-hidden": true, className: cn('size-2 shrink-0 rounded-full', ok === null ? 'bg-muted-foreground/40' : ok ? 'bg-gain' : 'bg-destructive') })), _jsx("span", { className: value === undefined ? 'min-w-0 truncate' : 'shrink-0', children: label }), value !== undefined && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, children: "\u00B7" }), _jsx("span", { className: "shrink-0 tabular-nums text-foreground/80", children: value })] }))] }));
147
147
  }
package/dist/theme.css CHANGED
@@ -414,6 +414,30 @@
414
414
  left: 100%;
415
415
  }
416
416
 
417
+ /* A row that scrolls sideways (LumenTabs, a view's page) fades at an edge with more beyond it.
418
+ `--fade-inset` keeps a vertical scrollbar at the end edge out of the fade. */
419
+ [data-fade="end"] {
420
+ mask-image: linear-gradient(
421
+ to left,
422
+ #000 var(--fade-inset, 0px),
423
+ transparent var(--fade-inset, 0px),
424
+ #000 calc(var(--fade-inset, 0px) + 2rem)
425
+ );
426
+ }
427
+ [data-fade="start"] {
428
+ mask-image: linear-gradient(to left, #000 calc(100% - 2rem), transparent);
429
+ }
430
+ [data-fade="both"] {
431
+ mask-image: linear-gradient(
432
+ to left,
433
+ #000 var(--fade-inset, 0px),
434
+ transparent var(--fade-inset, 0px),
435
+ #000 calc(var(--fade-inset, 0px) + 2rem),
436
+ #000 calc(100% - 2rem),
437
+ transparent
438
+ );
439
+ }
440
+
417
441
  /* A LumenView table on a narrow phone (`data-cards`): each row is a card, so nothing runs off
418
442
  the right edge. Line 1: the row's name (shortens) and one figure (`data-card="meta"`); line 2:
419
443
  the other values, small and muted. Numbers, dates and their units keep their width; only text
@@ -486,7 +510,17 @@
486
510
  }
487
511
  table[data-cards] [data-slot="unit-value-unit"] {
488
512
  position: static;
513
+ width: auto;
514
+ padding-left: 0;
489
515
  margin-left: 0.25em;
516
+ font-size: inherit;
517
+ }
518
+ /* A bare count names what it counts (`cardUnit`): no header says it on a card. */
519
+ table[data-cards] td[data-card-unit]::after {
520
+ content: attr(data-card-unit);
521
+ margin-left: 0.25em;
522
+ color: var(--muted-foreground);
523
+ font-weight: 400;
490
524
  }
491
525
  }
492
526
 
@@ -139,6 +139,10 @@ export interface ViewField<T> {
139
139
  maxWidth?: string | false;
140
140
  /** Left out on phones, where the table has no room for it (a returns table's longer windows). */
141
141
  hideOnPhone?: boolean;
142
+ /** Left out of a table when no row in it has a value (none, empty or 0): an Attachments column on mail with none. */
143
+ hideEmpty?: boolean;
144
+ /** On a phone card, where no header names it: the word after a bare count (`msgs`, so `5,861 msgs`). */
145
+ cardUnit?: string;
142
146
  /**
143
147
  * How the value updates (live domains): `live` moves with the market while it
144
148
  * trades (a quote), `asof` is a daily or snapshot value (a close, a fact, the assistant frame's column).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.107",
3
+ "version": "0.1.108",
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.107",
52
+ "@columenlabs/lumen-ui-types": "0.1.108",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",