@columenlabs/lumen-ui 0.1.169 → 0.1.171

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,28 @@
1
+ /** The narrowest and widest a person can drag a column (px). */
2
+ export declare const COL_MIN = 48;
3
+ export declare const COL_MAX = 960;
4
+ /**
5
+ * The widths a person set on one table's columns, by field key, kept on the device under `key`.
6
+ * Left out `key`: no widths (resizing off). `set` updates while dragging; `save` sets and keeps.
7
+ */
8
+ export declare function useColumnWidths(key: string | undefined): {
9
+ widths: Readonly<Record<string, number>>;
10
+ set: (col: string, w: number) => void;
11
+ save: (col: string, w: number) => void;
12
+ reset: (col: string) => void;
13
+ };
14
+ /**
15
+ * The grip on a header cell's right edge. It measures its header cell when a drag starts, so a
16
+ * column that never had a width set starts from where it is drawn.
17
+ */
18
+ export declare function ColumnResizer({ label, width, onWidth, onDone, onReset, }: {
19
+ /** The column's name, for the grip's label ("Resize Name"). */
20
+ label: string;
21
+ /** The width set, if any. */
22
+ width: number | undefined;
23
+ onWidth: (w: number) => void;
24
+ /** The drag or key press ended: keep this width. */
25
+ onDone: (w: number) => void;
26
+ /** Back to the column's own width. */
27
+ onReset: () => void;
28
+ }): import("react").JSX.Element;
@@ -0,0 +1,134 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // Resizable table columns: a grip on each header's right edge sets that column's width by drag
3
+ // (or ← → on the focused grip), a double click (or Enter) gives it back its own width, and the
4
+ // widths are kept on the device per table. A table turns it on with `resizable`, an app for every
5
+ // table with `configureLumen({ resizableColumns: true })`. Desktop only: a phone's rows are cards.
6
+ import { useCallback, useEffect, useRef, useState } from 'react';
7
+ import { cn } from './lib/utils.js';
8
+ /** The narrowest and widest a person can drag a column (px). */
9
+ export const COL_MIN = 48;
10
+ export const COL_MAX = 960;
11
+ /** How far ← or → moves a focused grip (px). */
12
+ const STEP = 16;
13
+ const PREFIX = 'lumen:col-widths:';
14
+ const clamp = (w) => Math.round(Math.min(COL_MAX, Math.max(COL_MIN, w)));
15
+ function read(key) {
16
+ try {
17
+ const raw = window.localStorage.getItem(PREFIX + key);
18
+ const v = raw ? JSON.parse(raw) : null;
19
+ if (!v || typeof v !== 'object')
20
+ return {};
21
+ const out = {};
22
+ for (const [k, w] of Object.entries(v))
23
+ if (typeof w === 'number' && w > 0)
24
+ out[k] = clamp(w);
25
+ return out;
26
+ }
27
+ catch {
28
+ return {};
29
+ }
30
+ }
31
+ function write(key, widths) {
32
+ try {
33
+ if (Object.keys(widths).length)
34
+ window.localStorage.setItem(PREFIX + key, JSON.stringify(widths));
35
+ else
36
+ window.localStorage.removeItem(PREFIX + key);
37
+ }
38
+ catch {
39
+ // Storage off (a private window, blocked site data): widths last for the visit only.
40
+ }
41
+ }
42
+ /**
43
+ * The widths a person set on one table's columns, by field key, kept on the device under `key`.
44
+ * Left out `key`: no widths (resizing off). `set` updates while dragging; `save` sets and keeps.
45
+ */
46
+ export function useColumnWidths(key) {
47
+ const [widths, setWidths] = useState(() => key && typeof window !== 'undefined' ? read(key) : {});
48
+ useEffect(() => {
49
+ setWidths(key ? read(key) : {});
50
+ }, [key]);
51
+ const set = useCallback((col, w) => {
52
+ setWidths((o) => (o[col] === clamp(w) ? o : { ...o, [col]: clamp(w) }));
53
+ }, []);
54
+ const save = useCallback((col, w) => {
55
+ setWidths((o) => {
56
+ const next = { ...o, [col]: clamp(w) };
57
+ if (key)
58
+ write(key, next);
59
+ return next;
60
+ });
61
+ }, [key]);
62
+ const reset = useCallback((col) => {
63
+ setWidths((o) => {
64
+ if (!(col in o))
65
+ return o;
66
+ const { [col]: _, ...rest } = o;
67
+ if (key)
68
+ write(key, rest);
69
+ return rest;
70
+ });
71
+ }, [key]);
72
+ return { widths: key ? widths : {}, set, save, reset };
73
+ }
74
+ /**
75
+ * The grip on a header cell's right edge. It measures its header cell when a drag starts, so a
76
+ * column that never had a width set starts from where it is drawn.
77
+ */
78
+ export function ColumnResizer({ label, width, onWidth, onDone, onReset, }) {
79
+ const drag = useRef(null);
80
+ const [active, setActive] = useState(false);
81
+ const cellWidth = (el) => el.closest('th')?.getBoundingClientRect().width ?? width ?? COL_MIN;
82
+ return (_jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": `Resize ${label}`, "aria-valuenow": width, "aria-valuemin": COL_MIN, "aria-valuemax": COL_MAX, tabIndex: 0, title: `Drag to resize ${label}; double-click to reset`, "data-slot": "column-resizer", "data-active": active || undefined, className: cn('absolute inset-y-0 right-0 z-10 w-2 cursor-col-resize touch-none select-none rounded-sm outline-none', "after:absolute after:inset-y-2 after:right-0.5 after:w-px after:bg-border after:content-['']", 'hover:after:w-0.5 hover:after:bg-primary data-active:after:w-0.5 data-active:after:bg-primary', 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:after:bg-primary'), onClick: (e) => e.stopPropagation(), onPointerDown: (e) => {
83
+ if (e.button !== 0)
84
+ return;
85
+ e.preventDefault();
86
+ e.stopPropagation();
87
+ e.currentTarget.setPointerCapture(e.pointerId);
88
+ const w = cellWidth(e.currentTarget);
89
+ drag.current = { x: e.clientX, w, frame: 0, next: w };
90
+ setActive(true);
91
+ }, onPointerMove: (e) => {
92
+ const d = drag.current;
93
+ if (!d)
94
+ return;
95
+ d.next = d.w + e.clientX - d.x;
96
+ if (!d.frame)
97
+ d.frame = requestAnimationFrame(() => {
98
+ d.frame = 0;
99
+ onWidth(d.next);
100
+ });
101
+ }, onPointerUp: (e) => {
102
+ const d = drag.current;
103
+ if (!d)
104
+ return;
105
+ if (d.frame)
106
+ cancelAnimationFrame(d.frame);
107
+ drag.current = null;
108
+ setActive(false);
109
+ e.currentTarget.releasePointerCapture(e.pointerId);
110
+ if (d.next !== d.w)
111
+ onDone(d.next);
112
+ }, onPointerCancel: () => {
113
+ const d = drag.current;
114
+ if (d?.frame)
115
+ cancelAnimationFrame(d.frame);
116
+ drag.current = null;
117
+ setActive(false);
118
+ }, onDoubleClick: (e) => {
119
+ e.stopPropagation();
120
+ onReset();
121
+ }, onKeyDown: (e) => {
122
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
123
+ e.preventDefault();
124
+ e.stopPropagation();
125
+ const w = width ?? cellWidth(e.currentTarget);
126
+ onDone(w + (e.key === 'ArrowRight' ? STEP : -STEP));
127
+ }
128
+ else if (e.key === 'Enter' || e.key === 'Delete' || e.key === 'Backspace') {
129
+ e.preventDefault();
130
+ e.stopPropagation();
131
+ onReset();
132
+ }
133
+ } }));
134
+ }
@@ -13,6 +13,9 @@ export interface KitConfig {
13
13
  meterLevels: MeterLevels;
14
14
  /** Where every health meter drops below green (`watch`) and below amber (`alert`), 0 to 1. */
15
15
  healthLevels: HealthLevels;
16
+ /** Every table's columns resize by a grip on their header's right edge (desktop), the widths
17
+ * kept on the device per table; one table overrides with its `resizable` prop. */
18
+ resizableColumns: boolean;
16
19
  }
17
20
  export declare const KIT: KitConfig;
18
21
  /** Runs `fn` after each `configureLumen` (preferences read again from the app's cookie). */
@@ -7,6 +7,7 @@ export const KIT = {
7
7
  marketOff: 'Live market data is not on this app.',
8
8
  meterLevels: METER_LEVELS,
9
9
  healthLevels: HEALTH_LEVELS,
10
+ resizableColumns: false,
10
11
  };
11
12
  const changed = new Set();
12
13
  /** Runs `fn` after each `configureLumen` (preferences read again from the app's cookie). */
@@ -170,6 +170,9 @@ export interface LumenViewProps<T> {
170
170
  onSort?: (sort: CustomViewConfig['sort']) => void;
171
171
  /** Its controls from its card (Edit widget ▸ Controls); missing keys take the Table defaults. */
172
172
  shown?: ViewCard['controls'];
173
+ /** A grip on each header's right edge resizes its column (desktop), kept on the device per
174
+ * table (the domain's id and `cardId`); left out: `configureLumen({ resizableColumns })`. */
175
+ resizable?: boolean;
173
176
  }
174
177
  /** What the header's + offers. */
175
178
  export interface AddColumn {
@@ -188,7 +191,7 @@ export interface AddColumn {
188
191
  * and ⋯, and its own filter and Group (they narrow this table only); its body
189
192
  * the table (grouped one level, or the pivot) with the loading states.
190
193
  */
191
- export declare function LumenView<T extends RowData>({ domain, config, rows, total, paging: server, groupBy: pickedGroup, pivot: pickedPivot, subgroup, load: sourceLoad, state, title, selection, onDetails, tapped, openKey, groupActions, controls, inLayout: inScroller, cardId, tools, filter, filterCount, onClearFilter, strip, link, note, onColumns, onAddColumn, sort, onSort, shown, }: LumenViewProps<T>): import("react").JSX.Element;
194
+ export declare function LumenView<T extends RowData>({ domain, config, rows, total, paging: server, groupBy: pickedGroup, pivot: pickedPivot, subgroup, load: sourceLoad, state, title, selection, onDetails, tapped, openKey, groupActions, controls, inLayout: inScroller, cardId, tools, filter, filterCount, onClearFilter, strip, link, note, onColumns, onAddColumn, sort, onSort, shown, resizable, }: LumenViewProps<T>): import("react").JSX.Element;
192
195
  /** A field's colour for its header: its topic's source (a broker green, an app brown) where
193
196
  * the group names one, else its group's (Holding, Symbol, Account, Formulas). */
194
197
  export declare function colorOf<T>(domain: ViewDomain<T>, f: ViewField<T> | undefined): string | null;
@@ -244,4 +247,9 @@ export interface LumenTableProps<T> {
244
247
  emptyState?: ReactNode;
245
248
  /** Takes every filter off: the empty body offers Clear filters. */
246
249
  onClearFilter?: () => void;
250
+ /** A grip on each header's right edge resizes its column (desktop, not a `fit` table); left
251
+ * out: `configureLumen({ resizableColumns })`. */
252
+ resizable?: boolean;
253
+ /** Where the widths are kept on the device (one key per table); left out: they last the visit. */
254
+ widthsKey?: string;
247
255
  }
@@ -11,12 +11,14 @@ import { createPaginatedRowModel, createSortedRowModel, flexRender, rowPaginatio
11
11
  import { ArrowDownIcon, ArrowDownWideNarrowIcon, ArrowUpIcon, ArrowUpNarrowWideIcon, ChartCandlestick, ChartSpline, ChevronRightIcon, ChevronsDownIcon, ChevronsDownUpIcon, ChevronsUpDownIcon, Columns3CogIcon, CornerDownRightIcon, DownloadIcon, FilterXIcon, ListCollapseIcon, PlusIcon, SigmaIcon, UserPenIcon, } from 'lucide-react';
12
12
  import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
13
13
  import { ColorChip, ColorDot } from './color-dot.js';
14
+ import { ColumnResizer, useColumnWidths } from './column-widths.js';
14
15
  import { DrawerSection } from './drawer-section.js';
15
16
  import { useFloatingHead } from './floating-head.js';
16
17
  import { FreshnessMark, freshnessLine, freshnessOf } from './freshness.js';
17
18
  import { useIsMobile } from './hooks/use-mobile.js';
18
19
  import { useTell } from './hooks/use-tell.js';
19
20
  import { IconButton } from './icon-button.js';
21
+ import { KIT } from './kit-config.js';
20
22
  import { lazyPart } from './lazy-part.js';
21
23
  import { cn } from './lib/utils.js';
22
24
  import { useGridCell } from './lumen-grid.js';
@@ -225,7 +227,7 @@ async function downloadCsv({ name, columns, rows, groupBy, sort, hidden, }) {
225
227
  * and ⋯, and its own filter and Group (they narrow this table only); its body
226
228
  * the table (grouped one level, or the pivot) with the loading states.
227
229
  */
228
- export function LumenView({ domain, config, rows, total = rows.length, paging: server, groupBy: pickedGroup = null, pivot: pickedPivot = false, subgroup, load: sourceLoad, state, title = config.name, selection, onDetails, tapped, openKey, groupActions, controls, inLayout: inScroller = false, cardId = 'view', tools, filter, filterCount, onClearFilter, strip, link, note, onColumns, onAddColumn, sort = config.sort, onSort, shown, }) {
230
+ export function LumenView({ domain, config, rows, total = rows.length, paging: server, groupBy: pickedGroup = null, pivot: pickedPivot = false, subgroup, load: sourceLoad, state, title = config.name, selection, onDetails, tapped, openKey, groupActions, controls, inLayout: inScroller = false, cardId = 'view', tools, filter, filterCount, onClearFilter, strip, link, note, onColumns, onAddColumn, sort = config.sort, onSort, shown, resizable, }) {
229
231
  // Its controls (Edit widget ▸ Controls): Pager, Columns and Download here.
230
232
  const on = (k) => controlOn({ kind: 'table', controls: shown }, k);
231
233
  const cell = useGridCell();
@@ -338,7 +340,7 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
338
340
  const pager = turn ? (_jsx(LumenPager, { ...turn })) : served ? (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: served.list ? `${served.list.length.toLocaleString()}${served.more ? '+' : ''} groups` : '' })) : (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: count }));
339
341
  // The page footer (`pagination.footer`): under the table while there is more than one page.
340
342
  const foot = turn && pagination.footer && !state && (turn.more || turn.total > turn.size) ? (_jsx(LumenPageFooter, { total: turn.total, page: turn.page, size: turn.size, onPage: turn.onPage, more: turn.more })) : null;
341
- const table = pivot && config.pivot && !state ? (_jsx(PivotTable, { domain: domain, rows: rows, pivot: config.pivot })) : (_jsx(LumenTable, { domain: domain, columns: columns, rows: state ? [] : tableRows, groupBy: groupBy, subgroup: sub, chips: chips, page: paging ? { index: page, size: pageSize } : null, inView: inLayout, fit: fit, onDetails: onDetails, tapped: tapped, openKey: openKey, selection: selection, groupActions: groupActions, sort: sort, onSort: sorted, serverSorts: server ? (server.sorts ?? []) : undefined, serverGroups: state ? null : served, emptyState: state, onClearFilter: filtered ? onClearFilter : undefined, total: config.total !== false, add: onAddColumn || onColumns
343
+ const table = pivot && config.pivot && !state ? (_jsx(PivotTable, { domain: domain, rows: rows, pivot: config.pivot })) : (_jsx(LumenTable, { domain: domain, columns: columns, rows: state ? [] : tableRows, groupBy: groupBy, subgroup: sub, chips: chips, page: paging ? { index: page, size: pageSize } : null, inView: inLayout, fit: fit, onDetails: onDetails, tapped: tapped, openKey: openKey, selection: selection, groupActions: groupActions, sort: sort, onSort: sorted, serverSorts: server ? (server.sorts ?? []) : undefined, serverGroups: state ? null : served, emptyState: state, onClearFilter: filtered ? onClearFilter : undefined, total: config.total !== false, resizable: resizable, widthsKey: `${domain.id}:${cardId}`, add: onAddColumn || onColumns
342
344
  ? { ...onAddColumn, field: onColumns && { domain, columns, onColumns } }
343
345
  : undefined }));
344
346
  return (_jsx(LumenWidget, { id: cardId, kind: "table", style: "table", title: title, phoneTitle: false, controls: controls, tools: blank ? (tools) : (_jsxs(_Fragment, { children: [tools, compare, pager, onColumns && !onAddColumn && on('columns') && (_jsx(ColumnPicker, { domain: domain, columns: columns, onColumns: onColumns })), !pivot && on('download') && (_jsx(IconButton, { icon: DownloadIcon, label: "Download CSV",
@@ -437,8 +439,17 @@ function sortingOf(sort, columns, serverSorts) {
437
439
  return [];
438
440
  return columns.some((f) => f.key === sort.key) ? [{ id: sort.key, desc: sort.desc }] : [];
439
441
  }
440
- function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null, chips = [], page = null, inView = false, fit = false, onDetails, tapped, openKey = null, selection, groupActions, sort, onSort, serverSorts, total = true, subgroup = null, add, serverGroups = null, emptyState, onClearFilter, }) {
442
+ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null, chips = [], page = null, inView = false, fit = false, onDetails, tapped, openKey = null, selection, groupActions, sort, onSort, serverSorts, total = true, subgroup = null, add, serverGroups = null, emptyState, onClearFilter, resizable = KIT.resizableColumns, widthsKey, }) {
441
443
  const isMobile = useIsMobile();
444
+ // Widths a person dragged (desktop only: a phone's rows are cards, and a fit table wraps instead).
445
+ const resizing = resizable && !isMobile && !fit;
446
+ const colWidths = useColumnWidths(resizing ? (widthsKey ?? '') || undefined : undefined);
447
+ const widthOf = (f) => resizing && f ? colWidths.widths[f.key] : undefined;
448
+ // A dragged width caps the column's cells in its place (the cell's own padding taken off).
449
+ const capFor = (f, i) => {
450
+ const w = widthOf(f);
451
+ return w ? `calc(${w}px - 2 * var(--cell-x))` : capOf(f, i);
452
+ };
442
453
  const [sorting, setSorting] = useState(() => sortingOf(sort, columns, serverSorts));
443
454
  // A saved sort patched while the table is open (the assistant's card move) applies at once.
444
455
  const savedSort = JSON.stringify(sort ?? null);
@@ -560,8 +571,8 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
560
571
  // A column's `cell` function is a component type to flexRender, so a new function means every
561
572
  // cell remounts (a cell being typed into loses its box). The defs change
562
573
  // only when the columns do; each cell reads the latest renderers through `latest`.
563
- const latest = useRef({ shown, cellOf, inline, chips, computed });
564
- latest.current = { shown, cellOf, inline, chips, computed };
574
+ const latest = useRef({ shown, cellOf, inline, chips, computed, capFor });
575
+ latest.current = { shown, cellOf, inline, chips, computed, capFor };
565
576
  const shownKeys = shown.map((f) => f.key).join(',');
566
577
  const inlineOn = inline.length > 0;
567
578
  // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the columns shown; the rest comes through `latest`
@@ -575,11 +586,11 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
575
586
  enableSorting: !serverSorts || serverSorts.includes(f.key),
576
587
  header: i === 0 && flat && subgroup ? subgroup.label : f.label,
577
588
  cell: ({ row }) => {
578
- const { shown: now, cellOf, inline, chips, computed } = latest.current;
589
+ const { shown: now, cellOf, inline, chips, computed, capFor } = latest.current;
579
590
  const field = now[i] ?? f;
580
591
  const chip = chips.some((c) => c.key === field.key);
581
592
  const v = computed.value(field, row.original);
582
- 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) => {
593
+ return (_jsx(CappedCell, { max: capFor(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) => {
583
594
  const v = computed.value(x, row.original);
584
595
  if (!x.edit && (v === null || v === ''))
585
596
  return [];
@@ -714,7 +725,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
714
725
  return n;
715
726
  });
716
727
  }, children: _jsx(ChevronRightIcon, { className: cn('transition-transform', openParts.has(k) && 'rotate-90') }) }));
717
- 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
728
+ 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: capFor(f, 0), children: name
718
729
  ? name(p)
719
730
  : 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)}`));
720
731
  // ↑ and ↓ step to the next data row (past part and panel rows); while a row's details are
@@ -813,7 +824,14 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
813
824
  };
814
825
  const box = useRef(null);
815
826
  useFloatingHead(box, !inView);
816
- return (_jsxs("div", { ref: box, className: cn(inView ? IN_VIEW : 'overflow-x-auto overflow-y-hidden [--pin-top:0px]', fit && FIT), children: [cardSort, _jsx(UnitColumns.Provider, { value: markUnits, children: _jsxs(Table, { "data-cards": "", 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) => {
827
+ return (_jsxs("div", { ref: box, className: cn(inView ? IN_VIEW : 'overflow-x-auto overflow-y-hidden [--pin-top:0px]', fit && FIT), children: [cardSort, _jsx(UnitColumns.Provider, { value: markUnits, children: _jsxs(Table, { "data-cards": "", 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: {
828
+ ...ruleOf(colorOf(domain, shown[i])),
829
+ ...(widthOf(shown[i]) && {
830
+ width: widthOf(shown[i]),
831
+ minWidth: widthOf(shown[i]),
832
+ maxWidth: widthOf(shown[i]),
833
+ }),
834
+ }, onPointerDown: pressStart, onPointerUp: pressEnd, onPointerLeave: pressEnd, onPointerCancel: pressEnd, onContextMenu: (e) => {
817
835
  if (press.current)
818
836
  e.preventDefault();
819
837
  }, "aria-sort": h.column.getIsSorted() === 'asc'
@@ -829,7 +847,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
829
847
  }, children: [_jsx(ListCollapseIcon, {}), " Close every ", subgroup?.label.toLowerCase()] }), _jsxs(DropdownMenuItem, { onSelect: () => setAll(false), children: [_jsx(ChevronsDownUpIcon, {}), " Collapse all"] })] })] })), i === 0 &&
830
848
  groups &&
831
849
  !(folds && !serverGroups) &&
832
- (!serverGroups || serverGroups.onToggleAll) && (_jsx(IconButton, { icon: anyClosed ? ChevronsUpDownIcon : ChevronsDownUpIcon, size: "icon-xs", className: "-ml-2 mr-1 align-middle", label: anyClosed ? 'Expand all groups' : 'Collapse all groups', onClick: () => setAll(anyClosed) })), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "xs", className: cn('font-medium', !(i === 0 && groups) && '-mx-2'), style: { color: colorOf(domain, shown[i]) ?? undefined }, onClick: h.column.getCanSort() ? h.column.getToggleSortingHandler() : undefined, children: [shown[i] ? _jsx(FreshnessMark, { domain: domain, field: shown[i] }) : null, flexRender(h.column.columnDef.header, h.getContext()), _jsx(SortIcon, { dir: h.column.getIsSorted() })] }) }), shown[i] && (domain.groups?.length || freshnessOf(domain, shown[i])) ? (_jsxs(TooltipContent, { children: [domain.groups?.length ? _jsx("div", { children: pathOf(domain, shown[i]) }) : null, freshnessLine(domain, shown[i]) ? (_jsx("div", { children: freshnessLine(domain, shown[i]) })) : null] })) : null] })] }, h.id))), add && (_jsx(TableHead, { className: cn('w-8 pl-1', PINNED_HEAD), children: _jsx(AddColumnMenu, { add: add, fieldOpen: fieldOpen, onFieldOpen: setFieldOpen }) }))] }, hg.id))) }), _jsxs(TableBody, { children: [!rows.length && !serverGroups?.list?.length && (_jsx(TableRow, { "data-slot": "table-empty", className: "hover:bg-transparent", children: _jsx(TableCell, { colSpan: span, className: "py-6 text-center whitespace-normal text-muted-foreground", children: _jsxs("div", { className: "flex flex-col items-center gap-3", children: [emptyState ?? 'No rows match.', onClearFilter && (_jsxs(Button, { variant: "outline", size: "sm", onClick: onClearFilter, children: [_jsx(FilterXIcon, {}), "Clear filters"] }))] }) }) })), groups && groupBy
850
+ (!serverGroups || serverGroups.onToggleAll) && (_jsx(IconButton, { icon: anyClosed ? ChevronsUpDownIcon : ChevronsDownUpIcon, size: "icon-xs", className: "-ml-2 mr-1 align-middle", label: anyClosed ? 'Expand all groups' : 'Collapse all groups', onClick: () => setAll(anyClosed) })), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "xs", className: cn('font-medium', !(i === 0 && groups) && '-mx-2'), style: { color: colorOf(domain, shown[i]) ?? undefined }, onClick: h.column.getCanSort() ? h.column.getToggleSortingHandler() : undefined, children: [shown[i] ? _jsx(FreshnessMark, { domain: domain, field: shown[i] }) : null, flexRender(h.column.columnDef.header, h.getContext()), _jsx(SortIcon, { dir: h.column.getIsSorted() })] }) }), shown[i] && (domain.groups?.length || freshnessOf(domain, shown[i])) ? (_jsxs(TooltipContent, { children: [domain.groups?.length ? _jsx("div", { children: pathOf(domain, shown[i]) }) : null, freshnessLine(domain, shown[i]) ? (_jsx("div", { children: freshnessLine(domain, shown[i]) })) : null] })) : null] }), resizing && shown[i] && (_jsx(ColumnResizer, { label: shown[i].label, width: widthOf(shown[i]), onWidth: (w) => colWidths.set(h.id, w), onDone: (w) => colWidths.save(h.id, w), onReset: () => colWidths.reset(h.id) }))] }, h.id))), add && (_jsx(TableHead, { className: cn('w-8 pl-1', PINNED_HEAD), children: _jsx(AddColumnMenu, { add: add, fieldOpen: fieldOpen, onFieldOpen: setFieldOpen }) }))] }, hg.id))) }), _jsxs(TableBody, { children: [!rows.length && !serverGroups?.list?.length && (_jsx(TableRow, { "data-slot": "table-empty", className: "hover:bg-transparent", children: _jsx(TableCell, { colSpan: span, className: "py-6 text-center whitespace-normal text-muted-foreground", children: _jsxs("div", { className: "flex flex-col items-center gap-3", children: [emptyState ?? 'No rows match.', onClearFilter && (_jsxs(Button, { variant: "outline", size: "sm", onClick: onClearFilter, children: [_jsx(FilterXIcon, {}), "Clear filters"] }))] }) }) })), groups && groupBy
833
851
  ? groups.map(([key, members], gi) => {
834
852
  const open = isOpen(key, gi);
835
853
  return (_jsxs(Fragment, { children: [_jsxs(TableRow, { className: ROLLUP, "data-group": key, "data-rollup": "", children: [selection && (_jsx(TableCell, { "data-card": "select", className: cn('w-8 pr-0', PINNED_GROUP), children: _jsx(Checkbox, { checked: members.every((m) => selection.keys.has(keyOf(m.original)))
@@ -1,9 +1,14 @@
1
1
  /** The Settings ▸ Notifications section's body. */
2
- export declare function NotificationsSettings({ api: base, product, sender, }: {
2
+ export declare function NotificationsSettings({ api: base, product, sender, parts, }: {
3
3
  /** The app's Notifications API base, e.g. `/api/notifications`. */
4
4
  api: string;
5
5
  /** The product's name in the copy ("Get Acme alerts by email"). */
6
6
  product: string;
7
7
  /** The address email comes from. */
8
8
  sender: string;
9
+ /**
10
+ * What to draw: the channels and the events grid (default), only the channels (where messages
11
+ * go) or only the events grid (what goes where), so an app can set them in two sections.
12
+ */
13
+ parts?: 'all' | 'channels' | 'events';
9
14
  }): import("react").JSX.Element;
@@ -147,12 +147,22 @@ function Events({ status, api }) {
147
147
  ...status.webhooks.map((h) => ({ id: h.id, name: h.name })),
148
148
  ];
149
149
  if (!targets.length)
150
- return (_jsx("p", { className: "text-sm text-muted-foreground", children: "Nothing to send to yet: turn on email or ntfy, or add a webhook, above." }));
151
- const grid = { gridTemplateColumns: `minmax(12rem, 2.4fr) repeat(${targets.length}, 5.5rem)` };
152
- return (_jsxs("div", { className: "grid grid-cols-1 gap-2", children: [_jsx("div", { className: "overflow-x-auto", children: _jsxs("div", { "aria-label": "Events", className: "grid min-w-max items-center", style: grid, children: [_jsxs("div", { className: "contents", children: [_jsx("span", { className: "pb-1 text-xs font-semibold tracking-wide text-muted-foreground uppercase", children: "Event" }), targets.map((t) => (_jsx("span", { className: "truncate pb-1 text-center text-xs font-semibold tracking-wide text-muted-foreground uppercase", title: t.name, children: t.name }, t.id)))] }), status.events.map((e) => (_jsxs("div", { className: "contents", children: [_jsxs("span", { className: "grid gap-0.5 border-t py-2 pr-3", children: [_jsx("span", { className: "text-sm", children: e.label }), _jsx("span", { className: "text-xs text-muted-foreground", children: e.detail })] }), targets.map((t) => (_jsx("span", { className: "self-stretch border-t py-2", children: _jsx("span", { className: "flex h-full items-center justify-center", children: _jsx(EventCell, { event: e, target: t.id, name: t.name, api: api }) }) }, t.id)))] }, e.key)))] }) }), _jsx("p", { className: "text-xs text-muted-foreground", children: "A new target starts with every event on." })] }));
150
+ return (_jsx("p", { className: "text-sm text-muted-foreground", children: "Nothing to send to yet: turn on email or ntfy, or add a webhook, first." }));
151
+ // The event column gives way first, so a phone shows the checkboxes; its detail waits in the hover.
152
+ const grid = { gridTemplateColumns: `minmax(7rem, 1fr) repeat(${targets.length}, 5.5rem)` };
153
+ // Groups in the order they first appear; events without one come first, unlabelled.
154
+ const groups = [];
155
+ for (const e of status.events) {
156
+ const g = groups.find((x) => x.label === e.group);
157
+ if (g)
158
+ g.events.push(e);
159
+ else
160
+ groups.push({ label: e.group, events: [e] });
161
+ }
162
+ return (_jsxs("div", { className: "grid grid-cols-1 gap-2", children: [_jsx("div", { className: "overflow-x-auto", children: _jsxs("div", { "aria-label": "Events", className: "grid min-w-max items-center", style: grid, children: [_jsxs("div", { className: "contents", children: [_jsx("span", { className: "pb-1 text-xs font-semibold tracking-wide text-muted-foreground uppercase", children: "Event" }), targets.map((t) => (_jsx("span", { className: "truncate pb-1 text-center text-xs font-semibold tracking-wide text-muted-foreground uppercase", title: t.name, children: t.name }, t.id)))] }), groups.map((g) => (_jsxs("div", { className: "contents", children: [g.label ? (_jsx("span", { className: "border-t pt-3 pb-1 text-xs font-semibold tracking-wide text-muted-foreground uppercase", style: { gridColumn: '1 / -1' }, children: g.label })) : null, g.events.map((e) => (_jsxs("div", { className: "contents", children: [_jsxs("span", { className: "flex min-w-0 items-baseline gap-2 border-t py-2 pr-3", title: `${e.label}: ${e.detail}`, children: [_jsx("span", { className: "truncate text-sm whitespace-nowrap sm:shrink-0", children: e.label }), _jsx("span", { className: "hidden truncate text-xs text-muted-foreground sm:inline", children: e.detail })] }), targets.map((t) => (_jsx("span", { className: "self-stretch border-t py-2", children: _jsx("span", { className: "flex h-full items-center justify-center", children: _jsx(EventCell, { event: e, target: t.id, name: t.name, api: api }) }) }, t.id)))] }, e.key)))] }, g.label ?? '')))] }) }), _jsx("p", { className: "text-xs text-muted-foreground", children: "A new target starts with every event on." })] }));
153
163
  }
154
164
  /** The Settings ▸ Notifications section's body. */
155
- export function NotificationsSettings({ api: base, product, sender, }) {
165
+ export function NotificationsSettings({ api: base, product, sender, parts = 'all', }) {
156
166
  const api = useNotifications(base, product, sender);
157
167
  const [open, setOpen] = useState(null);
158
168
  const toggle = (id) => (o) => setOpen(o ? id : null);
@@ -161,7 +171,9 @@ export function NotificationsSettings({ api: base, product, sender, }) {
161
171
  if (api.q.isError)
162
172
  return _jsx(ErrorLine, { error: api.q.error });
163
173
  const s = api.q.data;
174
+ if (parts === 'events')
175
+ return _jsx(Events, { status: s, api: api });
164
176
  return (_jsxs("div", { className: "grid grid-cols-1 gap-6", children: [_jsxs("div", { className: "grid grid-cols-1", children: [_jsx(IntegrationRow, { name: "Email", meta: "To your sign-in address", open: open === 'email', onOpenChange: toggle('email'), badge: s.email.connected ? (_jsx(TestBadge, { test: s.email.last_test })) : (_jsx(Badge, { variant: "outline", children: "Off" })), children: _jsx(Email, { status: s.email, api: api }) }), _jsx(IntegrationRow, { name: "ntfy", meta: "Phone notifications through the ntfy app", open: open === 'ntfy', onOpenChange: toggle('ntfy'), badge: s.ntfy.connected ? (_jsx(TestBadge, { test: s.ntfy.last_test })) : (_jsx(Badge, { variant: "outline", children: "Off" })), children: _jsx(Ntfy, { status: s.ntfy, api: api }) }), _jsx(IntegrationRow, { name: "Webhooks", meta: s.webhooks.length
165
177
  ? s.webhooks.map((h) => h.name).join(' · ')
166
- : 'Events posted to your own addresses', open: open === 'webhooks', onOpenChange: toggle('webhooks'), badge: s.webhooks.length ? (s.webhooks.some((h) => h.last_test?.ok === false) ? (_jsxs(Badge, { variant: "outline", className: "text-loss", children: [_jsx(AlertTriangleIcon, {}), "Failed"] })) : (_jsxs(Badge, { variant: "outline", children: [s.webhooks.length, " webhook", s.webhooks.length === 1 ? '' : 's'] }))) : (_jsx(Badge, { variant: "outline", children: "None" })), children: _jsx(Webhooks, { hooks: s.webhooks, api: api }) })] }), _jsxs("section", { className: "grid grid-cols-1 gap-2", children: [_jsx("h4", { className: "font-medium", children: "Events" }), _jsx(Events, { status: s, api: api })] })] }));
178
+ : 'Events posted to your own addresses', open: open === 'webhooks', onOpenChange: toggle('webhooks'), badge: s.webhooks.length ? (s.webhooks.some((h) => h.last_test?.ok === false) ? (_jsxs(Badge, { variant: "outline", className: "text-loss", children: [_jsx(AlertTriangleIcon, {}), "Failed"] })) : (_jsxs(Badge, { variant: "outline", children: [s.webhooks.length, " webhook", s.webhooks.length === 1 ? '' : 's'] }))) : (_jsx(Badge, { variant: "outline", children: "None" })), children: _jsx(Webhooks, { hooks: s.webhooks, api: api }) })] }), parts === 'all' ? (_jsxs("section", { className: "grid grid-cols-1 gap-2", children: [_jsx("h4", { className: "font-medium", children: "Events" }), _jsx(Events, { status: s, api: api })] })) : null] }));
167
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.169",
3
+ "version": "0.1.171",
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.169",
52
+ "@columenlabs/lumen-ui-types": "0.1.171",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",