@columenlabs/lumen-ui 0.1.159 → 0.1.160

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/index.d.ts CHANGED
@@ -89,7 +89,8 @@ export { type ConnectedDomain, ConnectOffer, connectionFields, linkGroupId } fro
89
89
  export { FRESHNESS, type Freshness, FreshnessIcon, FreshnessMark, freshnessLine, freshnessOf, } from './freshness.js';
90
90
  export { SourcesLine, useSourceHealth } from './source-health.js';
91
91
  export { TILE_WIDTH, Tile, TileChip, type TileOption, TileRow, TileSpark, TileToggleGroup, } from './tile.js';
92
- export { type FilterField, type FilterToken, LumenTokenizedFilter, type LumenFilterState, useLumenFilter, } from './lumen-filter.js';
92
+ export { type FacetCounts, type FilterField, type FilterToken, LumenTokenizedFilter, type LumenFilterState, countsShown, useFacetCounts, useLumenFilter, } from './lumen-filter.js';
93
+ export { LumenFilterPanel, LumenFilterPanelButton, type LumenFilterPanelProps, useFilterPanel, } from './lumen-filter-panel.js';
93
94
  export { useIsMobile } from './hooks/use-mobile.js';
94
95
  export { useBackClose } from './hooks/use-back-close.js';
95
96
  export { VIEW_ICONS, iconOf, viewIcon } from './view-icons.js';
package/dist/index.js CHANGED
@@ -96,7 +96,8 @@ export { ConnectOffer, connectionFields, linkGroupId } from './connections.js';
96
96
  export { FRESHNESS, FreshnessIcon, FreshnessMark, freshnessLine, freshnessOf, } from './freshness.js';
97
97
  export { SourcesLine, useSourceHealth } from './source-health.js';
98
98
  export { TILE_WIDTH, Tile, TileChip, TileRow, TileSpark, TileToggleGroup, } from './tile.js';
99
- export { LumenTokenizedFilter, useLumenFilter, } from './lumen-filter.js';
99
+ export { LumenTokenizedFilter, countsShown, useFacetCounts, useLumenFilter, } from './lumen-filter.js';
100
+ export { LumenFilterPanel, LumenFilterPanelButton, useFilterPanel, } from './lumen-filter-panel.js';
100
101
  export { useIsMobile } from './hooks/use-mobile.js';
101
102
  export { useBackClose } from './hooks/use-back-close.js';
102
103
  export { VIEW_ICONS, iconOf, viewIcon } from './view-icons.js';
@@ -0,0 +1,30 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type LumenFilterState } from './lumen-filter.js';
3
+ export interface LumenFilterPanelProps<T> {
4
+ /** The filter's state, the same one the page's filter box draws. */
5
+ filter: LumenFilterState<T>;
6
+ /** Open (desktop: shown beside the page; phone: the sheet is up). */
7
+ open: boolean;
8
+ onOpenChange: (open: boolean) => void;
9
+ /** A field's details, behind an (i) beside its name. Most fields need none. */
10
+ tips?: Record<string, ReactNode>;
11
+ /** The fields in the order shown (default: the filter's own order). Fields left out are not
12
+ * shown here, but still filter from the box. */
13
+ order?: string[];
14
+ /** Its heading (default `Filters`). */
15
+ title?: string;
16
+ className?: string;
17
+ }
18
+ /**
19
+ * Remembers whether the panel is open on this device, per page (`id`). Desktop starts open,
20
+ * a phone closed (its sheet opens from the filter icon).
21
+ */
22
+ export declare function useFilterPanel(id: string): {
23
+ open: boolean;
24
+ onOpenChange: (next: boolean) => void;
25
+ };
26
+ /** The filter icon that shows the panel (or opens its sheet on a phone), with how many fields
27
+ * filter now. On a desktop it shows only while the panel is hidden. */
28
+ export declare function LumenFilterPanelButton<T>({ filter, open, onOpenChange, }: Pick<LumenFilterPanelProps<T>, 'filter' | 'open' | 'onOpenChange'>): import("react").JSX.Element | null;
29
+ /** The filter panel: a side panel on a desktop, a bottom sheet on a phone. */
30
+ export declare function LumenFilterPanel<T>({ filter, open, onOpenChange, tips, order, title, className, }: LumenFilterPanelProps<T>): import("react").JSX.Element | null;
@@ -0,0 +1,167 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { EMPTY_VALUE } from '@columenlabs/lumen-ui-types/views';
3
+ // The filter box laid out as a panel: every field a section of values with counts, ticked in
4
+ // place. It reads and writes the same tokens as `LumenTokenizedFilter` (one `useLumenFilter`
5
+ // state), so a tick here is a chip in the box and a chip's × unticks it here. Counts come from
6
+ // `useFacetCounts`, the box's own: over the rows loaded, or the server's `facets` when the rows
7
+ // are one page of a server's. On a desktop it is a side panel that hides to a filter icon; on a
8
+ // phone the same body opens in a bottom sheet.
9
+ import { BanIcon, FilterIcon, PanelLeftCloseIcon, RotateCcwIcon, SearchIcon } from 'lucide-react';
10
+ import { useCallback, useId, useMemo, useState } from 'react';
11
+ import { useIsMobile } from './hooks/use-mobile.js';
12
+ import { IconButton } from './icon-button.js';
13
+ import { InfoTip } from './info-tip.js';
14
+ import { cn } from './lib/utils.js';
15
+ import { countsShown, useFacetCounts, } from './lumen-filter.js';
16
+ import { Checkbox } from './ui/checkbox.js';
17
+ import { Input } from './ui/input.js';
18
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet.js';
19
+ /** Values a section shows before its "more" opens the rest (and its search). */
20
+ const SHOWN = 6;
21
+ /** Past this many values a section offers a search over them. */
22
+ const SEARCH_FROM = 10;
23
+ const keyOf = (v) => v.toLowerCase();
24
+ const isEmpty = (t) => t.op === 'empty';
25
+ const fmt = (n) => n.toLocaleString();
26
+ /**
27
+ * Remembers whether the panel is open on this device, per page (`id`). Desktop starts open,
28
+ * a phone closed (its sheet opens from the filter icon).
29
+ */
30
+ export function useFilterPanel(id) {
31
+ const mobile = useIsMobile();
32
+ const storeKey = `lumen:filter-panel:${id}`;
33
+ const [kept, setKept] = useState(() => {
34
+ try {
35
+ const v = localStorage.getItem(storeKey);
36
+ return v === null ? null : v === '1';
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ });
42
+ const [phoneOpen, setPhoneOpen] = useState(false);
43
+ const open = mobile ? phoneOpen : (kept ?? true);
44
+ const onOpenChange = useCallback((next) => {
45
+ if (mobile)
46
+ return setPhoneOpen(next);
47
+ setKept(next);
48
+ try {
49
+ localStorage.setItem(storeKey, next ? '1' : '0');
50
+ }
51
+ catch {
52
+ // Kept for the visit only.
53
+ }
54
+ }, [mobile, storeKey]);
55
+ return { open, onOpenChange };
56
+ }
57
+ /** The filter icon that shows the panel (or opens its sheet on a phone), with how many fields
58
+ * filter now. On a desktop it shows only while the panel is hidden. */
59
+ export function LumenFilterPanelButton({ filter, open, onOpenChange, }) {
60
+ const mobile = useIsMobile();
61
+ const on = new Set(filter.tokens.map((t) => t.key)).size;
62
+ if (open && !mobile)
63
+ return null;
64
+ return (_jsxs("span", { className: "relative inline-flex", children: [_jsx(IconButton, { icon: FilterIcon, label: on ? `Show filters (${on} on)` : 'Show filters', variant: "outline", onClick: () => onOpenChange(true) }), on > 0 && (_jsx("span", { "aria-hidden": true, className: "num pointer-events-none absolute -top-1.5 -right-1.5 min-w-4 rounded-full bg-primary px-1 text-center text-xs leading-4 text-primary-foreground", children: on }))] }));
65
+ }
66
+ /** The filter panel: a side panel on a desktop, a bottom sheet on a phone. */
67
+ export function LumenFilterPanel({ filter, open, onOpenChange, tips, order, title = 'Filters', className, }) {
68
+ const mobile = useIsMobile();
69
+ const fields = useMemo(() => {
70
+ if (!order)
71
+ return filter.fields;
72
+ return order
73
+ .map((k) => filter.fields.find((f) => f.key === k))
74
+ .filter((f) => !!f);
75
+ }, [filter.fields, order]);
76
+ const on = new Set(filter.tokens.map((t) => t.key)).size;
77
+ // Counted once for every section, the same counts the filter box shows.
78
+ const facets = useFacetCounts(filter);
79
+ const head = (_jsxs("div", { className: "flex items-center gap-1", children: [!mobile && _jsx("span", { className: "text-sm font-semibold", children: title }), !mobile && on > 0 && (_jsx("span", { className: "num rounded-full bg-primary px-1.5 text-xs leading-4 text-primary-foreground", children: on })), _jsx("span", { className: "flex-1" }), _jsx(IconButton, { icon: RotateCcwIcon, label: "Clear filters", disabled: !filter.tokens.length && !filter.text, onClick: filter.clear }), !mobile && (_jsx(IconButton, { icon: PanelLeftCloseIcon, label: "Hide filters", onClick: () => onOpenChange(false) }))] }));
80
+ const body = (_jsx("div", { className: "flex flex-col", children: fields.map((f) => (_jsx(Section, { field: f, filter: filter, facets: facets, tip: tips?.[f.key] }, f.key))) }));
81
+ if (mobile)
82
+ return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, children: _jsxs(SheetContent, { side: "bottom", className: "max-h-[85svh] gap-0 overflow-hidden",
83
+ // The first thing in the sheet is a field's (i): focusing it would open its tip.
84
+ onOpenAutoFocus: (e) => e.preventDefault(), children: [_jsxs(SheetHeader, { className: "pr-12", children: [_jsx(SheetTitle, { children: title }), _jsx(SheetDescription, { className: "sr-only", children: "Pick what the list shows" })] }), _jsxs("div", { className: "flex items-center px-4", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: on ? `${on} on` : 'None on' }), _jsx("span", { className: "flex-1" }), head] }), _jsx("div", { className: "min-h-0 overflow-y-auto px-4 pb-[max(1rem,env(safe-area-inset-bottom))]", children: body })] }) }));
85
+ if (!open)
86
+ return null;
87
+ return (_jsxs("aside", { "aria-label": title, "data-slot": "filter-panel", className: cn('flex w-64 shrink-0 flex-col gap-1 rounded-xl border bg-card p-3', className), children: [head, body] }));
88
+ }
89
+ /** One field: its values ticked in place, or a number field's range. */
90
+ function Section({ field, filter, facets: { counts, empties }, tip, }) {
91
+ const { tokens, setTokens } = filter;
92
+ const shown = countsShown(filter);
93
+ const mine = tokens.filter((t) => t.key === field.key);
94
+ const not = mine.some((t) => t.not);
95
+ const [more, setMore] = useState(false);
96
+ const [q, setQ] = useState('');
97
+ const header = (_jsxs("div", { className: "flex h-7 items-center gap-1.5", children: [_jsx("span", { className: "truncate text-xs font-semibold", children: field.label }), tip && _jsx(InfoTip, { label: `About ${field.label}`, children: tip }), _jsx("span", { className: "flex-1" }), !field.number && mine.length > 0 && (_jsx(IconButton, { icon: BanIcon, size: "icon-xs", pressed: not, label: not ? 'Keep these values instead' : 'Leave these values out', onClick: () => setTokens(tokens.map((t) => (t.key === field.key ? flip(t, !not) : t))) }))] }));
98
+ if (field.number)
99
+ return _jsx(NumberSection, { field: field, filter: filter, header: header });
100
+ const counted = counts.get(field.key) ?? new Map();
101
+ const picked = new Set(mine.filter((t) => !isEmpty(t)).map((t) => keyOf(t.value)));
102
+ const all = new Map(counted);
103
+ for (const t of mine)
104
+ if (!isEmpty(t) && !all.has(t.value))
105
+ all.set(t.value, 0);
106
+ const needle = q.trim().toLowerCase();
107
+ const values = [...all]
108
+ .filter(([v]) => !needle || v.toLowerCase().includes(needle))
109
+ .sort((a, b) => Number(picked.has(keyOf(b[0]))) - Number(picked.has(keyOf(a[0]))) ||
110
+ b[1] - a[1] ||
111
+ a[0].localeCompare(b[0]));
112
+ const list = more || needle ? values : values.slice(0, SHOWN);
113
+ const none = empties.get(field.key) ?? 0;
114
+ const noneOn = mine.some(isEmpty);
115
+ const tick = (value) => {
116
+ if (picked.has(keyOf(value)))
117
+ setTokens(tokens.filter((t) => !(t.key === field.key && !isEmpty(t) && keyOf(t.value) === keyOf(value))));
118
+ else
119
+ setTokens([...tokens, not ? { key: field.key, value, not } : { key: field.key, value }]);
120
+ };
121
+ const tickNone = () => setTokens(noneOn
122
+ ? tokens.filter((t) => !(t.key === field.key && isEmpty(t)))
123
+ : [...tokens, { key: field.key, value: EMPTY_VALUE, op: 'empty', ...(not ? { not } : {}) }]);
124
+ if (!all.size && !none && !mine.length)
125
+ return null;
126
+ return (_jsxs("section", { className: "border-t py-2 first:border-t-0", "aria-label": field.label, children: [header, all.size > SEARCH_FROM && (more || needle) && (_jsxs("div", { className: "relative mb-1", children: [_jsx(SearchIcon, { className: "pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: q, onChange: (e) => setQ(e.target.value), placeholder: `Find ${field.label.toLowerCase()}`, "aria-label": `Find ${field.label.toLowerCase()}`, className: "h-7 pl-7 text-xs" })] })), _jsxs("ul", { className: "flex flex-col", children: [none > 0 || noneOn ? (_jsx(Value, { label: `No ${field.label.toLowerCase()}`, n: shown ? none : null, checked: noneOn, onToggle: tickNone, muted: true })) : null, list.map(([v, n]) => (_jsx(Value, { label: v, n: shown ? n : null, checked: picked.has(keyOf(v)), onToggle: () => tick(v) }, v)))] }), !needle && values.length > SHOWN && (_jsx("button", { type: "button", className: "mt-0.5 rounded-sm text-xs text-primary hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", onClick: () => setMore(!more), children: more ? 'Fewer' : `${fmt(values.length - SHOWN)} more` }))] }));
127
+ }
128
+ const flip = (t, not) => {
129
+ const { not: _, ...rest } = t;
130
+ return not ? { ...rest, not } : rest;
131
+ };
132
+ function Value({ label, n, checked, onToggle, muted, }) {
133
+ const id = useId();
134
+ return (_jsxs("li", { className: "flex h-7 items-center gap-2 rounded-md px-1 text-sm hover:bg-muted/60", children: [_jsx(Checkbox, { id: id, checked: checked, onCheckedChange: onToggle }), _jsx("label", { htmlFor: id, className: cn('min-w-0 flex-1 cursor-pointer truncate', muted && 'text-muted-foreground'), title: label, children: label }), n !== null && _jsx("span", { className: "num text-xs text-muted-foreground", children: fmt(n) })] }));
135
+ }
136
+ /** A number field: from and to, kept as one condition (`> a`, `< b` or `a..b`). */
137
+ function NumberSection({ field, filter, header, }) {
138
+ const { tokens, setTokens } = filter;
139
+ const t = tokens.find((x) => x.key === field.key);
140
+ const [from0, to0] = t?.op === 'between'
141
+ ? t.value.split('..')
142
+ : t?.op === 'gt'
143
+ ? [t.value, '']
144
+ : t?.op === 'lt'
145
+ ? ['', t.value]
146
+ : t?.op === 'eq'
147
+ ? [t.value, t.value]
148
+ : ['', ''];
149
+ const [from, setFrom] = useState(from0 ?? '');
150
+ const [to, setTo] = useState(to0 ?? '');
151
+ const apply = () => {
152
+ const a = from.trim();
153
+ const b = to.trim();
154
+ const rest = tokens.filter((x) => x.key !== field.key);
155
+ const num = (s) => s !== '' && Number.isFinite(Number(s));
156
+ if (num(a) && num(b))
157
+ setTokens([...rest, { key: field.key, op: 'between', value: `${a}..${b}` }]);
158
+ else if (num(a))
159
+ setTokens([...rest, { key: field.key, op: 'gt', value: a }]);
160
+ else if (num(b))
161
+ setTokens([...rest, { key: field.key, op: 'lt', value: b }]);
162
+ else
163
+ setTokens(rest);
164
+ };
165
+ const box = (value, set, label) => (_jsx(Input, { inputMode: "decimal", value: value, onChange: (e) => set(e.target.value), onBlur: apply, onKeyDown: (e) => e.key === 'Enter' && apply(), placeholder: label, "aria-label": `${field.label} ${label.toLowerCase()}`, className: "h-7 text-xs tabular-nums" }));
166
+ return (_jsxs("section", { className: "border-t py-2 first:border-t-0", "aria-label": field.label, children: [header, _jsxs("div", { className: "flex items-center gap-2", children: [box(from, setFrom, 'From'), _jsx("span", { className: "text-xs text-muted-foreground", children: "\u2013" }), box(to, setTo, 'To')] })] }));
167
+ }
@@ -35,15 +35,38 @@ export declare const tokenText: (t: FilterToken) => string;
35
35
  export declare function filterByTokens<T>(rows: T[], fields: FilterField<T>[], tokens: FilterToken[],
36
36
  /** The row's text, for `contains` tokens. */
37
37
  text?: (row: T) => string[]): T[];
38
+ /** A server's counts per field: each value's rows, and the rows with no value (`none`), over the
39
+ * rows every other field's tokens keep. */
40
+ export type FacetCounts = Record<string, {
41
+ values: Record<string, number>;
42
+ none?: number;
43
+ }>;
44
+ /**
45
+ * Each field's values with their counts, shared by the filter box and the filter panel. A field
46
+ * counts the rows the other fields' tokens keep, so picking one value never hides its siblings.
47
+ * Over a server's page the counts come from the server (`facets`), never from the page.
48
+ */
49
+ export declare function useFacetCounts<T>(filter: LumenFilterState<T>): {
50
+ counts: Map<string, Map<string, number>>;
51
+ empties: Map<string, number>;
52
+ };
53
+ /** Whether a count means something: over a server's page only when the server counted. */
54
+ export declare const countsShown: (filter: {
55
+ partial: boolean;
56
+ facets: FacetCounts | null;
57
+ }) => boolean;
38
58
  /** Keeps the tokens and text, and filters `rows` by them. */
39
- export declare function useLumenFilter<T>(rows: T[], fields: FilterField<T>[], { text: textOf, initial, partial, }?: {
59
+ export declare function useLumenFilter<T>(rows: T[], fields: FilterField<T>[], { text: textOf, initial, partial, facets, }?: {
40
60
  /** Free-text haystack per row (a name, an id), besides the field values. */
41
61
  text?: (row: T) => string[];
42
62
  /** Tokens applied when the view opens (a view's default filters). */
43
63
  initial?: FilterToken[];
44
64
  /** `rows` are one page of a server's rows: a count over them is not the real count, so the
45
- * menu shows none. */
65
+ * menu shows none (unless the server sends its own `facets`). */
46
66
  partial?: boolean;
67
+ /** The server's counts for each field's values, taken over every row the other fields'
68
+ * tokens keep (one query where the rows live). Given, the box and the panel show these. */
69
+ facets?: FacetCounts | null;
47
70
  }): {
48
71
  fields: FilterField<T>[];
49
72
  rows: T[];
@@ -59,6 +82,7 @@ export declare function useLumenFilter<T>(rows: T[], fields: FilterField<T>[], {
59
82
  results: T[];
60
83
  hasText: boolean;
61
84
  partial: boolean;
85
+ facets: FacetCounts | null;
62
86
  clear: () => void;
63
87
  };
64
88
  export type LumenFilterState<T> = ReturnType<typeof useLumenFilter<T>>;
@@ -113,8 +113,46 @@ function onePerField(tokens) {
113
113
  }
114
114
  /** How many values a field lists in the menu before typing narrows it. */
115
115
  const PER_FIELD = 8;
116
+ /**
117
+ * Each field's values with their counts, shared by the filter box and the filter panel. A field
118
+ * counts the rows the other fields' tokens keep, so picking one value never hides its siblings.
119
+ * Over a server's page the counts come from the server (`facets`), never from the page.
120
+ */
121
+ export function useFacetCounts(filter) {
122
+ const { fields, indexed, tokens, facets } = filter;
123
+ return useMemo(() => {
124
+ const counts = new Map();
125
+ const empties = new Map();
126
+ for (const f of fields) {
127
+ if (f.number)
128
+ continue;
129
+ if (facets) {
130
+ const got = facets[f.key];
131
+ counts.set(f.key, new Map(Object.entries(got?.values ?? {})));
132
+ empties.set(f.key, got?.none ?? 0);
133
+ continue;
134
+ }
135
+ const m = new Map();
136
+ let none = 0;
137
+ for (const { row, values, nums } of indexed) {
138
+ if (!passes(values, nums, tokens, fields, f.key))
139
+ continue;
140
+ const vs = new Set(f.values(row));
141
+ if (!vs.size)
142
+ none++;
143
+ for (const v of vs)
144
+ m.set(v, (m.get(v) ?? 0) + 1);
145
+ }
146
+ counts.set(f.key, m);
147
+ empties.set(f.key, none);
148
+ }
149
+ return { counts, empties };
150
+ }, [fields, indexed, tokens, facets]);
151
+ }
152
+ /** Whether a count means something: over a server's page only when the server counted. */
153
+ export const countsShown = (filter) => !filter.partial || !!filter.facets;
116
154
  /** Keeps the tokens and text, and filters `rows` by them. */
117
- export function useLumenFilter(rows, fields, { text: textOf, initial = [], partial = false, } = {}) {
155
+ export function useLumenFilter(rows, fields, { text: textOf, initial = [], partial = false, facets, } = {}) {
118
156
  const [tokens, setRaw] = useState(() => onePerField(initial));
119
157
  const setTokens = useCallback((next) => setRaw(onePerField(next)), []);
120
158
  const [text, setText] = useState('');
@@ -162,6 +200,7 @@ export function useLumenFilter(rows, fields, { text: textOf, initial = [], parti
162
200
  results,
163
201
  hasText,
164
202
  partial,
203
+ facets: facets ?? null,
165
204
  clear,
166
205
  };
167
206
  }
@@ -271,9 +310,11 @@ function NumberCondition({ label, current, tall, count, onSet, }) {
271
310
  } })] })), _jsx("span", { className: "w-16 shrink-0 text-right text-muted-foreground text-xs tabular-nums", children: current && ok && count ? `${count(op, value(op, a, b))} rows` : '' })] })] }));
272
311
  }
273
312
  export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label = 'Filter', className, saved, onSave, changed, link: given, }) {
274
- const { fields, indexed, tokens, setTokens, text, setText, hasText, partial } = filter;
275
- // Over one page of a server's rows a count is not the real one: show none.
276
- const tally = (n) => (partial ? '' : n);
313
+ const { fields, indexed, tokens, setTokens, text, setText, hasText } = filter;
314
+ // Over one page of a server's rows a count is not the real one: show none, unless the server counted.
315
+ const tally = (n) => (countsShown(filter) ? n : '');
316
+ // Text counts are over the rows loaded: none over a server's page.
317
+ const pageTally = (n) => (filter.partial ? '' : n);
277
318
  // A box its card passes no link takes the card's own (every card carries a square).
278
319
  const link = useAutoFilterLink(tokens, given);
279
320
  // Phone: the box is one tappable row and the menu is a bottom sheet.
@@ -382,31 +423,8 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
382
423
  return keyOf(a) === keyOf(b);
383
424
  };
384
425
  const dirty = !!onSave && (!!changed || !sameTokens(tokens, saved ?? []));
385
- // Each field's values with counts. A field counts the rows the other fields' tokens keep, so a
386
- // chip can take more values of its own field.
387
- const { counts, empties } = useMemo(() => {
388
- const counts = new Map();
389
- // Rows with no value on each field, for its "No <field>" row.
390
- const empties = new Map();
391
- for (const f of fields) {
392
- if (f.number)
393
- continue;
394
- const m = new Map();
395
- let none = 0;
396
- for (const { row, values, nums } of indexed) {
397
- if (!passes(values, nums, tokens, fields, f.key))
398
- continue;
399
- const vs = new Set(f.values(row));
400
- if (!vs.size)
401
- none++;
402
- for (const v of vs)
403
- m.set(v, (m.get(v) ?? 0) + 1);
404
- }
405
- counts.set(f.key, m);
406
- empties.set(f.key, none);
407
- }
408
- return { counts, empties };
409
- }, [fields, indexed, tokens]);
426
+ // Each field's values with counts (the same counts the filter panel shows).
427
+ const { counts, empties } = useFacetCounts(filter);
410
428
  const picked = (key, value) => tokens.some((t) => same(t, { key, value }));
411
429
  /** A field's values matching `n`: picked ones first, then the most common. In the open field
412
430
  * "picked" means picked when it opened, so the order holds still while values are ticked. */
@@ -531,7 +549,7 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
531
549
  setField(null);
532
550
  setText('');
533
551
  focus();
534
- }, children: _jsx(ChevronLeftIcon, {}) }), _jsx(activeField.icon, { className: "size-4 text-muted-foreground" }), _jsx("span", { className: "font-medium", children: activeField.label }), !activeField.number && (_jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, className: "ml-auto", value: notFor(activeField.key) ? 'exclude' : 'include', onValueChange: (v) => v && setNot(activeField.key, v === 'exclude'), "aria-label": `${activeField.label}: include or exclude`, children: [_jsx(ToggleGroupItem, { value: "include", className: "h-7 px-2 text-xs font-normal", children: "Include" }), _jsx(ToggleGroupItem, { value: "exclude", className: "h-7 px-2 text-xs font-normal", children: "Exclude" })] }))] }), activeField.number && (_jsx(NumberCondition, { label: activeField.label, current: tokens.find((t) => t.key === activeField.key), tall: mobile, count: partial ? undefined : (op, value) => numberCount(activeField.key, op, value), onSet: (op, value) => setCondition(activeField.key, op, value) }, activeField.key))] }), active === CONTAINS_KEY ? (_jsxs(CommandGroup, { className: "px-1 py-0.5", children: [containsWord && !picked(CONTAINS_KEY, containsWord) && (_jsxs(CommandItem, { value: `${CONTAINS_KEY}:${containsWord}`, onSelect: () => toggle(CONTAINS_KEY, containsWord), className: tall, children: [_jsx(TextSearchIcon, { className: "text-muted-foreground" }), _jsxs("span", { className: "min-w-0 truncate", children: ["\u201C", containsWord, "\u201D"] }), _jsx(CommandShortcut, { className: shortcut, children: tally(wordOf(containsWord, notFor(CONTAINS_KEY))) })] })), tokens
552
+ }, children: _jsx(ChevronLeftIcon, {}) }), _jsx(activeField.icon, { className: "size-4 text-muted-foreground" }), _jsx("span", { className: "font-medium", children: activeField.label }), !activeField.number && (_jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, className: "ml-auto", value: notFor(activeField.key) ? 'exclude' : 'include', onValueChange: (v) => v && setNot(activeField.key, v === 'exclude'), "aria-label": `${activeField.label}: include or exclude`, children: [_jsx(ToggleGroupItem, { value: "include", className: "h-7 px-2 text-xs font-normal", children: "Include" }), _jsx(ToggleGroupItem, { value: "exclude", className: "h-7 px-2 text-xs font-normal", children: "Exclude" })] }))] }), activeField.number && (_jsx(NumberCondition, { label: activeField.label, current: tokens.find((t) => t.key === activeField.key), tall: mobile, count: filter.partial ? undefined : (op, value) => numberCount(activeField.key, op, value), onSet: (op, value) => setCondition(activeField.key, op, value) }, activeField.key))] }), active === CONTAINS_KEY ? (_jsxs(CommandGroup, { className: "px-1 py-0.5", children: [containsWord && !picked(CONTAINS_KEY, containsWord) && (_jsxs(CommandItem, { value: `${CONTAINS_KEY}:${containsWord}`, onSelect: () => toggle(CONTAINS_KEY, containsWord), className: tall, children: [_jsx(TextSearchIcon, { className: "text-muted-foreground" }), _jsxs("span", { className: "min-w-0 truncate", children: ["\u201C", containsWord, "\u201D"] }), _jsx(CommandShortcut, { className: shortcut, children: pageTally(wordOf(containsWord, notFor(CONTAINS_KEY))) })] })), tokens
535
553
  .filter((t) => t.key === CONTAINS_KEY)
536
554
  .map((t) => valueRow(CONTAINS_KEY, t.value, wordOf(t.value, !!t.not)))] })) : (!activeField.number && (_jsxs(CommandGroup, { className: "px-1 py-0.5", children: [(!needle || noneOf(activeField.label).toLowerCase().includes(needle)) &&
537
555
  emptyRow(activeField), valuesOf(activeField.key, needle, 200).map(([v, n]) => valueRow(activeField.key, v, n))] }))), _jsx(CommandEmpty, { children: "No matching values." })] })) : q ? (_jsxs(_Fragment, { children: [_jsx(CommandEmpty, { children: "No matching values." }), word && (_jsx(CommandGroup, { className: "px-1 py-0.5", children: _jsxs(CommandItem, { value: `${CONTAINS_KEY}:${word}`, onSelect: () => {
@@ -540,7 +558,7 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
540
558
  token(CONTAINS_KEY, word, chipOf(CONTAINS_KEY)?.not ?? negate),
541
559
  ]);
542
560
  setText('');
543
- }, className: tall, children: [_jsx(TextSearchIcon, { className: "text-muted-foreground" }), _jsxs("span", { className: "min-w-0 truncate", children: [_jsxs("span", { className: "text-muted-foreground", children: ["Text ", negate ? 'not contains' : 'contains', ' '] }), "\u201C", word, "\u201D"] }), _jsx(CommandShortcut, { className: shortcut, children: tally(wordOf(word, negate)) })] }) })), fields.some((f) => f.label.toLowerCase().startsWith(q) || f.key.startsWith(q)) && (_jsx(CommandGroup, { heading: "Fields", className: "px-1 py-0.5", children: fields
561
+ }, className: tall, children: [_jsx(TextSearchIcon, { className: "text-muted-foreground" }), _jsxs("span", { className: "min-w-0 truncate", children: [_jsxs("span", { className: "text-muted-foreground", children: ["Text ", negate ? 'not contains' : 'contains', ' '] }), "\u201C", word, "\u201D"] }), _jsx(CommandShortcut, { className: shortcut, children: pageTally(wordOf(word, negate)) })] }) })), fields.some((f) => f.label.toLowerCase().startsWith(q) || f.key.startsWith(q)) && (_jsx(CommandGroup, { heading: "Fields", className: "px-1 py-0.5", children: fields
544
562
  .filter((f) => f.label.toLowerCase().startsWith(q) || f.key.startsWith(q))
545
563
  .map((f) => (_jsxs(CommandItem, { value: `field:${f.key}`, onSelect: () => openField(f.key), className: tall, children: [_jsx(f.icon, { className: "text-muted-foreground" }), f.label, _jsx(ChevronRightIcon, { className: "ml-auto text-muted-foreground" })] }, f.key))) })), (q.startsWith('no') || (q.length > 1 && 'empty'.startsWith(q))) &&
546
564
  fields.some((f) => !f.number &&
@@ -2,7 +2,7 @@ import { type CustomViewConfig } from '@columenlabs/lumen-ui-types/views';
2
2
  import type { RowData } from '@tanstack/react-table';
3
3
  import { type LucideIcon } from 'lucide-react';
4
4
  import { type ReactElement, type ReactNode } from 'react';
5
- import { type FilterToken } from './lumen-filter.js';
5
+ import { type FacetCounts, type FilterToken } from './lumen-filter.js';
6
6
  import { type LinkColor } from './lumen-links.js';
7
7
  import { type LumenLoad } from './lumen-source.js';
8
8
  import { type LumenViewProps, type ServerPaging } from './lumen-view.js';
@@ -130,6 +130,17 @@ export interface LumenLayoutViewProps<T> {
130
130
  columnPicker?: boolean;
131
131
  /** No filter box (a short list, e.g. a playbook's variants). */
132
132
  hideFilter?: boolean;
133
+ /** The server's counts for each filter field's values over the rows the other chips keep, so the
134
+ * box and the filter panel count every row, not one page (with `paging`). */
135
+ facets?: FacetCounts | null;
136
+ /** A filter panel beside the page: the same filter as the box, each field's values ticked in
137
+ * place. `id` keeps it open or hidden per device; `tips` gives a field an InfoTip. */
138
+ filterPanel?: {
139
+ id: string;
140
+ tips?: Record<string, ReactNode>;
141
+ order?: string[];
142
+ title?: string;
143
+ };
133
144
  /**
134
145
  * How the person's chips, sort and grouping save (rule C, by surface):
135
146
  * `document` (a saved view, the default) holds them for the visit and shows Changed with Save
@@ -25,6 +25,7 @@ import { lazyPart } from './lazy-part.js';
25
25
  import { cn } from './lib/utils.js';
26
26
  import { LumenDrawer } from './lumen-drawer.js';
27
27
  import { LumenTokenizedFilter, filterByTokens, useLumenFilter, } from './lumen-filter.js';
28
+ import { LumenFilterPanel, LumenFilterPanelButton, useFilterPanel } from './lumen-filter-panel.js';
28
29
  import { LumenGrid } from './lumen-grid.js';
29
30
  import { KEY_FIELDS, LinksProvider, followedBy, followsText, keyFilterFields, shared, useLinkState, useLinks, usePublishLinks, useRowPick, } from './lumen-links.js';
30
31
  import { LumenStates, useCardLoads } from './lumen-source.js';
@@ -102,6 +103,9 @@ function useKeptColors(cards) {
102
103
  // gutters on both sides with shadows, so neither a card border nor a wide table scrolled under it
103
104
  // peeks out beside it; shadows, unlike a wider box, never make the page scroll sideways.
104
105
  const INTRO = 'sticky left-0 flex w-(--view-w) flex-col gap-(--shell-section-gap) has-[[data-slot=tabs-list]]:top-0 has-[[data-slot=tabs-list]]:z-30 has-[[data-slot=tabs-list]]:bg-background has-[[data-slot=tabs-list]]:shadow-[var(--shell-gutter)_0_0_0_var(--color-background),calc(-1*var(--shell-gutter))_0_0_0_var(--color-background)]';
106
+ /** The filter panel beside the page's scroller: one screen tall like it, scrolling on its own,
107
+ * in the page's left gutter. */
108
+ const PANEL = 'ml-(--shell-gutter) h-[calc(100svh-var(--shell-header-height)-var(--shell-status-height)-2*var(--shell-gutter))] overflow-y-auto';
105
109
  /** The page's one scroller: cards, filter and table scroll together. */
106
110
  function Scroller({ fit, children }) {
107
111
  // Tables' pinned headers stop under a sticky tab row (--pin-top), not behind it. The intro can
@@ -216,7 +220,7 @@ function LayoutTable({ card, base, config, rows, load, state, onDetails, openKey
216
220
  const domain = useMemo(() => withViewFields(base, own), [base, own]);
217
221
  return (_jsx(LumenViewCard, { id: card.id, domain: domain, config: own, rows: rows, load: load, state: state, title: card.title ?? config.name, onDetails: onDetails, openKey: openKey, controls: controls, shown: card.controls, onColumns: onLayout && ((columns) => onLayout(tableLayoutOf({ ...own, columns }))) }));
218
222
  }
219
- function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead, domain: base, config, rows, onConfigChange: saveConfig, editable: canEdit, editor, onDuplicate, onDelete, onReset, load, state, tile, rowTitle, viewId, onShownRows, onSummaryRows, selection, onOpen, openKey: openedKey, rowPick: initialPick, groupActions, onTokens, paging, viewTools, columnPicker, hideFilter, save: saveMode = 'document', }) {
223
+ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead, domain: base, config, rows, onConfigChange: saveConfig, editable: canEdit, editor, onDuplicate, onDelete, onReset, load, state, tile, rowTitle, viewId, onShownRows, onSummaryRows, selection, onOpen, openKey: openedKey, rowPick: initialPick, groupActions, onTokens, paging, viewTools, columnPicker, hideFilter, facets, filterPanel, save: saveMode = 'document', }) {
220
224
  const onConfigChange = canEdit === false ? undefined : saveConfig;
221
225
  // The registry's join fields for the keys the rows carry (Symbol, Account), then the view's own
222
226
  // formulas and formats.
@@ -230,7 +234,9 @@ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead,
230
234
  initial: config.filters.default,
231
235
  // A server's page is not every row: the menu's counts would be over that page only.
232
236
  partial: !!paging && (paging.more || paging.page > 0 || (paging.total ?? 0) > rows.length),
237
+ facets,
233
238
  });
239
+ const panel = useFilterPanel(filterPanel?.id ?? '');
234
240
  // A view patched while open (the assistant's card move, a save from another tab) applies its new saved
235
241
  // filters at once; the person's own unsaved tokens stay while the saved ones
236
242
  // do not change.
@@ -505,7 +511,7 @@ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead,
505
511
  // A server's page cannot be grouped (a group would bucket the one page only), unless the
506
512
  // server groups it.
507
513
  const groupButton = viewOn('group') && (!paging || paging.groups) ? group.button : null;
508
- const viewFilter = !showBox && !groupButton ? undefined : (_jsxs(_Fragment, { children: [showBox && (_jsx(LumenTokenizedFilter, { filter: filter, label: `Filter ${config.name}`, className: "min-w-0 flex-1", saved: config.filters.default, link: {
514
+ const viewFilter = !showBox && !groupButton ? undefined : (_jsxs(_Fragment, { children: [filterPanel && (_jsx(LumenFilterPanelButton, { filter: filter, open: panel.open, onOpenChange: panel.onOpenChange })), showBox && (_jsx(LumenTokenizedFilter, { filter: filter, label: `Filter ${config.name}`, className: "min-w-0 flex-1", saved: config.filters.default, link: {
509
515
  color: viewColor,
510
516
  onPick: (color) => links.setColor(viewCardId, color),
511
517
  } })), groupButton] }));
@@ -629,7 +635,7 @@ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead,
629
635
  setDraft(saved);
630
636
  setDraftView({});
631
637
  setLayout(true);
632
- } })] }))), children: [_jsx(LinksProvider, { links: links, children: _jsx(Scroller, { fit: fit, children: body }) }), _jsx(LumenDrawer, { open: open !== null, title: openTitle, onClose: () => setDetails(null), children: open !== null && (_jsx(DetailsBody, { domain: domain, row: open, columns: columns, chips: config.chips ?? [] })) }), onConfigChange && (_jsxs(_Fragment, { children: [_jsx(AddWidgetDialog, { open: adding, onOpenChange: setAdding, domain: domain, cards: cards, name: config.name, onAdd: (c) => {
638
+ } })] }))), children: [_jsx(LinksProvider, { links: links, children: filterPanel ? (_jsxs("div", { className: "flex min-w-0", children: [_jsx(LumenFilterPanel, { filter: filter, open: panel.open, onOpenChange: panel.onOpenChange, tips: filterPanel.tips, order: filterPanel.order, title: filterPanel.title, className: PANEL }), _jsx("div", { className: "min-w-0 flex-1", children: _jsx(Scroller, { fit: fit, children: body }) })] })) : (_jsx(Scroller, { fit: fit, children: body })) }), _jsx(LumenDrawer, { open: open !== null, title: openTitle, onClose: () => setDetails(null), children: open !== null && (_jsx(DetailsBody, { domain: domain, row: open, columns: columns, chips: config.chips ?? [] })) }), onConfigChange && (_jsxs(_Fragment, { children: [_jsx(AddWidgetDialog, { open: adding, onOpenChange: setAdding, domain: domain, cards: cards, name: config.name, onAdd: (c) => {
633
639
  setAdding(false);
634
640
  // A second table starts as a copy of the view's layout, then goes its own way.
635
641
  putCard(isTableCard(c) && mainTable && !c.source
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.159",
3
+ "version": "0.1.160",
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.159",
52
+ "@columenlabs/lumen-ui-types": "0.1.160",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",