@columenlabs/lumen-ui 0.1.53 → 0.1.55

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/README.md CHANGED
@@ -75,6 +75,8 @@ Every export, its props and an example are on the [docs](https://lumen.columenla
75
75
  - Every card body sits in `LumenBoundary`, so one crash never blanks a page.
76
76
  - Numbers use tabular figures and a true minus `−`; a gain or loss always shows a sign or arrow.
77
77
  - A page's header numbers are metric cards: `TileRow` of `Tile` (or `TileToggleGroup` when they pick what the page shows), with `fill` so they share the row, each with `spark` set to its daily history where it has one. Don't build a card of your own for this.
78
+ - Column widths: the first column (a row's name) grows only to `--lead-col-max` (18rem, theme.css); past it the cell truncates on one line and a plain text value shows in full on hover (a rendered cell brings its own hover card). Cap any other column with the field's `maxWidth` (`'12rem'`), or lift the cap with `maxWidth: false`. In a rendered name cell, put the name in a `truncate` span so the ellipsis lands on it, not on a chip after it.
79
+ - Column alignment is the table's, not the cell's. Text left, numbers right, and a number column has one right edge for its header, values, dashes and Total. `UnitValue` puts the unit in a fixed slot after the number (`--unit-slot`, 2rem) and its status dot just left of the number. In a LumenView table the first `UnitValue` drawn in a column marks it (`data-units` on every cell of that column, through the `UnitColumns` context and the cell's `data-col`); theme.css then widens that column's right gutter by the slot and hangs each unit in it. So a plain `—`, `Free` or `$0.21` an app draws in the same column sits on the digits' edge, and the header sits over the digits. Don't hand-place units, dots or padding to line a column up; if a column is off, draw its numbers through `UnitValue`. Outside a LumenView table (a hover card, a tile) `UnitValue` keeps its slot inline.
78
80
  - A metered column (use against an allowance) shows one number per cell with `UnitValue` (right-aligned number, fixed muted unit slot, status dot), never `value [pct]`. Offer both readings through the field's `showAs` (Amount / % of allowance, picked per column in Edit view, saved as `shows`); default amounts for sizes (storage) and shares for capped counts. Put the other reading, the allowance and the cost formula in a hover card. Totals use `rollRender`: sum amounts with their unit; a share is the rows' total over their allowance, never a sum of percents.
79
81
  - A floating bulk-action bar over a table's selection: Apply clears the selection and the bar goes away; the toast's Undo restores the change, not the selection. Cancel keeps the selection.
80
82
  - Bars in a column line up: use `ShareMeter` (fixed track, fixed right-aligned number), never a bar sized by its label.
@@ -86,6 +86,11 @@ export interface ServerGroups {
86
86
  list: ServerGroup[] | null;
87
87
  /** Groups past those listed: a More groups row asks for them. */
88
88
  more?: () => void;
89
+ /** The groups come a page at a time instead: the header's pager turns the page (no More
90
+ * groups row), and `list` is the page shown. */
91
+ pages?: Omit<ServerPaging, 'sorts' | 'onSort' | 'groups'> & {
92
+ noun?: string;
93
+ };
89
94
  /** A group header's click: open or close it. */
90
95
  onToggle: (key: string) => void;
91
96
  /** Expand all (true) or Collapse all (false): open or close every group listed. */
@@ -42,6 +42,7 @@ import { Skeleton } from './ui/skeleton.js';
42
42
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table.js';
43
43
  import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
44
44
  import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
45
+ import { UnitColumns } from './unit-value.js';
45
46
  import { EditCell, EditInputCell, EditManyCell } from './view-edit-cell.js';
46
47
  import { ViewEditDialog } from './view-editor.js';
47
48
  import { fieldOptions } from './view-formulas.js';
@@ -289,7 +290,7 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
289
290
  const compare = openChart && picked.length > 1 ? (_jsx(IconButton, { icon: ChartSpline, label: `Compare ${picked.join(', ')}`, variant: "outline", onClick: () => openChart(picked) })) : null;
290
291
  // A server's page: the pager counts from the server's total, or the rows seen so far. Server
291
292
  // groups page themselves: the header counts them.
292
- const pager = served ? (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: served.list ? `${served.list.length.toLocaleString()}${served.more ? '+' : ''} groups` : '' })) : server ? (_jsx(LumenPager, { total: server.total ?? server.page * server.size + rows.length, page: server.page, size: server.size, onPage: server.onPage, onSize: server.onSize ?? (() => { }), sizes: server.sizes ?? [server.size], more: server.total === null && server.more })) : paging ? (_jsx(LumenPager, { total: tableRows.length, page: page, size: pageSize, onPage: setPageIndex, onSize: setPageSize, noun: rows.length === total ? 'rows' : `of ${total} rows` })) : (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: count }));
293
+ const pager = served?.pages ? (_jsx(LumenPager, { total: served.pages.total ?? served.pages.page * served.pages.size + (served.list?.length ?? 0), page: served.pages.page, size: served.pages.size, onPage: served.pages.onPage, onSize: served.pages.onSize ?? (() => { }), sizes: served.pages.sizes ?? [served.pages.size], noun: served.pages.noun ?? 'groups', more: served.pages.total === null && served.pages.more })) : served ? (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: served.list ? `${served.list.length.toLocaleString()}${served.more ? '+' : ''} groups` : '' })) : server ? (_jsx(LumenPager, { total: server.total ?? server.page * server.size + rows.length, page: server.page, size: server.size, onPage: server.onPage, onSize: server.onSize ?? (() => { }), sizes: server.sizes ?? [server.size], more: server.total === null && server.more })) : paging ? (_jsx(LumenPager, { total: tableRows.length, page: page, size: pageSize, onPage: setPageIndex, onSize: setPageSize, noun: rows.length === total ? 'rows' : `of ${total} rows` })) : (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: count }));
293
294
  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: onSort, serverSorts: server ? (server.sorts ?? []) : undefined, serverGroups: served, total: config.total !== false, add: onAddColumn || onColumns
294
295
  ? { ...onAddColumn, field: onColumns && { domain, columns, onColumns } }
295
296
  : undefined }));
@@ -343,6 +344,27 @@ const alignOf = (format) => (format === 'text' ? 'text-left' : 'text-right');
343
344
  /** An ISO date, alone or with a time after it: `2026-10-14`, `2026-10-14 16:17`. */
344
345
  const isDateText = (v) => typeof v === 'string' && v.length <= 25 && /^\d{4}-\d{2}-\d{2}(?:$|[ T])/.test(v);
345
346
  const colOf = (f) => cn(alignOf(f?.format ?? 'text'), f?.hideOnPhone && 'max-md:hidden');
347
+ /**
348
+ * A column's cell props: its classes, its key (`data-col`), and `data-units` once a `UnitValue`
349
+ * has drawn in it. Every cell of such a column (header, dash, Total) then right-aligns on the
350
+ * numbers' edge while the units hang in the gutter (theme.css), so one column has one edge.
351
+ */
352
+ const colPropsOf = (units) => (f, ...more) => ({
353
+ className: cn(colOf(f), ...more),
354
+ 'data-col': f?.key,
355
+ 'data-units': f && units.has(f.key) ? '' : undefined,
356
+ });
357
+ /**
358
+ * A column's widest cell (`maxWidth`): the first column defaults to `--lead-col-max` (18rem in
359
+ * theme.css), so one long name never pushes the other columns off screen; `false` lifts it.
360
+ */
361
+ const capOf = (f, i) => f.maxWidth === false ? undefined : (f.maxWidth ?? (i === 0 ? 'var(--lead-col-max)' : undefined));
362
+ /** Holds a cell to its column's cap on one line: text past it ends in an ellipsis. */
363
+ function CappedCell({ max, title, children, }) {
364
+ if (!max)
365
+ return children;
366
+ return (_jsx("div", { className: "flex min-w-0 *:min-w-0 *:max-w-full *:truncate", style: { maxWidth: max }, title: title, children: children }));
367
+ }
346
368
  /** The flat or grouped table: sortable headers, a Total row, group rows that collapse. */
347
369
  // Sorting, and paging of flat tables (the page is LumenView's, set right of the row count).
348
370
  const SORTING = tableFeatures({
@@ -384,6 +406,10 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
384
406
  pageSize: paging ? page.size : Math.max(rows.length, 1),
385
407
  };
386
408
  // Groups whose state differs from where they start: open, or past OPEN_ALL rows only the first.
409
+ // Columns a UnitValue has drawn in (`UnitColumns`): their cells share the numbers' edge.
410
+ const [units, setUnits] = useState(() => new Set());
411
+ const markUnits = useCallback((key) => setUnits((s) => (s.has(key) ? s : new Set(s).add(key))), []);
412
+ const col = colPropsOf(units);
387
413
  const [flipped, setFlipped] = useState(new Set());
388
414
  const [more, setMore] = useState(new Map());
389
415
  // The grouped field is the group row's label, so its column hides.
@@ -475,12 +501,13 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
475
501
  const { shown: now, cellOf, inline, chips, computed } = latest.current;
476
502
  const field = now[i] ?? f;
477
503
  const chip = chips.some((c) => c.key === field.key);
478
- return i === 0 && inline.length ? (_jsx(FirstCell, { value: cellOf(field, row.original, chip), chips: inline.flatMap((x) => {
479
- const v = computed.value(x, row.original);
480
- if (!x.edit && (v === null || v === ''))
481
- return [];
482
- return [{ key: x.key, node: cellOf(x, row.original, true) }];
483
- }) })) : (cellOf(field, row.original, chip));
504
+ const v = computed.value(field, row.original);
505
+ return (_jsx(CappedCell, { max: capOf(field, i), title: !field.render && typeof v === 'string' ? v : undefined, children: i === 0 && inline.length ? (_jsx(FirstCell, { value: cellOf(field, row.original, chip), chips: inline.flatMap((x) => {
506
+ const v = computed.value(x, row.original);
507
+ if (!x.edit && (v === null || v === ''))
508
+ return [];
509
+ return [{ key: x.key, node: cellOf(x, row.original, true) }];
510
+ }) })) : (cellOf(field, row.original, chip)) }));
484
511
  },
485
512
  })), [shownKeys, inlineOn, serverSorts?.join(',')]);
486
513
  const table = useTable({
@@ -560,7 +587,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
560
587
  // drawn; spanning the hidden ones too pushed the group's totals right of their headers.
561
588
  const leadSpan = Math.max(1, lead - (isMobile ? shown.slice(0, lead).filter((f) => f.hideOnPhone).length : 0));
562
589
  const rollCell = (f, members) => rollsUp(f) && f.rollRender ? (f.rollRender(members)) : rollsUp(f) ? (_jsx(ViewValueCell, { value: computed.roll(f, members), format: f.aggregate && isCountAgg(f.aggregate) ? 'rounded' : f.format, amount: f.aggregate !== 'count' && f.amount, compact: f.compact })) : null;
563
- const rollCells = (members) => shown.map((f) => (_jsx(TableCell, { className: colOf(f), children: rollCell(f, members) }, f.key)));
590
+ const rollCells = (members) => shown.map((f) => (_jsx(TableCell, { ...col(f), children: rollCell(f, members) }, f.key)));
564
591
  // A row's chevron opens one level under it, closed by default: its panel, else the view's
565
592
  // sub-group ("Each row opens"; None opens nothing).
566
593
  const partsOf = (row) => (domain.panel ? [] : (subgroup?.rows?.(row) ?? []));
@@ -577,9 +604,9 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
577
604
  return n;
578
605
  });
579
606
  }, children: _jsx(ChevronRightIcon, { className: cn('transition-transform', openParts.has(k) && 'rotate-90') }) }));
580
- const partRow = (p, name) => (_jsxs(TableRow, { className: cn('bg-muted/30 text-muted-foreground hover:bg-muted/40', onDetails && 'cursor-pointer'), onClick: onDetails ? () => onDetails(p) : undefined, children: [selection && _jsx(TableCell, {}), shown.map((f, i) => (_jsx(TableCell, { className: cn(colOf(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" }), name
581
- ? name(p)
582
- : 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)}`));
607
+ const partRow = (p, name) => (_jsxs(TableRow, { className: cn('bg-muted/30 text-muted-foreground hover:bg-muted/40', 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
608
+ ? name(p)
609
+ : 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)}`));
583
610
  // The whole row opens the details drawer.
584
611
  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
585
612
  ? (e) => {
@@ -590,14 +617,14 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
590
617
  onDetails(r.original);
591
618
  }
592
619
  }
593
- : undefined, children: [selectCell(keyOf(r.original), keyOf(r.original)), r.getAllCells().map((c, i) => (_jsx(TableCell, { className: cn(colOf(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())] })) : (flexRender(c.column.columnDef.cell, c.getContext())) }, c.id)))] }, domain.rowKey(r.original)), openParts.has(keyOf(r.original)) &&
620
+ : 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())] })) : (flexRender(c.column.columnDef.cell, c.getContext())) }, c.id)))] }, domain.rowKey(r.original)), openParts.has(keyOf(r.original)) &&
594
621
  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)));
595
622
  // Rows sharing the sub-group's `under` key fold into one rolled-up line that opens them; a key
596
623
  // with one row keeps that row (NVDA opens Shares, then each option).
597
624
  const foldRow = (key, members, scope) => {
598
625
  const k = `under:${scope}\u0000${key}`;
599
626
  const ms = members.map((m) => m.original);
600
- return (_jsxs(Fragment, { children: [_jsxs(TableRow, { "data-row": k, children: [selection && _jsx(TableCell, {}), shown.map((f, i) => (_jsx(TableCell, { className: cn(colOf(f), groupBy && i === 0 && 'pl-8'), children: i === 0 ? (_jsxs("span", { className: "flex items-center gap-1", children: [chevron(k, `${key} ${openLabel()}`), _jsx("span", { className: "font-medium", children: key }), _jsx("span", { className: "num text-xs text-muted-foreground", children: ms.length })] })) : (rollCell(f, ms)) }, f.key)))] }), openParts.has(k) && ms.map((p) => partRow(p, subgroup?.name))] }, k));
627
+ return (_jsxs(Fragment, { children: [_jsxs(TableRow, { "data-row": k, 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()}`), _jsx("span", { className: "font-medium", children: key }), _jsx("span", { className: "num text-xs text-muted-foreground", children: ms.length })] })) : (rollCell(f, ms)) }, f.key)))] }), openParts.has(k) && ms.map((p) => partRow(p, subgroup?.name))] }, k));
601
628
  };
602
629
  const rowsOf = (list, scope) => {
603
630
  const under = folds ? subgroup?.under : undefined;
@@ -644,32 +671,32 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
644
671
  };
645
672
  const box = useRef(null);
646
673
  useFloatingHead(box, !inView);
647
- return (_jsx("div", { ref: box, className: cn(inView ? IN_VIEW : 'overflow-x-auto overflow-y-hidden [--pin-top:0px]', fit && FIT), children: _jsxs(Table, { children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((hg) => (_jsxs(TableRow, { children: [selection && (_jsx(TableHead, { className: cn('w-8 pr-0', PINNED_HEAD), children: _jsx(Checkbox, { checked: allOn ? true : someOn ? 'indeterminate' : false, onCheckedChange: (on) => toggle(allKeys, on === true), "aria-label": "Select every row" }) })), hg.headers.map((h, i) => (_jsxs(TableHead, { className: cn(colOf(shown[i]), PINNED_HEAD), style: ruleOf(colorOf(domain, shown[i])), onPointerDown: pressStart, onPointerUp: pressEnd, onPointerLeave: pressEnd, onPointerCancel: pressEnd, onContextMenu: (e) => {
648
- if (press.current)
649
- e.preventDefault();
650
- }, "aria-sort": h.column.getIsSorted() === 'asc'
651
- ? 'ascending'
652
- : h.column.getIsSorted() === 'desc'
653
- ? 'descending'
654
- : undefined, children: [i === 0 && groups && (!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
655
- ? groups.map(([key, members], gi) => {
656
- const open = isOpen(key, gi);
657
- return (_jsxs(Fragment, { children: [_jsxs(TableRow, { className: ROLLUP, "data-group": key, children: [selection && (_jsx(TableCell, { className: cn('w-8 pr-0', PINNED_GROUP), children: _jsx(Checkbox, { checked: members.every((m) => selection.keys.has(keyOf(m.original)))
658
- ? true
659
- : members.some((m) => selection.keys.has(keyOf(m.original)))
660
- ? 'indeterminate'
661
- : false, onCheckedChange: (on) => toggle(members.map((m) => keyOf(m.original)), on === true), "aria-label": `Select every row of ${key}` }) })), shown.slice(lead - 1).map((f, i) => i === 0 ? (_jsxs(TableCell, { colSpan: leadSpan, className: PINNED_GROUP, children: [_jsxs(Button, { variant: "ghost", size: "xs", className: "-mx-2 hover:bg-transparent aria-expanded:bg-transparent", "aria-expanded": open, "aria-label": `${open ? 'Collapse' : 'Expand'} ${key}`, onClick: () => serverGroups
662
- ? serverGroups.onToggle(key)
663
- : setFlipped((c) => {
664
- const n = new Set(c);
665
- n.has(key) ? n.delete(key) : n.add(key);
666
- return n;
667
- }), children: [_jsx(ChevronRightIcon, { className: cn('transition-transform', open && 'rotate-90') }), _jsx(groupBy.icon, {}), key, _jsx("span", { className: "num font-normal text-muted-foreground", children: serverGroups
668
- ? (served.get(key)?.n?.toLocaleString() ?? '')
669
- : members.length })] }), groupActions?.(key, members.map((m) => m.original))] }, f.key)) : (_jsx(TableCell, { className: cn(colOf(f), PINNED_GROUP), children: !serverGroups &&
670
- rollCell(f, members.map((m) => m.original)) }, f.key)))] }), open && (serverGroups ? servedRows(members, key) : drawn(members, key))] }, key));
671
- })
672
- : drawn(sorted, ''), serverGroups?.more && (_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))] }))] })] }) }));
674
+ return (_jsx("div", { ref: box, className: cn(inView ? IN_VIEW : 'overflow-x-auto overflow-y-hidden [--pin-top:0px]', fit && FIT), children: _jsx(UnitColumns.Provider, { value: markUnits, children: _jsxs(Table, { children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((hg) => (_jsxs(TableRow, { children: [selection && (_jsx(TableHead, { className: cn('w-8 pr-0', PINNED_HEAD), children: _jsx(Checkbox, { checked: allOn ? true : someOn ? 'indeterminate' : false, onCheckedChange: (on) => toggle(allKeys, on === true), "aria-label": "Select every row" }) })), hg.headers.map((h, i) => (_jsxs(TableHead, { ...col(shown[i], PINNED_HEAD), style: ruleOf(colorOf(domain, shown[i])), onPointerDown: pressStart, onPointerUp: pressEnd, onPointerLeave: pressEnd, onPointerCancel: pressEnd, onContextMenu: (e) => {
675
+ if (press.current)
676
+ e.preventDefault();
677
+ }, "aria-sort": h.column.getIsSorted() === 'asc'
678
+ ? 'ascending'
679
+ : h.column.getIsSorted() === 'desc'
680
+ ? 'descending'
681
+ : undefined, children: [i === 0 && groups && (!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
682
+ ? groups.map(([key, members], gi) => {
683
+ const open = isOpen(key, gi);
684
+ return (_jsxs(Fragment, { children: [_jsxs(TableRow, { className: ROLLUP, "data-group": key, children: [selection && (_jsx(TableCell, { className: cn('w-8 pr-0', PINNED_GROUP), children: _jsx(Checkbox, { checked: members.every((m) => selection.keys.has(keyOf(m.original)))
685
+ ? true
686
+ : members.some((m) => selection.keys.has(keyOf(m.original)))
687
+ ? 'indeterminate'
688
+ : false, onCheckedChange: (on) => toggle(members.map((m) => keyOf(m.original)), on === true), "aria-label": `Select every row of ${key}` }) })), shown.slice(lead - 1).map((f, i) => i === 0 ? (_jsxs(TableCell, { colSpan: leadSpan, className: PINNED_GROUP, children: [_jsxs(Button, { variant: "ghost", size: "xs", className: "-mx-2 hover:bg-transparent aria-expanded:bg-transparent", "aria-expanded": open, "aria-label": `${open ? 'Collapse' : 'Expand'} ${key}`, onClick: () => serverGroups
689
+ ? serverGroups.onToggle(key)
690
+ : setFlipped((c) => {
691
+ const n = new Set(c);
692
+ n.has(key) ? n.delete(key) : n.add(key);
693
+ return n;
694
+ }), children: [_jsx(ChevronRightIcon, { className: cn('transition-transform', open && 'rotate-90') }), _jsx(groupBy.icon, {}), key, _jsx("span", { className: "num font-normal text-muted-foreground", children: serverGroups
695
+ ? (served.get(key)?.n?.toLocaleString() ?? '')
696
+ : members.length })] }), groupActions?.(key, members.map((m) => m.original))] }, f.key)) : (_jsx(TableCell, { ...col(f, PINNED_GROUP), children: !serverGroups &&
697
+ rollCell(f, members.map((m) => m.original)) }, f.key)))] }), open && (serverGroups ? servedRows(members, key) : drawn(members, key))] }, key));
698
+ })
699
+ : 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))] }))] })] }) }) }));
673
700
  }
674
701
  /** One value rolled up into a rows × columns grid, with totals where the roll-up adds. */
675
702
  function PivotTable({ domain, rows, pivot, }) {
package/dist/pager.js CHANGED
@@ -9,6 +9,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenu
9
9
  export function LumenPager({ total, page, size, onPage, onSize, noun = 'rows', sizes = viewPageSizes, more = false, }) {
10
10
  const pages = Math.max(1, Math.ceil(total / size)) + (more ? 1 : 0);
11
11
  return (_jsxs("nav", { "aria-label": "Pages", className: "flex items-center", children: [_jsxs(DropdownMenu, { modal: false, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "sm", className: "num h-7 px-2 text-xs font-normal text-muted-foreground", "aria-label": "Rows per page", children: pages > 1
12
- ? `${page * size + 1}–${Math.min((page + 1) * size, total)} of ${total}${more ? '+' : ''}`
13
- : `${total} ${noun}` }) }), _jsx(DropdownMenuContent, { align: "end", className: "w-40", children: _jsx(DropdownMenuRadioGroup, { value: String(size), onValueChange: (v) => onSize(Number(v)), children: sizes.map((n) => (_jsxs(DropdownMenuRadioItem, { value: String(n), className: "num whitespace-nowrap", children: [n, " per page"] }, n))) }) })] }), pages > 1 && (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: ChevronLeftIcon, label: "Previous page", disabled: page === 0, onClick: () => onPage(page - 1) }), _jsx(IconButton, { icon: ChevronRightIcon, label: "Next page", disabled: page >= pages - 1, onClick: () => onPage(page + 1) })] }))] }));
12
+ ? `${(page * size + 1).toLocaleString()}–${Math.min((page + 1) * size, total).toLocaleString()} of ${total.toLocaleString()}${more ? '+' : ''}`
13
+ : `${total.toLocaleString()} ${noun}` }) }), _jsx(DropdownMenuContent, { align: "end", className: "w-40", children: _jsx(DropdownMenuRadioGroup, { value: String(size), onValueChange: (v) => onSize(Number(v)), children: sizes.map((n) => (_jsxs(DropdownMenuRadioItem, { value: String(n), className: "num whitespace-nowrap", children: [n, " per page"] }, n))) }) })] }), pages > 1 && (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: ChevronLeftIcon, label: "Previous page", disabled: page === 0, onClick: () => onPage(page - 1) }), _jsx(IconButton, { icon: ChevronRightIcon, label: "Next page", disabled: page >= pages - 1, onClick: () => onPage(page + 1) })] }))] }));
14
14
  }
package/dist/theme.css CHANGED
@@ -95,6 +95,10 @@
95
95
  --row-h: 28px;
96
96
  --cell-x: 8px;
97
97
  --cell-y: 4px;
98
+ /* A UnitValue's unit slot (`GB`, `%`, `/day`), hung in the gutter of a units column. */
99
+ --unit-slot: 2rem;
100
+ /* A table's first (name) column stops growing here; longer names end in an ellipsis. */
101
+ --lead-col-max: 18rem;
98
102
 
99
103
  --background: #f4f6fa;
100
104
  --foreground: #121826;
@@ -388,6 +392,20 @@
388
392
  }
389
393
  }
390
394
 
395
+ /* A table column a UnitValue drew in (`data-units`, set by LumenView): every cell, header and
396
+ Total included, right-aligns on the numbers' edge; each UnitValue's unit slot hangs in the
397
+ widened gutter (out of flow, so no wrapper an app puts round it can shift it). One column, one edge, whatever an app's render draws beside it. */
398
+ [data-units] {
399
+ padding-right: calc(var(--cell-x) + var(--unit-slot));
400
+ }
401
+ [data-units] [data-slot="unit-value"] {
402
+ position: relative;
403
+ }
404
+ [data-units] [data-slot="unit-value-unit"] {
405
+ position: absolute;
406
+ left: 100%;
407
+ }
408
+
391
409
  @layer utilities {
392
410
  .bg-gap-hatch {
393
411
  background: repeating-linear-gradient(135deg, var(--gap) 0 3px, var(--gap-soft) 3px 6px);
@@ -1,5 +1,7 @@
1
1
  import type { MeterLevels } from '@columenlabs/lumen-ui-types/views';
2
- import type { ReactNode } from 'react';
2
+ import { type ReactNode } from 'react';
3
+ /** Set by a table: marks the column (its cell's `data-col`) a UnitValue draws in. */
4
+ export declare const UnitColumns: import("react").Context<((col: string) => void) | null>;
3
5
  export declare function UnitValue({ value, unit, tone: toneProp, share, levels, title, className, }: {
4
6
  /** The number, already formatted (`231.4`). */
5
7
  value: ReactNode;
@@ -1,7 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useContext, useLayoutEffect, useRef } from 'react';
2
3
  import { cn } from './lib/utils.js';
3
4
  import { meterValueTone } from './meter-level.js';
5
+ /** Set by a table: marks the column (its cell's `data-col`) a UnitValue draws in. */
6
+ export const UnitColumns = createContext(null);
4
7
  export function UnitValue({ value, unit, tone: toneProp, share, levels, title, className, }) {
5
8
  const tone = toneProp ?? (share === undefined ? undefined : meterValueTone(share, levels));
6
- return (_jsxs("span", { title: title, className: cn('inline-flex w-full items-center justify-end gap-1 whitespace-nowrap', className), children: [_jsx("span", { className: cn('num text-right', tone === 'warning' && 'text-warning', tone === 'loss' && 'text-loss'), children: value }), _jsx("span", { className: "w-10 shrink-0 text-left text-muted-foreground text-xs", children: unit }), _jsx("span", { "aria-hidden": "true", className: cn('size-1.5 shrink-0 rounded-full', tone === 'warning' ? 'bg-warning' : tone === 'loss' ? 'bg-loss' : 'bg-transparent') })] }));
9
+ const mark = useContext(UnitColumns);
10
+ const ref = useRef(null);
11
+ // Before paint, so the column never draws on two edges, even for a frame.
12
+ useLayoutEffect(() => {
13
+ const col = ref.current?.closest('[data-col]')?.getAttribute('data-col');
14
+ if (mark && col)
15
+ mark(col);
16
+ }, [mark]);
17
+ return (_jsxs("span", { ref: ref, "data-slot": "unit-value", title: title, className: cn('inline-flex w-full items-center justify-end whitespace-nowrap', className), children: [_jsxs("span", { className: cn('num relative text-right', tone === 'warning' && 'text-warning', tone === 'loss' && 'text-loss'), children: [tone && (_jsx("span", { "aria-hidden": "true", className: cn('-translate-y-1/2 absolute top-1/2 right-full mr-1 size-1.5 rounded-full', tone === 'warning' ? 'bg-warning' : 'bg-loss') })), value] }), _jsx("span", { "data-slot": "unit-value-unit", className: "w-(--unit-slot) shrink-0 pl-1 text-left text-muted-foreground text-xs", children: unit })] }));
7
18
  }
@@ -129,6 +129,12 @@ export interface ViewField<T> {
129
129
  }> | undefined;
130
130
  /** The cell's background, a theme colour mix (a P/L heat grid). */
131
131
  tint?: (row: T) => string | undefined;
132
+ /**
133
+ * The column's widest cell, a CSS length (`14rem`): past it the cell ends in an ellipsis on one
134
+ * line, the full text in its hover. The first column defaults to `--lead-col-max` (18rem);
135
+ * `false` lets it grow.
136
+ */
137
+ maxWidth?: string | false;
132
138
  /** Left out on phones, where the table has no room for it (a returns table's longer windows). */
133
139
  hideOnPhone?: boolean;
134
140
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,7 +41,7 @@
41
41
  "@codemirror/lang-javascript": "6.2.5",
42
42
  "@codemirror/lang-json": "6.0.2",
43
43
  "@codemirror/lang-sql": "6.10.0",
44
- "@columenlabs/lumen-ui-types": "0.1.53",
44
+ "@columenlabs/lumen-ui-types": "0.1.55",
45
45
  "@dnd-kit/core": "6.3.1",
46
46
  "@dnd-kit/sortable": "10.0.0",
47
47
  "@dnd-kit/utilities": "3.2.2",