@columenlabs/lumen-ui 0.1.41 → 0.1.43

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.
@@ -32,7 +32,7 @@ export declare function CoverageLegend({ levels, hint, labels, }: {
32
32
  * cell made a 100-row map 13,000 tooltips, seconds per render). `label` is the
33
33
  * accessible name and the tooltip's first line; `tip` marks the cell for the shared tooltip.
34
34
  */
35
- export declare const CoverageCell: import("react").MemoExoticComponent<({ level, label, tip, picked, disabled, pickable, onClick, className, fill, }: {
35
+ export declare const CoverageCell: import("react").MemoExoticComponent<({ level, label, tip, picked, disabled, pickable, onClick, className, fill, mark, start, preview, }: {
36
36
  level: CoverageLevel;
37
37
  label: string;
38
38
  /** The share in hand (0–1): a part-done block fills to it from the left, whatever its state. */
@@ -45,6 +45,13 @@ export declare const CoverageCell: import("react").MemoExoticComponent<({ level,
45
45
  pickable?: boolean;
46
46
  onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
47
47
  className?: string;
48
+ /** A backfill pick: a frame and a check (`add`) or a minus (`remove`, out of the queue).
49
+ * Ink only, so it never reads as a state's colour or the queued dashes. */
50
+ mark?: 'add' | 'remove';
51
+ /** The first block of a range being picked: its mark pulses until the range ends. */
52
+ start?: boolean;
53
+ /** Inside the range the pointer would pick (or, with `mark`, unpick) next. */
54
+ preview?: boolean;
48
55
  }) => import("react").JSX.Element>;
49
56
  /**
50
57
  * The one tooltip of a coverage map: it follows the cell under the pointer or keyboard focus inside
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // What LumenCoverageMap and LumenCoverageCalendarMap share: one state per cell, its look, the legend of the states on screen and the cell with its
3
3
  // tooltip. A colour fix here reaches every coverage surface.
4
+ import { CheckIcon, MinusIcon } from 'lucide-react';
4
5
  import { memo, useRef, useState } from 'react';
5
6
  import { cn } from './lib/utils.js';
6
7
  import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
@@ -67,14 +68,20 @@ export function CoverageLegend({ levels, hint, labels, }) {
67
68
  * cell made a 100-row map 13,000 tooltips, seconds per render). `label` is the
68
69
  * accessible name and the tooltip's first line; `tip` marks the cell for the shared tooltip.
69
70
  */
70
- export const CoverageCell = memo(function CoverageCell({ level, label, tip, picked = false, disabled = false, pickable = false, onClick, className, fill, }) {
71
- return (_jsx("button", { type: "button", "data-cell": tip, "aria-label": `${label}: ${coverageLabel(level)}`, "aria-pressed": pickable && !disabled ? picked : undefined, disabled: disabled, onClick: onClick, className: cn('min-w-0 rounded-[3px] p-0 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default', coverageClass(level), 'relative overflow-hidden',
71
+ export const CoverageCell = memo(function CoverageCell({ level, label, tip, picked = false, disabled = false, pickable = false, onClick, className, fill, mark, start = false, preview = false, }) {
72
+ return (_jsxs("button", { type: "button", "data-cell": tip, "aria-label": `${label}: ${coverageLabel(level)}`, "aria-pressed": pickable && !disabled ? picked : undefined, disabled: disabled, onClick: onClick, className: cn('min-w-0 rounded-[3px] p-0 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default', coverageClass(level), 'relative overflow-hidden',
72
73
  // outline-none would also hide the picked outline, so a picked cell must not carry it.
73
- picked
74
- ? 'outline-2 outline-offset-1 outline-foreground'
75
- : level === 'queued' || level === 'failed'
76
- ? ''
77
- : 'outline-none', pickable && !disabled && 'cursor-pointer', className), children: fill !== undefined && fill > 0 && fill < 1 && (_jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute inset-y-0 left-0 bg-cov-full", style: { width: `${Math.round(fill * 100)}%` } })) }));
74
+ mark && !preview
75
+ ? 'shadow-[inset_0_0_0_2px_var(--foreground),inset_0_0_0_3px_var(--card)] outline-none'
76
+ : preview
77
+ ? 'shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--foreground)_45%,transparent)] outline-none'
78
+ : picked
79
+ ? 'outline-2 outline-offset-1 outline-foreground'
80
+ : level === 'queued' || level === 'failed'
81
+ ? ''
82
+ : 'outline-none', pickable && !disabled && 'cursor-pointer', className), children: [fill !== undefined && fill > 0 && fill < 1 && (_jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute inset-y-0 left-0 bg-cov-full", style: { width: `${Math.round(fill * 100)}%` } })), mark && (_jsx("span", { "aria-hidden": true, className: cn('pointer-events-none absolute top-1/2 right-0.5 flex size-2.5 -translate-y-1/2 items-center justify-center rounded-full bg-foreground text-background sm:size-3', start && 'motion-safe:animate-pulse',
83
+ // A picked block inside an unpicking range: its check fades, about to go.
84
+ preview && 'opacity-40'), children: mark === 'add' ? (_jsx(CheckIcon, { className: "size-2 stroke-[3.5] sm:size-2.5" })) : (_jsx(MinusIcon, { className: "size-2 stroke-[3.5] sm:size-2.5" })) }))] }));
78
85
  });
79
86
  /**
80
87
  * The one tooltip of a coverage map: it follows the cell under the pointer or keyboard focus inside
@@ -72,7 +72,8 @@ export interface LumenCoverageMapProps<TRaw = unknown, TKey extends QueryKey = Q
72
72
  unit?: string;
73
73
  /** Right-hand columns; a phone keeps Complete only. */
74
74
  summary?: CoverageSummary[];
75
- /** `backfill`: click, shift-click a range, then the selection bar's buttons. `drill`: a click opens the block. */
75
+ /** `backfill`: tap a block, then another to pick the range between (a year's label picks its
76
+ * year), then the selection bar's buttons. `drill`: a click opens the block. */
76
77
  select?: 'backfill' | 'drill';
77
78
  /** Months a backfill can take (the source's own limits). */
78
79
  selectable?: (row: string, month: string) => boolean;
@@ -88,6 +89,8 @@ export interface LumenCoverageMapProps<TRaw = unknown, TKey extends QueryKey = Q
88
89
  stateLabels?: Partial<Record<CoverageLevel, string>>;
89
90
  /** Picks these months as if clicked (pass a new object each time); nothing is queued until the bar's button. */
90
91
  preselect?: CoveragePick;
92
+ /** In the selection bar while nothing is picked: quick picks (buttons that set `preselect`). */
93
+ pickTools?: ReactNode;
91
94
  /** The drilled block (`2026-09`) and the click that changes it. */
92
95
  drilled?: string | null;
93
96
  onDrill?: (row: string, period: string) => void;
@@ -121,4 +124,4 @@ export interface LumenCoverageMapProps<TRaw = unknown, TKey extends QueryKey = Q
121
124
  export declare function blockOf(period: string, block: CoverageBlock): string;
122
125
  /** `2026-09` → `Sep 2026`, `2026-Q3` → `2026 Q3`, days stay as they are. */
123
126
  export declare function blockName(key: string): string;
124
- export declare function LumenCoverageMap<TRaw, TKey extends QueryKey>({ source, title, icon, rows: rowSpec, layout, block: initialBlock, zoom, window, unit, summary, select, selectable, onQueue, queueLabel, pickNote, removable, onRemove, preselect, stateLabels, drilled, onDrill, groupBy, hint, pageSize: initialPageSize, pager, tools, filter, filterCount, empty, lazy, }: LumenCoverageMapProps<TRaw, TKey>): import("react").JSX.Element;
127
+ export declare function LumenCoverageMap<TRaw, TKey extends QueryKey>({ source, title, icon, rows: rowSpec, layout, block: initialBlock, zoom, window, unit, summary, select, selectable, onQueue, queueLabel, pickNote, removable, onRemove, preselect, pickTools, stateLabels, drilled, onDrill, groupBy, hint, pageSize: initialPageSize, pager, tools, filter, filterCount, empty, lazy, }: LumenCoverageMapProps<TRaw, TKey>): import("react").JSX.Element;
@@ -180,7 +180,7 @@ function sameNode(a, b) {
180
180
  * changes (`sig`), so a poll redraws the rows whose blocks moved and a pick redraws one row
181
181
  * (a 100-row map used to redraw all 13,000 cells on every poll).
182
182
  */
183
- const MapRow = memo(function MapRow({ r, cols, marks, picks, total, look, }) {
183
+ const MapRow = memo(function MapRow({ r, cols, marks, picks, start, previews, total, look, }) {
184
184
  const text = r.label ?? r.row;
185
185
  const link = look.link?.(r.row);
186
186
  return (_jsxs(_Fragment, { children: [_jsxs("span", {
@@ -189,7 +189,7 @@ const MapRow = memo(function MapRow({ r, cols, marks, picks, total, look, }) {
189
189
  const m = marks[i];
190
190
  if (!m)
191
191
  return _jsx("span", {}, k);
192
- return (_jsx(CoverageCell, { level: m.level, fill: m.fill, label: `${text} · ${blockName(k)}`, tip: `${r.row}|${k}`, picked: picks.includes(k) || look.drilled === k, pickable: look.select === 'backfill' || look.select === 'drill', disabled: look.select === 'backfill' ? !m.canPick : look.select !== 'drill', className: "h-[18px]" }, k));
192
+ return (_jsx(CoverageCell, { level: m.level, fill: m.fill, label: `${text} · ${blockName(k)}`, tip: `${r.row}|${k}`, picked: picks.includes(k) || look.drilled === k, mark: picks.includes(k) ? m.kind : undefined, start: start === k, preview: m.canPick && previews.includes(k), pickable: look.select === 'backfill' || look.select === 'drill', disabled: look.select === 'backfill' ? !m.canPick : look.select !== 'drill', className: "h-[18px]" }, k));
193
193
  }), look.sums.map((s) => (_jsx("span", { className: cn('num pl-3 text-right text-xs whitespace-nowrap', s !== 'complete' && 'text-muted-foreground'), children: s === 'complete'
194
194
  ? fmtPct(total.have, total.expected)
195
195
  : s === 'first'
@@ -200,7 +200,7 @@ const MapRow = memo(function MapRow({ r, cols, marks, picks, total, look, }) {
200
200
  ? fmtCounts(total.have, total.expected)
201
201
  : fmtInt(total.have) }, s)))] }));
202
202
  }, (a, b) => a.sig === b.sig && sameNode(a.r.badge, b.r.badge));
203
- export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowSpec, layout = 'grid', block: initialBlock = 'month', zoom, window = 'all', unit = 'sessions', summary = ['complete', 'last'], select, selectable = () => true, onQueue, queueLabel = 'Queue backfill', pickNote, removable = () => false, onRemove, preselect, stateLabels, drilled = null, onDrill, groupBy = false, hint, pageSize: initialPageSize, pager, tools, filter, filterCount, empty, lazy = false, }) {
203
+ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowSpec, layout = 'grid', block: initialBlock = 'month', zoom, window = 'all', unit = 'sessions', summary = ['complete', 'last'], select, selectable = () => true, onQueue, queueLabel = 'Queue backfill', pickNote, removable = () => false, onRemove, preselect, pickTools, stateLabels, drilled = null, onDrill, groupBy = false, hint, pageSize: initialPageSize, pager, tools, filter, filterCount, empty, lazy = false, }) {
204
204
  const { data, load: raw, ref } = useLumenSource(source, { lazy });
205
205
  // A coverage map refreshes in place: any refresh, asked for or a poll, only spins its ↻; no bar or dimming over the cells.
206
206
  const load = { ...raw, fetching: raw.fetching || raw.background };
@@ -209,11 +209,18 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
209
209
  const [block, setBlock] = useState(layout === 'months' ? 'month' : initialBlock);
210
210
  const [picked, setPicked] = useState(new Set());
211
211
  const [queueing, setQueueing] = useState(false);
212
- const anchor = useRef(null);
212
+ // A range: the first tap picks a block and starts it, the next tap in the row picks every block
213
+ // between. Started on a picked block, the range unpicks instead (`remove`).
214
+ // `over` is the block under the pointer meanwhile; `last` anchors shift-click.
215
+ const [start, setStart] = useState(null);
216
+ const [over, setOver] = useState(null);
217
+ const last = useRef(null);
213
218
  // A preselect picks its months as if clicked; it replaces the current pick.
214
219
  useEffect(() => {
215
- if (preselect)
216
- setPicked(new Set(preselect.flatMap((p) => p.months.map((m) => `${p.row}|${m}`))));
220
+ if (!preselect)
221
+ return;
222
+ setPicked(new Set(preselect.flatMap((p) => p.months.map((m) => `${p.row}|${m}`))));
223
+ setStart(null);
217
224
  }, [preselect]);
218
225
  const scroller = useRef(null);
219
226
  const [pageSize, setPageSize] = useState(initialPageSize ?? 0);
@@ -234,7 +241,7 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
234
241
  // biome-ignore lint/correctness/useExhaustiveDependencies: re-run when the columns change
235
242
  useLayoutEffect(() => {
236
243
  const el = scroller.current;
237
- if (el)
244
+ if (el && layout === 'grid')
238
245
  el.scrollLeft = el.scrollWidth;
239
246
  }, [strip]);
240
247
  const sums = mobile ? summary.filter((s) => s === 'complete') : summary;
@@ -246,7 +253,7 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
246
253
  const ids = keys.map((k) => `${row}|${k}`);
247
254
  const all = ids.every((id) => next.has(id));
248
255
  for (const id of ids) {
249
- if (mode === 'toggle' && all)
256
+ if (mode === 'remove' || (mode === 'toggle' && all))
250
257
  next.delete(id);
251
258
  else
252
259
  next.add(id);
@@ -277,6 +284,7 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
277
284
  try {
278
285
  await act(pickedMonths(remove ? removable : selectable));
279
286
  setPicked(new Set());
287
+ setStart(null);
280
288
  }
281
289
  finally {
282
290
  setQueueing(false);
@@ -303,6 +311,15 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
303
311
  .get(row)
304
312
  ?.get(key)
305
313
  ?.months.some((m) => selectable(row, m) || (onRemove !== undefined && removable(row, m)));
314
+ /** What picking a block does: queue its months, or take them out of the queue. */
315
+ const kindOf = (row, key) => folded?.cells
316
+ .get(row)
317
+ ?.get(key)
318
+ ?.months.some((m) => selectable(row, m))
319
+ ? 'add'
320
+ : 'remove';
321
+ /** A row's blocks in time order; the months layout runs across years. */
322
+ const orderOf = (row) => layout === 'months' ? [...(folded?.cells.get(row)?.keys() ?? [])].sort() : cols;
306
323
  // One click handler for every cell: drill, or pick (shift-click picks a range in the row).
307
324
  const onCellClick = (e) => {
308
325
  const el = e.target instanceof Element ? e.target.closest('[data-cell]') : null;
@@ -318,33 +335,88 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
318
335
  }
319
336
  if (select !== 'backfill')
320
337
  return;
321
- // The months layout ranges over its months in time order, across years.
322
- const order = layout === 'months' ? [...(folded?.cells.get(row)?.keys() ?? [])].sort() : cols;
338
+ const order = orderOf(row);
323
339
  const i = order.indexOf(k);
324
- if (e.shiftKey && anchor.current?.row === row) {
325
- const [lo, hi] = [anchor.current.index, i].sort((x, y) => x - y);
326
- const span = order.slice(lo, hi + 1);
327
- toggle(row, span.filter((key) => canPick(row, key)), 'add');
340
+ const range = (from, mode = 'add') => {
341
+ const [lo, hi] = [from, i].sort((x, y) => x - y);
342
+ toggle(row, order.slice(lo, hi + 1).filter((key) => canPick(row, key)), mode);
343
+ };
344
+ if (e.shiftKey && last.current?.row === row) {
345
+ range(last.current.index);
346
+ setStart(null);
347
+ }
348
+ else if (start?.row === row) {
349
+ // The second tap ends the range. On its own first block it only ends it: a picked range
350
+ // start is out already, a new one goes back out.
351
+ if (start.key === k)
352
+ toggle(row, [k], 'remove');
353
+ else
354
+ range(order.indexOf(start.key), start.remove ? 'remove' : 'add');
355
+ setStart(null);
356
+ }
357
+ else {
358
+ const remove = picked.has(`${row}|${k}`);
359
+ toggle(row, [k], remove ? 'remove' : 'add');
360
+ setStart({ row, key: k, remove });
328
361
  }
329
- else
330
- toggle(row, [k], 'toggle');
331
- anchor.current = { row, index: i };
362
+ setOver(null);
363
+ last.current = { row, index: i };
364
+ };
365
+ // While a range is open, the block under the pointer shows what the next tap would pick.
366
+ const onCellOver = (e) => {
367
+ if (!start)
368
+ return;
369
+ const el = e.target instanceof Element ? e.target.closest('[data-cell]') : null;
370
+ const tip = el?.dataset.cell ?? '';
371
+ const cut = tip.lastIndexOf('|');
372
+ if (el && tip.slice(0, cut) === start.row)
373
+ setOver(tip.slice(cut + 1));
374
+ };
375
+ const previewOf = (row) => {
376
+ if (!start || start.row !== row || !over)
377
+ return [];
378
+ const order = orderOf(row);
379
+ const [lo, hi] = [order.indexOf(start.key), order.indexOf(over)].sort((x, y) => x - y);
380
+ // An unpicking range previews the picked blocks it would take out; a picking one the rest.
381
+ return lo < 0
382
+ ? []
383
+ : order
384
+ .slice(lo, hi + 1)
385
+ .filter((k) => k === start.key ? start.remove : picked.has(`${row}|${k}`) === start.remove);
386
+ };
387
+ const picking = picked.size > 0 || !!start;
388
+ // Picking fades the blocks a pick can't take, so what can be picked stands out.
389
+ const routes = {
390
+ onClick: onCellClick,
391
+ onPointerOver: onCellOver,
392
+ onPointerLeave: () => setOver(null),
393
+ onKeyDown: (e) => {
394
+ if (e.key === 'Escape')
395
+ setStart(null);
396
+ },
332
397
  };
398
+ const fade = select === 'backfill' && picking && '[&_[data-cell]:disabled]:opacity-30';
333
399
  let body = null;
334
400
  if (data && folded) {
335
401
  if (layout === 'months') {
336
402
  const row = data.rows[0]?.row ?? data.facts[0]?.row ?? '';
337
403
  const cells = folded.cells.get(row) ?? new Map();
404
+ const previews = new Set(previewOf(row));
338
405
  const years = [...new Set([...cells.keys()].map((k) => k.slice(0, 4)))].sort().reverse();
339
406
  const sumCols = sums.map((s) => SUMMARY_WIDTH[s]).join(' ');
340
407
  const grid = {
341
408
  gridTemplateColumns: `${mobile ? '2.75rem' : '4rem'} repeat(12, minmax(${mobile ? '14px' : '18px'}, 1fr)) ${sumCols}`,
342
409
  };
343
- body = (_jsxs("div", { className: "grid items-center gap-[3px]", style: grid, onClick: onCellClick, children: [_jsx("span", { className: "text-xs text-muted-foreground", children: "Year" }), MON.map((m, i) => (_jsx("span", { className: "text-xs text-muted-foreground", children: mobile ? (i % 3 === 0 ? m : '') : m }, m))), 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))), years.map((y) => {
410
+ body = (_jsxs("div", { className: cn('grid items-center gap-[3px]', fade), style: grid, ...routes, children: [_jsx("span", { className: "text-xs text-muted-foreground", children: "Year" }), MON.map((m, i) => (_jsx("span", { className: "text-xs text-muted-foreground", children: mobile ? (i % 3 === 0 ? m : '') : m }, m))), 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))), years.map((y) => {
344
411
  let have = 0;
345
412
  let expected = 0;
413
+ // A year's label picks the months in it that can be queued (again: takes them out).
414
+ const yearKeys = MON.map((_, i) => `${y}-${String(i + 1).padStart(2, '0')}`).filter((k) => canPick(row, k) && kindOf(row, k) === 'add');
346
415
  return [
347
- _jsx("span", { className: "num text-xs", children: y }, `${y}-label`),
416
+ select === 'backfill' && yearKeys.length ? (_jsx("button", { type: "button", "aria-label": `Pick ${y}`, className: "num -ml-1 w-fit cursor-pointer rounded px-1 text-left text-xs hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", onClick: () => {
417
+ toggle(row, yearKeys, 'toggle');
418
+ setStart(null);
419
+ }, children: y }, `${y}-label`)) : (_jsx("span", { className: "num text-xs", children: y }, `${y}-label`)),
348
420
  ...MON.map((_, i) => {
349
421
  const key = `${y}-${String(i + 1).padStart(2, '0')}`;
350
422
  const c = cells.get(key);
@@ -353,7 +425,7 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
353
425
  levels.add(c.level);
354
426
  have += c.have;
355
427
  expected += c.expected;
356
- return (_jsx(CoverageCell, { level: c.level, fill: c.expected > 0 ? c.have / c.expected : undefined, label: `${row} · ${blockName(key)}`, tip: `${row}|${key}`, picked: picked.has(`${row}|${key}`) || drilled === key, pickable: select === 'backfill' || select === 'drill', disabled: select === 'backfill' ? !canPick(row, key) : select !== 'drill', className: mobile ? 'h-4' : 'h-5' }, key));
428
+ return (_jsx(CoverageCell, { level: c.level, fill: c.expected > 0 ? c.have / c.expected : undefined, label: `${row} · ${blockName(key)}`, tip: `${row}|${key}`, picked: picked.has(`${row}|${key}`) || drilled === key, mark: picked.has(`${row}|${key}`) ? kindOf(row, key) : undefined, start: start?.key === key, preview: previews.has(key) && canPick(row, key), pickable: select === 'backfill' || select === 'drill', disabled: select === 'backfill' ? !canPick(row, key) : select !== 'drill', className: mobile ? 'h-4' : 'h-5' }, key));
357
429
  }),
358
430
  ...sums.map((s) => (_jsx("span", { className: cn('num pl-3 text-right text-xs', s !== 'complete' && 'text-muted-foreground'), children: s === 'complete'
359
431
  ? fmtPct(have, expected)
@@ -390,7 +462,7 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
390
462
  }
391
463
  const look = { mobile, groupBy, select, sums, drilled, link: rowSpec.link };
392
464
  let group;
393
- body = (_jsxs("div", { className: "grid min-w-min items-center gap-[3px]", style: grid, onClick: onCellClick, 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) => {
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) => {
394
466
  const out = [];
395
467
  const shut = groupBy && folds.has(foldKey + (r.group ?? ''));
396
468
  if (groupBy && r.group !== group) {
@@ -411,10 +483,13 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
411
483
  level: c.level,
412
484
  canPick: select === 'backfill' && canPick(r.row, k),
413
485
  fill: c.expected > 0 ? c.have / c.expected : undefined,
486
+ kind: select === 'backfill' && canPick(r.row, k) ? kindOf(r.row, k) : undefined,
414
487
  };
415
488
  });
416
489
  const picks = pickedBy.get(r.row) ?? [];
417
- out.push(_jsx(MapRow, { r: r, cols: cols, marks: marks, picks: picks, total: folded.totals.get(r.row) ?? { have: 0, expected: 0 }, look: look, sig: [
490
+ const rowStart = start?.row === r.row ? start.key : undefined;
491
+ const previews = previewOf(r.row);
492
+ out.push(_jsx(MapRow, { r: r, cols: cols, marks: marks, picks: picks, start: rowStart, previews: previews, total: folded.totals.get(r.row) ?? { have: 0, expected: 0 }, look: look, sig: [
418
493
  r.label,
419
494
  r.sub,
420
495
  r.first,
@@ -429,8 +504,10 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
429
504
  cols[0],
430
505
  cols.at(-1),
431
506
  picks.join(),
507
+ rowStart,
508
+ previews.join(),
432
509
  marks
433
- .map((m) => (m ? `${m.level}${m.canPick ? '+' : ''}${m.fill ?? ''}` : ''))
510
+ .map((m) => m ? `${m.level}${m.canPick ? '+' : ''}${m.kind ?? ''}${m.fill ?? ''}` : '')
434
511
  .join(),
435
512
  JSON.stringify(folded.totals.get(r.row) ?? null),
436
513
  ].join('\u0001') }, r.row));
@@ -446,20 +523,31 @@ export function LumenCoverageMap({ source, title, icon = Grid3x3Icon, rows: rowS
446
523
  const pickedRows = [...new Set([...pick, ...unpick].map((p) => p.row))];
447
524
  const rowName = (row) => data?.rows.find((r) => r.row === row)?.label ?? row;
448
525
  const months = (n) => `${n} month${n > 1 ? 's' : ''}`;
526
+ const word = layout === 'months' ? 'month' : 'block in the row';
449
527
  const head = (_jsxs(_Fragment, { children: [tools, zoom && layout === 'grid' && (_jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: block, onValueChange: (v) => {
450
528
  if (!v)
451
529
  return;
452
530
  setBlock(v);
453
531
  setPicked(new Set());
532
+ setStart(null);
454
533
  }, "aria-label": "Block size", className: "max-sm:hidden", children: zoom.map((z) => (_jsx(ToggleGroupItem, { value: z, children: BLOCK_LABEL[z] }, z))) })), pager ? (_jsx(LumenPager, { total: pager.total, page: pager.page, size: pager.size, sizes: pager.sizes, onPage: pager.onPage, onSize: pager.onSize, noun: plural(rowSpec.title) })) : (pageSize > 0 &&
455
534
  layout === 'grid' &&
456
535
  data && (_jsx(LumenPager, { total: rowTotal, page: page, size: pageSize, onPage: setPageIndex, onSize: (n) => {
457
536
  setPageSize(n);
458
537
  setPageIndex(0);
459
538
  }, noun: plural(rowSpec.title) })))] }));
460
- return (_jsx("div", { ref: ref, className: "min-w-0", children: _jsx(LumenWidget, { id: title, kind: null, icon: icon, title: title, tools: head, filter: filter, filterCount: filterCount, load: load, rows: data?.rows.length, pad: false, children: _jsx("div", { className: "flex flex-col gap-3 px-3 pt-3 pb-3", children: _jsxs(LumenStates, { load: cells, skeleton: _jsx(GridSkeleton, {}), empty: data && !data.rows.length && !data.facts.length ? empty : undefined, children: [_jsx("div", { ref: scroller, className: "overflow-x-auto overflow-y-hidden pb-1", children: _jsx(CoverageTip, { content: tipFor, className: "w-max min-w-full", children: body }) }), pickedCount > 0 && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 rounded-lg bg-muted px-3 py-2 text-sm", children: [_jsxs("span", { className: "min-w-0", children: [layout === 'grid' && (_jsx("span", { className: "num font-semibold", children: `${pickedRows.map(rowName).join(', ')} · ` })), `${months(pickedCount)} picked`, pickNote && queueCount > 0 ? ` · ${pickNote(pick)}` : ''] }), _jsx("span", { className: "flex-1" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setPicked(new Set()), 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)] }))] })), _jsx("div", { className: "border-t pt-3", children: _jsx(CoverageLegend, { levels: levels, labels: stateLabels, hint: hint ??
539
+ return (_jsx("div", { ref: ref, className: "min-w-0", children: _jsx(LumenWidget, { id: title, kind: null, icon: icon, title: title, tools: head, filter: filter, filterCount: filterCount, load: load, rows: data?.rows.length, pad: false, children: _jsx("div", { className: "flex flex-col gap-3 px-3 pt-3 pb-3", children: _jsxs(LumenStates, { load: cells, skeleton: _jsx(GridSkeleton, {}), empty: data && !data.rows.length && !data.facts.length ? empty : undefined, children: [select === 'backfill' && (_jsxs("div", { "aria-live": "polite", className: cn('flex min-h-11 flex-wrap items-center gap-2 rounded-lg px-3 py-1.5 text-sm', pickedCount > 0
540
+ ? 'bg-foreground/5 ring-1 ring-foreground ring-inset'
541
+ : 'bg-muted'), children: [_jsx("span", { className: "min-w-0 flex-1 basis-56", children: pickedCount > 0 ? (_jsxs(_Fragment, { children: [layout === 'grid' && (_jsx("span", { className: "num font-semibold", children: `${pickedRows.map(rowName).join(', ')} · ` })), _jsx("span", { className: "font-semibold", children: `${months(pickedCount)} picked` }), _jsxs("span", { className: "text-muted-foreground", children: [pickNote && queueCount > 0 ? ` · ${pickNote(pick)}` : '', start
542
+ ? ` · Tap another ${word} to ${start.remove ? 'unpick' : 'pick'} the range`
543
+ : ''] })] })) : (_jsx("span", { className: "text-muted-foreground", children: pickTools
544
+ ? 'Pick on the map, or'
545
+ : `Tap a ${word}, then another to pick the range` })) }), pickedCount > 0 ? (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
546
+ setPicked(new Set());
547
+ 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 ??
461
549
  (select === 'backfill'
462
- ? 'Click a block, shift-click for a range'
550
+ ? 'Tap a block, then another for a range'
463
551
  : select === 'drill'
464
552
  ? 'Click a block to see its days'
465
553
  : undefined) }) })] }) }) }) }));
@@ -874,7 +874,12 @@ function DetailsSection({ domain, draft, setDraft, shownKeys, }) {
874
874
  });
875
875
  }, children: SHAPES.map((s) => {
876
876
  const Icon = shapeIcon(s.value);
877
- return (_jsxs(ToggleGroupItem, { value: s.value, "aria-label": s.label, children: [_jsx(Icon, {}), s.label] }, s.value));
877
+ const note = s.value === 'table'
878
+ ? null
879
+ : (domain.groupNote ?? (s.value === 'pivot' ? domain.pivotNote : null));
880
+ const off = !!note && draft.shape !== s.value;
881
+ const item = (_jsxs(ToggleGroupItem, { value: s.value, "aria-label": s.label, disabled: off, children: [_jsx(Icon, {}), s.label] }, s.value));
882
+ return off ? (_jsx(WhyOff, { note: note, children: item }, s.value)) : (item);
878
883
  }) }) }), draft.shape === 'group' && (_jsx(Row, { label: "Group by", children: fieldSelect('Default grouping', draft.group.default ?? '', offered, (v) => setDraft({ ...draft, group: { ...draft.group, default: v === NONE ? null : v } }), 'No grouping') })), subgroupsOf(domain).length ? (_jsx(Row, { label: subgroupLabel(domain), children: _jsx(LumenSelector, { label: subgroupLabel(domain), size: "sm", className: "w-full min-w-0", options: [
879
884
  { value: NONE, label: 'None' },
880
885
  ...subgroupsOf(domain).map((sg) => ({
@@ -988,7 +993,8 @@ function ColumnList({ base, domain, draft, setDraft, shownKeys, hiddenKeys, onEd
988
993
  hidden: rowKeys.filter((k) => !shown.has(k)),
989
994
  });
990
995
  };
991
- const groupNote = (f) => f.groupNote ??
996
+ const groupNote = (f) => domain.groupNote ??
997
+ f.groupNote ??
992
998
  (f.format === 'text' ? 'Not offered for grouping.' : 'Numbers can’t be grouped.');
993
999
  if (!rows.length)
994
1000
  return _jsx("p", { className: "text-sm text-muted-foreground", children: "Tick fields on the right to show them." });
@@ -1005,7 +1011,7 @@ function ColumnList({ base, domain, draft, setDraft, shownKeys, hiddenKeys, onEd
1005
1011
  const g = groupInfo(f);
1006
1012
  const shown = shownKeys.includes(f.key);
1007
1013
  const canF = canFilterKeys.includes(f.key);
1008
- const canG = groupKeys.includes(f.key);
1014
+ const canG = !domain.groupNote && groupKeys.includes(f.key);
1009
1015
  return (_jsxs(ColumnRow, { id: f.key, className: grid, children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [_jsx("span", { className: cn('h-4 w-1 shrink-0 rounded-full', !shown && 'opacity-40'), style: { background: g.color }, "aria-hidden": true }), _jsx("span", { className: cn('truncate', !shown && 'text-muted-foreground'), children: f.label }), _jsx("span", { className: "truncate text-xs text-muted-foreground max-md:hidden", children: g.label }), !shown && (_jsx(Badge, { variant: "outline", className: "shrink-0 font-normal text-muted-foreground", children: "Not shown" })), isFormula(f.key) && (_jsx(IconButton, { icon: PencilIcon, size: "icon-xs", className: "text-muted-foreground", label: `Edit formula ${f.label}`, onClick: () => onEditFormula(f.key) }))] }), _jsx("span", { className: "flex justify-center", children: _jsx(Checkbox, { "aria-label": `Show ${f.label}`, checked: shown, onCheckedChange: (v) => setShown(f.key, v === true) }) }), canF ? (_jsx("span", { className: "flex justify-center", children: _jsx(Checkbox, { "aria-label": `Filter by ${f.label}`, checked: filterChoices.includes(f.key), onCheckedChange: (v) => setDraft({
1010
1016
  ...draft,
1011
1017
  filters: {
@@ -161,6 +161,11 @@ export interface ViewDomain<T> {
161
161
  fields: ViewField<T>[];
162
162
  /** Field groups in order; without them every field is one group. */
163
163
  groups?: ViewFieldGroup[];
164
+ /** Why its rows can't group at all (a server pages them, so a group would cover one page):
165
+ * Edit view greys the Group and Pivot shapes and every Group box with it. */
166
+ groupNote?: string;
167
+ /** Why its rows can't pivot (a server pages them): Edit view greys the Pivot shape with it. */
168
+ pivotNote?: string;
164
169
  /**
165
170
  * The keys its rows carry (views from data domains): a reader per key, null
166
171
  * for a row without one (cash). The shell's `useJoins` attaches the registry's join fields for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
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.41",
44
+ "@columenlabs/lumen-ui-types": "0.1.43",
45
45
  "@dnd-kit/core": "6.3.1",
46
46
  "@dnd-kit/sortable": "10.0.0",
47
47
  "@dnd-kit/utilities": "3.2.2",