@columenlabs/lumen-ui 0.1.45 → 0.1.47

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/dist/nav.js CHANGED
@@ -12,9 +12,33 @@ import { useFolded } from './fold.js';
12
12
  import { cn } from './lib/utils.js';
13
13
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.js';
14
14
  import { SidebarGroup, SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, useSidebar, } from './ui/sidebar.js';
15
+ /**
16
+ * How well a link matches where the person is: -1 when it doesn't, else higher for a closer
17
+ * match. A link's query (`/resources?label=a`) must be in the URL too, and each matched key
18
+ * outranks a bare path, so `?label=a` beats `/resources` on `/resources?label=a`.
19
+ */
20
+ function useMatch() {
21
+ const { path, search } = useRouterState({
22
+ select: (s) => ({ path: s.location.pathname, search: s.location.searchStr }),
23
+ });
24
+ const here = new URLSearchParams(search);
25
+ return ({ to, exact }) => {
26
+ const [base = '', query] = to.split('?');
27
+ const want = new URLSearchParams(query);
28
+ for (const [k, v] of want)
29
+ if (here.get(k) !== v)
30
+ return -1;
31
+ const keys = [...want.keys()].length;
32
+ if (path === base)
33
+ return 2 + keys * 10;
34
+ if (!exact && path.startsWith(`${base.replace(/\/$/, '')}/`))
35
+ return 1 + keys * 10;
36
+ return -1;
37
+ };
38
+ }
15
39
  function useActive() {
16
- const path = useRouterState({ select: (s) => s.location.pathname });
17
- return ({ to, exact }) => path === to || (!exact && path.startsWith(`${to.replace(/\/$/, '')}/`));
40
+ const match = useMatch();
41
+ return (i) => match(i) >= 0;
18
42
  }
19
43
  /**
20
44
  * A sidebar group whose header folds its items: bold caps, no icon. At rest
@@ -36,13 +60,13 @@ export function NavGroup({ label, items, pageType, }) {
36
60
  }
37
61
  const SECTION_BADGE = 'num group-data-[collapsible=icon]:hidden';
38
62
  /** One section item: a link, its badge, and the chevron and nested items when it has any. */
39
- function SectionRow({ item, active, onRemove, removeLabel, }) {
63
+ function SectionRow({ item, activeId, onRemove, removeLabel, }) {
40
64
  const subs = item.items ?? [];
41
- const subActive = subs.some((s) => active({ to: s.to, exact: true }));
42
- const isActive = active({ to: item.to, exact: true }) && !subActive;
65
+ const subActive = activeId?.startsWith(`${item.id}/`) ?? false;
66
+ const isActive = activeId === item.id;
43
67
  // The open item shows no trash icon.
44
68
  const remove = item.fixed || isActive ? undefined : onRemove;
45
- const row = (_jsxs(_Fragment, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.title, isActive: isActive, children: _jsxs(Link, { to: item.to, children: [_jsx(item.icon, {}), _jsx("span", { children: item.title })] }) }), item.badge !== undefined && item.badge !== null && (_jsx(SidebarMenuBadge, { className: cn(SECTION_BADGE, subs.length > 0 && 'right-7',
69
+ const row = (_jsxs(_Fragment, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.title, isActive: isActive, children: _jsxs(Link, { to: item.to, children: [_jsx(item.icon, {}), _jsx("span", { children: item.title })] }) }), item.badge !== undefined && item.badge !== null && (_jsx(SidebarMenuBadge, { className: cn(SECTION_BADGE,
46
70
  // The remove icon shows on hover from md up, and always below it: make room there.
47
71
  remove &&
48
72
  'max-md:right-7 md:group-focus-within/menu-item:opacity-0 md:group-hover/menu-item:opacity-0'), children: item.badge })), remove && (_jsx(LumenConfirm, { title: `${removeLabel(item)}?`, action: "Remove", onConfirm: () => remove(item), children: _jsx(SidebarMenuAction, { showOnHover: true, "aria-label": removeLabel(item), title: removeLabel(item),
@@ -50,7 +74,10 @@ function SectionRow({ item, active, onRemove, removeLabel, }) {
50
74
  className: "text-muted-foreground peer-hover/menu-button:text-muted-foreground hover:bg-transparent hover:text-destructive", children: _jsx(Trash2Icon, {}) }) }))] }));
51
75
  if (!subs.length)
52
76
  return _jsx(SidebarMenuItem, { children: row });
53
- return (_jsx(Collapsible, { asChild: true, defaultOpen: item.defaultOpen || subActive || active(item), className: "group/collapsible", children: _jsxs(SidebarMenuItem, { children: [row, _jsx(CollapsibleTrigger, { asChild: true, children: _jsx(SidebarMenuAction, { "aria-label": `Show or hide ${item.title}`, className: "data-[state=open]:rotate-90", children: _jsx(ChevronRightIcon, {}) }) }), _jsx(CollapsibleContent, { children: _jsx(SidebarMenuSub, { children: subs.map((sub) => (_jsx(SidebarMenuSubItem, { children: _jsx(SidebarMenuSubButton, { asChild: true, isActive: active({ to: sub.to, exact: true }), children: _jsxs(Link, { to: sub.to, children: [sub.color && (_jsx("span", { "aria-hidden": true, className: "size-2 shrink-0 rounded-full", style: { background: sub.color } })), _jsx("span", { children: sub.title })] }) }) }, sub.id))) }) })] }) }));
77
+ return (_jsx(Collapsible, { asChild: true, defaultOpen: item.defaultOpen || subActive || isActive, className: "group/collapsible", children: _jsxs(SidebarMenuItem, { children: [row, _jsx(CollapsibleTrigger, { asChild: true, children: _jsx(SidebarMenuAction, { "aria-label": `Show or hide ${item.title}`, className: "right-auto left-1.5 bg-sidebar opacity-0 group-hover/menu-item:opacity-100 focus-visible:opacity-100 peer-data-[active=true]/menu-button:bg-sidebar-accent [&>svg]:transition-transform data-[state=open]:[&>svg]:rotate-90", children: _jsx(ChevronRightIcon, {}) }) }), _jsx(CollapsibleContent, { children: _jsx(SidebarMenuSub, { className: "mr-0 pr-0", children: subs.map((sub) => {
78
+ const hasBadge = sub.badge !== undefined && sub.badge !== null;
79
+ return (_jsxs(SidebarMenuSubItem, { children: [_jsx(SidebarMenuSubButton, { asChild: true, isActive: activeId === `${item.id}/${sub.id}`, className: cn(hasBadge && 'pr-8'), children: _jsxs(Link, { to: sub.to, title: sub.title, children: [sub.icon ? (_jsx("span", { "aria-hidden": true, className: "flex shrink-0 [&>svg]:size-4", style: { color: sub.color }, children: _jsx(sub.icon, {}) })) : (sub.color && (_jsx("span", { "aria-hidden": true, className: "mx-1 size-2 shrink-0 rounded-full", style: { background: sub.color } }))), _jsx("span", { children: sub.title })] }) }), hasBadge && (_jsx("span", { className: cn('pointer-events-none absolute top-1 right-1 flex h-5 min-w-5 items-center justify-center px-1 text-xs text-muted-foreground', typeof sub.badge === 'number' && 'num'), children: sub.badge }))] }, sub.id));
80
+ }) }) })] }) }));
54
81
  }
55
82
  /**
56
83
  * A section of things a person keeps (views, trades, a watchlist): `+` adds one, the
@@ -66,8 +93,22 @@ export function SidebarSection({ label, items, onAdd, addLabel = `Add to ${label
66
93
  type: assistantSubType ?? 'page',
67
94
  })),
68
95
  ]), label, pageType);
69
- const active = useActive();
96
+ const match = useMatch();
97
+ // One selected row per section: the closest match wins, the first on a tie, so two items that
98
+ // open the same page never both look selected. An open parent is never selected for being open.
99
+ let activeId = null;
100
+ let best = -1;
101
+ for (const i of items) {
102
+ const own = match({ to: i.to, exact: true });
103
+ if (own > best)
104
+ [best, activeId] = [own, i.id];
105
+ for (const sub of i.items ?? []) {
106
+ const m = match({ to: sub.to, exact: true });
107
+ if (m > best)
108
+ [best, activeId] = [m, `${i.id}/${sub.id}`];
109
+ }
110
+ }
70
111
  return (_jsx(FoldGroup, { label: label, action: onAdd && (_jsx(SidebarGroupAction, { title: addLabel, "aria-label": addLabel, onClick: onAdd, children: _jsx(PlusIcon, {}) })), children: _jsxs(SidebarGroupContent, { children: [_jsxs(SidebarMenu, { children: [loading
71
112
  ? [0, 1].map((i) => (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuSkeleton, { showIcon: true }) }, i)))
72
- : items.map((item) => (_jsx(SectionRow, { item: item, active: active, onRemove: onRemove, removeLabel: removeLabel }, item.id))), !loading && !items.length && empty && (_jsx("li", { className: "px-2 py-1 text-xs text-muted-foreground group-data-[collapsible=icon]:hidden", children: empty }))] }), children] }) }));
113
+ : items.map((item) => (_jsx(SectionRow, { item: item, activeId: activeId, onRemove: onRemove, removeLabel: removeLabel }, item.id))), !loading && !items.length && empty && (_jsx("li", { className: "px-2 py-1 text-xs text-muted-foreground group-data-[collapsible=icon]:hidden", children: empty }))] }), children] }) }));
73
114
  }
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from 'react';
2
+ export declare function ShareMeter({ value, label, color, wide, className, }: {
3
+ /** 0 to 1 fills the bar; past 1 it stays full. */
4
+ value: number;
5
+ /** The number beside the bar, e.g. `113%`; left out, the rounded percent. */
6
+ label?: ReactNode;
7
+ /** The bar's colour, a theme token (`var(--loss)` when over). */
8
+ color?: string;
9
+ /** Fills its cell (a list row) instead of the fixed table width. */
10
+ wide?: boolean;
11
+ className?: string;
12
+ }): import("react").JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from './lib/utils.js';
3
+ import { Progress } from './ui/progress.js';
4
+ export function ShareMeter({ value, label, color = 'var(--primary)', wide, className, }) {
5
+ return (_jsxs("span", { className: cn('inline-flex items-center gap-2', wide && 'w-full', className), children: [_jsx(Progress, { value: Math.max(0, Math.min(value, 1)) * 100, className: cn('h-1.5 shrink-0 [&>[data-slot=progress-indicator]]:bg-(--meter-color)', wide ? 'w-full shrink' : 'w-14'), style: { '--meter-color': color } }), _jsx("span", { className: "num w-11 shrink-0 text-right text-xs", children: label ?? `${Math.round(value * 100)}%` })] }));
6
+ }
package/dist/tile.d.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import type { ReactNode } from 'react';
2
- export declare function Tile({ label, value, caption, lines, className, valueClassName, }: {
2
+ /** A number's history as a small line and area, scaled to its own range; `color` is a theme
3
+ * token (SVG reads CSS variables, so it follows the theme). Fewer than two points draw nothing. */
4
+ export declare function TileSpark({ values, color, className, }: {
5
+ values: number[];
6
+ color?: string;
7
+ className?: string;
8
+ }): import("react").JSX.Element | null;
9
+ export declare function Tile({ label, value, caption, lines, className, valueClassName, spark, sparkColor, }: {
3
10
  label: ReactNode;
4
11
  value: ReactNode;
5
12
  caption?: ReactNode;
@@ -7,13 +14,19 @@ export declare function Tile({ label, value, caption, lines, className, valueCla
7
14
  lines?: ReactNode[];
8
15
  className?: string;
9
16
  valueClassName?: string;
17
+ /** The number's history (oldest first), drawn as a small line under it: a metric card. */
18
+ spark?: number[];
19
+ /** The line's colour, a theme token; left out, `var(--chart-1)`. */
20
+ sparkColor?: string;
10
21
  }): import("react").JSX.Element;
11
22
  /** Width of every header tile on desktop, toggle tiles and plain Tiles alike; shrinks to fit the row. */
12
23
  export declare const TILE_WIDTH = "lg:w-(--tile-width) lg:flex-initial";
13
- /** A page's header row of tiles: wraps on phones, one line on desktop. */
14
- export declare function TileRow({ children, className, ...props }: {
24
+ /** A page's header row of tiles: wraps on phones, one line on desktop. `fill` shares the row's
25
+ * width between its tiles (a row of metric cards) instead of the fixed tile width. */
26
+ export declare function TileRow({ children, className, fill, ...props }: {
15
27
  children: ReactNode;
16
28
  className?: string;
29
+ fill?: boolean;
17
30
  'aria-label'?: string;
18
31
  }): import("react").JSX.Element;
19
32
  export interface TileOption {
@@ -21,14 +34,19 @@ export interface TileOption {
21
34
  label: string;
22
35
  value: ReactNode;
23
36
  caption?: ReactNode;
37
+ /** The option's history, a small line under its number (see `Tile`). */
38
+ spark?: number[];
39
+ sparkColor?: string;
24
40
  }
25
- export declare function TileToggleGroup({ options, selected, onSelect, label, className, }: {
41
+ export declare function TileToggleGroup({ options, selected, onSelect, label, className, fill, }: {
26
42
  options: TileOption[];
27
43
  selected: string;
28
44
  onSelect: (id: string) => void;
29
45
  /** Accessible name of the group. */
30
46
  label: string;
31
47
  className?: string;
48
+ /** Spans the row, its tiles sharing the width (see `TileRow`). */
49
+ fill?: boolean;
32
50
  }): import("react").JSX.Element;
33
51
  /** A compact tile: a small caps label over one short value (a quote strip's IV, expiry and move). */
34
52
  export declare function TileChip({ label, value }: {
package/dist/tile.js CHANGED
@@ -2,20 +2,35 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { cn } from './lib/utils.js';
3
3
  import { Card } from './ui/card.js';
4
4
  import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
5
- function TileBody({ label, value, caption, lines, valueClassName, }) {
6
- return (_jsxs(_Fragment, { children: [_jsx("span", { className: "w-full truncate text-xs text-muted-foreground", children: label }), _jsx("span", { className: cn('num truncate text-xl leading-7 font-semibold', valueClassName), children: value }), caption ? (_jsx("span", { className: "num line-clamp-2 text-xs text-muted-foreground", children: caption })) : null, lines?.map((l, i) => (_jsx("span", { className: "num line-clamp-2 text-xs text-muted-foreground", children: l }, i)))] }));
5
+ /** A number's history as a small line and area, scaled to its own range; `color` is a theme
6
+ * token (SVG reads CSS variables, so it follows the theme). Fewer than two points draw nothing. */
7
+ export function TileSpark({ values, color = 'var(--chart-1)', className, }) {
8
+ if (values.length < 2)
9
+ return null;
10
+ const max = Math.max(...values);
11
+ const min = Math.min(0, ...values);
12
+ const span = max - min || 1;
13
+ const pts = values.map((v, i) => `${((i / (values.length - 1)) * 100).toFixed(2)},${(22 - ((v - min) / span) * 20).toFixed(2)}`);
14
+ return (_jsxs("svg", { viewBox: "0 0 100 24", preserveAspectRatio: "none", "aria-hidden": "true",
15
+ // Inline size: a parent's `[&_svg]:size-4` (toggle items, buttons) must not shrink it.
16
+ style: { width: '100%', height: '1.5rem' }, className: cn('overflow-visible', className), children: [_jsx("polygon", { points: `0,24 ${pts.join(' ')} 100,24`, fill: color, fillOpacity: 0.15 }), _jsx("polyline", { points: pts.join(' '), fill: "none", stroke: color, strokeWidth: 1.5, strokeLinejoin: "round", vectorEffect: "non-scaling-stroke" })] }));
7
17
  }
8
- export function Tile({ label, value, caption, lines, className, valueClassName, }) {
9
- return (_jsx(Card, { size: "sm", className: cn('min-w-0 gap-0.5 px-3', className), children: _jsx(TileBody, { label: label, value: value, caption: caption, lines: lines, valueClassName: valueClassName }) }));
18
+ function TileBody({ label, value, caption, lines, valueClassName, spark, sparkColor, }) {
19
+ return (_jsxs(_Fragment, { children: [_jsx("span", { className: "w-full truncate text-xs text-muted-foreground", children: label }), _jsx("span", { className: cn('num truncate text-xl leading-7 font-semibold', valueClassName), children: value }), caption ? (_jsx("span", { className: "num line-clamp-2 text-xs text-muted-foreground", children: caption })) : null, lines?.map((l, i) => (_jsx("span", { className: "num line-clamp-2 text-xs text-muted-foreground", children: l }, i))), spark ? _jsx(TileSpark, { values: spark, color: sparkColor, className: "mt-1 self-stretch" }) : null] }));
20
+ }
21
+ export function Tile({ label, value, caption, lines, className, valueClassName, spark, sparkColor, }) {
22
+ return (_jsx(Card, { size: "sm", className: cn('min-w-0 flex-1 basis-2/5 gap-0.5 px-3 lg:basis-auto', TILE_WIDTH, className), children: _jsx(TileBody, { label: label, value: value, caption: caption, lines: lines, valueClassName: valueClassName, spark: spark, sparkColor: sparkColor }) }));
10
23
  }
11
24
  /** Width of every header tile on desktop, toggle tiles and plain Tiles alike; shrinks to fit the row. */
12
25
  export const TILE_WIDTH = 'lg:w-(--tile-width) lg:flex-initial';
13
- /** A page's header row of tiles: wraps on phones, one line on desktop. */
14
- export function TileRow({ children, className, ...props }) {
15
- return (_jsx("div", { className: cn('flex flex-wrap gap-2.5 lg:flex-nowrap', className), ...props, children: children }));
26
+ /** A page's header row of tiles: wraps on phones, one line on desktop. `fill` shares the row's
27
+ * width between its tiles (a row of metric cards) instead of the fixed tile width. */
28
+ export function TileRow({ children, className, fill, ...props }) {
29
+ return (_jsx("div", { className: cn('flex flex-wrap gap-2.5 lg:flex-nowrap', fill && 'lg:*:w-auto lg:*:flex-1', className), ...props, children: children }));
16
30
  }
17
- export function TileToggleGroup({ options, selected, onSelect, label, className, }) {
18
- return (_jsx(Card, { size: "sm", className: cn('min-w-0 gap-0 bg-border py-0 lg:w-fit lg:flex-initial', className), children: _jsx(ToggleGroup, { type: "single", spacing: 0, value: selected, onValueChange: (v) => v && onSelect(v), "aria-label": label, className: "grid w-full grid-cols-[repeat(auto-fit,minmax(6.5rem,1fr))] gap-px lg:flex lg:w-fit", children: options.map((o) => (_jsx(ToggleGroupItem, { value: o.id, "aria-label": o.label, className: "h-auto min-w-0 flex-col lg:w-(--tile-width) lg:shrink items-start justify-start gap-0.5 rounded-none! bg-card px-3 py-3 text-left font-normal data-[state=on]:bg-primary/5 data-[state=on]:shadow-[inset_0_-2px_0_var(--primary)]", children: _jsx(TileBody, { label: o.label, value: o.value, caption: o.caption }) }, o.id))) }) }));
31
+ export function TileToggleGroup({ options, selected, onSelect, label, className, fill, }) {
32
+ return (_jsx(Card, { size: "sm", className: cn('min-w-0 gap-0 bg-border py-0 lg:w-fit lg:flex-initial', fill &&
33
+ 'lg:w-full lg:*:data-[slot=toggle-group]:w-full lg:**:data-[slot=toggle-group-item]:flex-1', className), children: _jsx(ToggleGroup, { type: "single", spacing: 0, value: selected, onValueChange: (v) => v && onSelect(v), "aria-label": label, className: "grid w-full grid-cols-[repeat(auto-fit,minmax(6.5rem,1fr))] gap-px lg:flex lg:w-fit", children: options.map((o) => (_jsx(ToggleGroupItem, { value: o.id, "aria-label": o.label, className: "h-auto min-w-0 flex-col lg:w-(--tile-width) lg:shrink items-start justify-start gap-0.5 rounded-none! bg-card px-3 py-3 text-left font-normal data-[state=on]:bg-primary/5 data-[state=on]:shadow-[inset_0_-2px_0_var(--primary)]", children: _jsx(TileBody, { label: o.label, value: o.value, caption: o.caption, spark: o.spark, sparkColor: o.sparkColor }) }, o.id))) }) }));
19
34
  }
20
35
  /** A compact tile: a small caps label over one short value (a quote strip's IV, expiry and move). */
21
36
  export function TileChip({ label, value }) {
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from 'react';
2
+ export declare function UnitValue({ value, unit, tone, title, className, }: {
3
+ /** The number, already formatted (`231.4`). */
4
+ value: ReactNode;
5
+ /** Its unit (`GB`, `M`, `/day`); left out, the slot stays empty so the column still lines up. */
6
+ unit?: ReactNode;
7
+ /** Near (`warning`) or past (`loss`) a limit: tints the number and shows a dot. */
8
+ tone?: 'warning' | 'loss';
9
+ title?: string;
10
+ className?: string;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from './lib/utils.js';
3
+ export function UnitValue({ value, unit, tone, title, className, }) {
4
+ 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') })] }));
5
+ }
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from 'react';
1
+ import { type ComponentType, type ReactNode } from 'react';
2
2
  export declare function EditCell({ label, value, options, unused, chip, note, colorOf, fixed, edited, onSave, }: {
3
3
  /** The field's name, e.g. `Basket`. */
4
4
  label: string;
@@ -23,13 +23,17 @@ export declare function EditCell({ label, value, options, unused, chip, note, co
23
23
  * a tap opens a picker that ticks any number and takes a typed name, and closing it saves them all
24
24
  * at once. Clicks stay off the row, which opens its details.
25
25
  */
26
- export declare function EditManyCell({ label, values, options, unused, note, colorOf, onSave, }: {
26
+ export declare function EditManyCell({ label, values, options, unused, note, colorOf, iconOf, onSave, }: {
27
27
  label: string;
28
28
  values: string[];
29
29
  options: string[];
30
30
  unused?: string[];
31
31
  note?: string;
32
32
  colorOf?: (value: string) => string | undefined;
33
+ /** A value's icon (a label's), drawn in its colour on its chip and in the picker. */
34
+ iconOf?: (value: string) => ComponentType<{
35
+ className?: string;
36
+ }> | undefined;
33
37
  onSave: (values: string[]) => Promise<unknown>;
34
38
  }): import("react").JSX.Element;
35
39
  /**
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // opens its details.
5
5
  import { PencilLineIcon, PlusIcon, XIcon } from 'lucide-react';
6
6
  import { useEffect, useRef, useState } from 'react';
7
- import { ColorChip, colorIcon } from './color-dot.js';
7
+ import { ColorChip, colorIcon, tintedIcon } from './color-dot.js';
8
8
  import { cn } from './lib/utils.js';
9
9
  import { LumenSelector } from './lumen-selector.js';
10
10
  import { Input } from './ui/input.js';
@@ -69,7 +69,7 @@ export function EditCell({ label, value, options, unused = [], chip, note, color
69
69
  * a tap opens a picker that ticks any number and takes a typed name, and closing it saves them all
70
70
  * at once. Clicks stay off the row, which opens its details.
71
71
  */
72
- export function EditManyCell({ label, values, options, unused = [], note, colorOf, onSave, }) {
72
+ export function EditManyCell({ label, values, options, unused = [], note, colorOf, iconOf, onSave, }) {
73
73
  const [open, setOpen] = useState(false);
74
74
  const [draft, setDraft] = useState(values);
75
75
  const [error, setError] = useState(null);
@@ -99,7 +99,12 @@ export function EditManyCell({ label, values, options, unused = [], note, colorO
99
99
  };
100
100
  const row = (o) => {
101
101
  const c = colorOf?.(o);
102
- return { value: o, label: o, ...(c ? { icon: colorIcon(c) } : {}) };
102
+ const i = iconOf?.(o);
103
+ return {
104
+ value: o,
105
+ label: o,
106
+ ...(i ? { icon: tintedIcon(i, c) } : c ? { icon: colorIcon(c) } : {}),
107
+ };
103
108
  };
104
109
  const all = [...options, ...unused];
105
110
  const picked = draft.filter((v) => !all.includes(v));
@@ -107,7 +112,7 @@ export function EditManyCell({ label, values, options, unused = [], note, colorO
107
112
  { label: 'In use', options: [...options, ...picked].map(row) },
108
113
  { label: 'Unused', options: unused.map(row) },
109
114
  ];
110
- return (_jsxs("span", { className: "inline-flex min-w-0 items-center gap-1", onClick: stop, onKeyDown: stop, children: [values.length > 0 && (_jsx("button", { type: "button", "aria-label": `${label}: ${values.join(', ')}`, className: "flex min-w-0 flex-wrap items-center gap-1 rounded text-left", onClick: start, children: values.map((v) => (_jsx(ColorChip, { color: colorOf?.(v), children: v }, v))) })), _jsx(LumenSelector, { multiple: true, trigger: "icon", icon: values.length ? PencilLineIcon : PlusIcon, size: "sm", label: values.length ? `Change ${label.toLowerCase()}` : `Add ${label.toLowerCase()}`, className: cn('size-6 text-muted-foreground', values.length
115
+ return (_jsxs("span", { className: "inline-flex min-w-0 items-center gap-1", onClick: stop, onKeyDown: stop, children: [values.length > 0 && (_jsx("button", { type: "button", "aria-label": `${label}: ${values.join(', ')}`, className: "flex min-w-0 items-center gap-1 overflow-hidden rounded text-left", onClick: start, children: values.map((v) => (_jsx(ColorChip, { color: colorOf?.(v), icon: iconOf?.(v), children: v }, v))) })), _jsx(LumenSelector, { multiple: true, trigger: "icon", icon: values.length ? PencilLineIcon : PlusIcon, size: "sm", label: values.length ? `Change ${label.toLowerCase()}` : `Add ${label.toLowerCase()}`, className: cn('size-6 text-muted-foreground', values.length
111
116
  ? 'opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100 max-md:opacity-100'
112
117
  : '-ml-1'), groups: groups, value: draft, disabled: saving, empty: `Type a ${label.toLowerCase()} name.`, create: (q) => q.trim() || null, createLabel: (q) => `Add “${q.trim()}”`, open: open, onOpenChange: (o) => {
113
118
  if (o)
@@ -1033,7 +1033,13 @@ function ColumnList({ base, domain, draft, setDraft, shownKeys, hiddenKeys, onEd
1033
1033
  }) }) })) : (_jsx(WhyOff, { note: groupNote(f), children: _jsx(Checkbox, { "aria-label": `Group by ${f.label}`, disabled: true, checked: false }) })), _jsx("span", { className: "max-md:hidden", children: chippable(f) ? (_jsx(LumenSelector, { label: `Show ${f.label} as`, size: "sm", className: "h-7 w-full", options: Object.entries(CHIP_LABELS).map(([v, l]) => ({
1034
1034
  value: v,
1035
1035
  label: v === 'inline' ? `On ${first?.label ?? 'first'}` : l,
1036
- })), value: placement(f.key), onChange: (v) => setPlacement(f.key, v) })) : f.format !== 'text' ? (_jsx(LumenSelector, { label: `Format of ${f.label}`, size: "sm", className: "h-7 w-full", options: formatOptions(), value: f.format, onChange: (v) => setDraft({
1036
+ })), value: placement(f.key), onChange: (v) => setPlacement(f.key, v) })) : f.showAs && f.showAs.length > 1 ? (_jsx(LumenSelector, { label: `Show ${f.label} as`, size: "sm", className: "h-7 w-full", options: f.showAs.map((o) => ({ value: o.id, label: o.label })), value: draft.shows?.[f.key] ?? f.showAs[0]?.id ?? '', onChange: (v) => {
1037
+ const { [f.key]: _, ...rest } = draft.shows ?? {};
1038
+ setDraft({
1039
+ ...draft,
1040
+ shows: v === f.showAs?.[0]?.id ? rest : { ...rest, [f.key]: v },
1041
+ });
1042
+ } })) : f.format !== 'text' ? (_jsx(LumenSelector, { label: `Format of ${f.label}`, size: "sm", className: "h-7 w-full", options: formatOptions(), value: f.format, onChange: (v) => setDraft({
1037
1043
  ...draft,
1038
1044
  formats: {
1039
1045
  ...draft.formats,
@@ -1,7 +1,7 @@
1
1
  import type { JoinKey } from '@columenlabs/lumen-ui-types/domains';
2
2
  import { type CustomViewConfig, type ViewAgg, type ViewChip, type ViewFormat, type ViewTotalAgg } from '@columenlabs/lumen-ui-types/views';
3
3
  import type { LucideIcon } from 'lucide-react';
4
- import type { ReactNode } from 'react';
4
+ import type { ComponentType, ReactNode } from 'react';
5
5
  import type { ChartBand, ChartMarker, Series } from './growth-chart.js';
6
6
  export type ViewCellValue = number | string | null;
7
7
  /** One field of a data domain: how to read it, show it, filter, group and roll it up. */
@@ -95,6 +95,22 @@ export interface ViewField<T> {
95
95
  /** Draws the cell instead of its formatted value (a sparkline); sorting and filters still use
96
96
  * `value` (strategy lab). */
97
97
  render?: (row: T) => ReactNode;
98
+ /**
99
+ * Draws a group or Total row's cell from its rows instead of the rolled-up number: an amount with
100
+ * its unit, or a share worked out again as the rows' total over their allowance (never a sum of
101
+ * percents). Only where the field rolls up (`aggregate`).
102
+ */
103
+ rollRender?: (rows: T[]) => ReactNode;
104
+ /**
105
+ * Ways the column can read, picked per column in Edit view ("Show as"), e.g. an amount or its % of
106
+ * an allowance. The first is the field as defined; each other one's `field` replaces those parts
107
+ * of it (value, format, render, aggregate...). The pick saves with the view (`shows`).
108
+ */
109
+ showAs?: {
110
+ id: string;
111
+ label: string;
112
+ field?: Partial<Omit<ViewField<T>, 'key' | 'showAs'>>;
113
+ }[];
98
114
  /** The ticker a click on the cell charts in the app's chart dialog (an option charts its
99
115
  * underlying); null leaves the row's cell plain. */
100
116
  chart?: (row: T) => string | null;
@@ -107,6 +123,10 @@ export interface ViewField<T> {
107
123
  * cell, its chip and the edit picker. A CSS colour; a theme token reads in light and dark.
108
124
  */
109
125
  color?: (value: string) => string | undefined;
126
+ /** A text value's icon (a label's), drawn in its `color` on its chip and in the edit picker. */
127
+ iconOf?: (value: string) => ComponentType<{
128
+ className?: string;
129
+ }> | undefined;
110
130
  /** The cell's background, a theme colour mix (a P/L heat grid). */
111
131
  tint?: (row: T) => string | undefined;
112
132
  /** Left out on phones, where the table has no room for it (a returns table's longer windows). */
@@ -323,7 +343,7 @@ export declare const tilesOf: (c: CustomViewConfig) => ({
323
343
  key: string;
324
344
  value: string;
325
345
  not?: boolean | undefined;
326
- op?: "between" | "eq" | "gt" | "lt" | undefined;
346
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
327
347
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
328
348
  }[];
329
349
  sign: boolean;
@@ -336,7 +356,7 @@ export declare const tilesOf: (c: CustomViewConfig) => ({
336
356
  key: string;
337
357
  value: string;
338
358
  not?: boolean | undefined;
339
- op?: "between" | "eq" | "gt" | "lt" | undefined;
359
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
340
360
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
341
361
  }[];
342
362
  suffix: string;
@@ -101,14 +101,22 @@ export function withViewFields(domain, c) {
101
101
  const own = takesFormulas(domain) && formulasOf(c).length ? formulaFields(formulasOf(c)) : [];
102
102
  const picked = c.formats ?? {};
103
103
  const aggs = c.aggs ?? {};
104
- if (!own.length && !Object.keys(picked).length && !Object.keys(aggs).length)
104
+ const shows = c.shows ?? {};
105
+ if (!own.length &&
106
+ !Object.keys(picked).length &&
107
+ !Object.keys(aggs).length &&
108
+ !Object.keys(shows).length)
105
109
  return domain;
106
110
  const mine = new Set(own.map((f) => f.key));
107
111
  // A text formula groups like any text field: each row's value is the formula
108
112
  // over that row alone. One that reads group_sum or total_sum depends on the rows shown, so it
109
113
  // cannot pick its own group.
110
114
  const rowValue = (f) => (r) => computedOf(full, [r], null).value(f, r);
111
- const fields = [...domain.fields, ...own].map((f) => {
115
+ const shown = (f) => {
116
+ const alt = f.showAs?.find((o) => o.id === shows[f.key]);
117
+ return alt?.field ? { ...f, ...alt.field } : f;
118
+ };
119
+ const fields = [...domain.fields, ...own].map(shown).map((f) => {
112
120
  if (f.format === 'text') {
113
121
  if (!mine.has(f.key) || !f.formula)
114
122
  return f;
package/dist/whats-new.js CHANGED
@@ -3,7 +3,8 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
3
3
  // every app): the version in the status bar opens the release notes, newest
4
4
  // first, each release's Key takeaways only. An app turns it on with LumenShell's `releases`, its
5
5
  // releases API (`GET ?limit=&before=` answering `{releases}`), which leaves out what the person can't
6
- // open; a host that answers 404 (self-host an app) keeps the version hidden. With `apps`, each
6
+ // open; a host that answers 404 (self-host an app) keeps the button hidden. Until the first release
7
+ // the button reads "What's new" and opens an empty state, so a new app shows it from day one. With `apps`, each
7
8
  // takeaway carries a chip of the app it is about (`apps` on the takeaway; none: everyone), and a
8
9
  // release where one app has `GROUP_AT` or more is split into one sub-section per app.
9
10
  import { useInfiniteQuery } from '@tanstack/react-query';
@@ -50,12 +51,13 @@ export function WhatsNew({ api, apps, everyone = 'Global', }) {
50
51
  staleTime: 30 * 60_000,
51
52
  retry: false,
52
53
  });
53
- const releases = q.data?.pages.flatMap((p) => p ?? []) ?? [];
54
- const latest = releases[0];
55
- if (!latest)
54
+ // Hidden while loading, on an error, and when the host has no releases API (404).
55
+ if (!q.data || q.data.pages[0] === null)
56
56
  return null;
57
+ const releases = q.data.pages.flatMap((p) => p ?? []);
58
+ const latest = releases[0];
57
59
  const day = (at) => new Date(at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone });
58
- return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", className: "num h-6 px-1.5 text-muted-foreground text-xs", onClick: () => setOpen(true), title: "What's new", children: latest.tag }), _jsx(LumenDrawer, { open: open, onClose: () => setOpen(false), title: "What's new", description: "Key takeaways of each release, newest first", footerStart: `${releases.length} releases`, footer: _jsxs(_Fragment, { children: [q.hasNextPage && (_jsx(Button, { variant: "outline", size: "sm", disabled: q.isFetchingNextPage, onClick: () => q.fetchNextPage(), children: "Show more" })), _jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Close" })] }), children: releases.map((r, i) => (_jsx(DrawerSection, { title: r.tag, hint: day(r.published_at), defaultOpen: i === 0, children: r.takeaways.length ? (_jsx(Notes, { takeaways: r.takeaways, apps: apps, everyone: everyone })) : (_jsx("p", { className: "py-1 pl-1 text-muted-foreground text-sm", children: "Nothing new for you in this one." })) }, r.tag))) })] }));
60
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", className: "num h-6 px-1.5 text-muted-foreground text-xs", onClick: () => setOpen(true), title: "What's new", children: latest?.tag ?? "What's new" }), _jsxs(LumenDrawer, { open: open, onClose: () => setOpen(false), title: "What's new", description: "Key takeaways of each release, newest first", footerStart: releases.length ? `${releases.length} releases` : undefined, footer: _jsxs(_Fragment, { children: [q.hasNextPage && (_jsx(Button, { variant: "outline", size: "sm", disabled: q.isFetchingNextPage, onClick: () => q.fetchNextPage(), children: "Show more" })), _jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Close" })] }), children: [!releases.length && (_jsx("p", { className: "py-2 text-muted-foreground text-sm", children: "No releases yet. Each release's key takeaways show here once it ships." })), releases.map((r, i) => (_jsx(DrawerSection, { title: r.tag, hint: day(r.published_at), defaultOpen: i === 0, children: r.takeaways.length ? (_jsx(Notes, { takeaways: r.takeaways, apps: apps, everyone: everyone })) : (_jsx("p", { className: "py-1 pl-1 text-muted-foreground text-sm", children: "Nothing new for you in this one." })) }, r.tag)))] })] }));
59
61
  }
60
62
  /** One release's takeaways: a flat list, or one sub-section per app once one app has many. */
61
63
  function Notes({ takeaways, apps, everyone, }) {
@@ -72,7 +72,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
72
72
  key: string;
73
73
  value: string;
74
74
  not?: boolean | undefined;
75
- op?: "between" | "eq" | "gt" | "lt" | undefined;
75
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
76
76
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
77
77
  }[];
78
78
  source?: string | undefined;
@@ -97,6 +97,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
97
97
  }[];
98
98
  formats?: Record<string, "compact" | "integer" | "number" | "percent" | "price" | "price_rounded" | "price_signed" | "rounded" | "signed" | "weight"> | undefined;
99
99
  aggs?: Record<string, "count" | "distinct" | "first" | "last" | "max" | "mean" | "min" | "none" | "sum"> | undefined;
100
+ shows?: Record<string, string> | undefined;
100
101
  group: {
101
102
  fields: string[];
102
103
  default: string | null;
@@ -135,7 +136,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
135
136
  key: string;
136
137
  value: string;
137
138
  not?: boolean | undefined;
138
- op?: "between" | "eq" | "gt" | "lt" | undefined;
139
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
139
140
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
140
141
  }[];
141
142
  source?: string | undefined;
@@ -170,7 +171,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
170
171
  key: string;
171
172
  value: string;
172
173
  not?: boolean | undefined;
173
- op?: "between" | "eq" | "gt" | "lt" | undefined;
174
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
174
175
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
175
176
  }[];
176
177
  source?: string | undefined;
@@ -195,7 +196,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
195
196
  key: string;
196
197
  value: string;
197
198
  not?: boolean | undefined;
198
- op?: "between" | "eq" | "gt" | "lt" | undefined;
199
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
199
200
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
200
201
  }[];
201
202
  sign: boolean;
@@ -208,7 +209,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
208
209
  key: string;
209
210
  value: string;
210
211
  not?: boolean | undefined;
211
- op?: "between" | "eq" | "gt" | "lt" | undefined;
212
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
212
213
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
213
214
  }[];
214
215
  suffix: string;
@@ -246,7 +247,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
246
247
  key: string;
247
248
  value: string;
248
249
  not?: boolean | undefined;
249
- op?: "between" | "eq" | "gt" | "lt" | undefined;
250
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
250
251
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
251
252
  }[];
252
253
  source?: string | undefined;
@@ -270,7 +271,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
270
271
  key: string;
271
272
  value: string;
272
273
  not?: boolean | undefined;
273
- op?: "between" | "eq" | "gt" | "lt" | undefined;
274
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
274
275
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
275
276
  }[];
276
277
  sign: boolean;
@@ -283,7 +284,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
283
284
  key: string;
284
285
  value: string;
285
286
  not?: boolean | undefined;
286
- op?: "between" | "eq" | "gt" | "lt" | undefined;
287
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
287
288
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
288
289
  }[];
289
290
  suffix: string;
@@ -316,7 +317,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
316
317
  key: string;
317
318
  value: string;
318
319
  not?: boolean | undefined;
319
- op?: "between" | "eq" | "gt" | "lt" | undefined;
320
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
320
321
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
321
322
  }[];
322
323
  source?: string | undefined;
@@ -353,7 +354,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
353
354
  key: string;
354
355
  value: string;
355
356
  not?: boolean | undefined;
356
- op?: "between" | "eq" | "gt" | "lt" | undefined;
357
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
357
358
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
358
359
  }[];
359
360
  source?: string | undefined;
@@ -388,7 +389,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
388
389
  key: string;
389
390
  value: string;
390
391
  not?: boolean | undefined;
391
- op?: "between" | "eq" | "gt" | "lt" | undefined;
392
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
392
393
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
393
394
  }[];
394
395
  source?: string | undefined;
@@ -423,7 +424,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
423
424
  key: string;
424
425
  value: string;
425
426
  not?: boolean | undefined;
426
- op?: "between" | "eq" | "gt" | "lt" | undefined;
427
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
427
428
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
428
429
  }[];
429
430
  source?: string | undefined;
@@ -461,7 +462,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
461
462
  key: string;
462
463
  value: string;
463
464
  not?: boolean | undefined;
464
- op?: "between" | "eq" | "gt" | "lt" | undefined;
465
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
465
466
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
466
467
  }[];
467
468
  source?: string | undefined;
@@ -493,7 +494,7 @@ export declare const readsRows: (card: ViewWidget) => card is {
493
494
  key: string;
494
495
  value: string;
495
496
  not?: boolean | undefined;
496
- op?: "between" | "eq" | "gt" | "lt" | undefined;
497
+ op?: "between" | "empty" | "eq" | "gt" | "lt" | undefined;
497
498
  link?: "amber" | "blue" | "lime" | "pink" | "teal" | undefined;
498
499
  }[];
499
500
  source?: string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
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.45",
44
+ "@columenlabs/lumen-ui-types": "0.1.47",
45
45
  "@dnd-kit/core": "6.3.1",
46
46
  "@dnd-kit/sortable": "10.0.0",
47
47
  "@dnd-kit/utilities": "3.2.2",