@columenlabs/lumen-ui 0.1.161 → 0.1.163

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Tells `tell` a value each time the value itself changes, never because `tell` is a new function.
3
+ * The view hears its sort, chips and grouping through these: an app's inline callback (a new
4
+ * function every render) used to re-run the effect, set the app's state, render, and so on without
5
+ * end, asking the server for the same page each time. The value is compared by its JSON, so an
6
+ * equal value in a new object is not a change either. A `tell` that appears later hears the value.
7
+ */
8
+ export declare function useTell<V>(value: V, tell: ((value: V) => void) | undefined): void;
@@ -0,0 +1,24 @@
1
+ import * as React from 'react';
2
+ /**
3
+ * Tells `tell` a value each time the value itself changes, never because `tell` is a new function.
4
+ * The view hears its sort, chips and grouping through these: an app's inline callback (a new
5
+ * function every render) used to re-run the effect, set the app's state, render, and so on without
6
+ * end, asking the server for the same page each time. The value is compared by its JSON, so an
7
+ * equal value in a new object is not a change either. A `tell` that appears later hears the value.
8
+ */
9
+ export function useTell(value, tell) {
10
+ const tellRef = React.useRef(tell);
11
+ tellRef.current = tell;
12
+ const valueRef = React.useRef(value);
13
+ valueRef.current = value;
14
+ const sent = React.useRef(null);
15
+ const key = JSON.stringify(value ?? null);
16
+ const has = !!tell;
17
+ React.useEffect(() => {
18
+ const mark = has ? key : null;
19
+ if (mark === null || sent.current === mark)
20
+ return;
21
+ sent.current = mark;
22
+ tellRef.current?.(valueRef.current);
23
+ }, [key, has]);
24
+ }
package/dist/index.d.ts CHANGED
@@ -95,6 +95,7 @@ export { TILE_WIDTH, Tile, TileChip, type TileOption, TileRow, TileSpark, TileTo
95
95
  export { type FacetCounts, type FilterField, type FilterToken, LumenTokenizedFilter, type LumenFilterState, countsShown, useFacetCounts, useLumenFilter, } from './lumen-filter.js';
96
96
  export { LumenFilterPanel, LumenFilterPanelButton, type LumenFilterPanelProps, useFilterPanel, } from './lumen-filter-panel.js';
97
97
  export { useIsMobile } from './hooks/use-mobile.js';
98
+ export { useTell } from './hooks/use-tell.js';
98
99
  export { useBackClose } from './hooks/use-back-close.js';
99
100
  export { VIEW_ICONS, iconOf, viewIcon } from './view-icons.js';
100
101
  export { type AppViewState, useAppView } from './app-views.js';
package/dist/index.js CHANGED
@@ -102,6 +102,7 @@ export { TILE_WIDTH, Tile, TileChip, TileRow, TileSpark, TileToggleGroup, } from
102
102
  export { LumenTokenizedFilter, countsShown, useFacetCounts, useLumenFilter, } from './lumen-filter.js';
103
103
  export { LumenFilterPanel, LumenFilterPanelButton, useFilterPanel, } from './lumen-filter-panel.js';
104
104
  export { useIsMobile } from './hooks/use-mobile.js';
105
+ export { useTell } from './hooks/use-tell.js';
105
106
  export { useBackClose } from './hooks/use-back-close.js';
106
107
  export { VIEW_ICONS, iconOf, viewIcon } from './view-icons.js';
107
108
  export { useAppView } from './app-views.js';
@@ -89,7 +89,7 @@ export function LumenFilterPanel({ filter, open, onOpenChange, tips, order, titl
89
89
  /** One field: its values ticked in place, or a number field's range. */
90
90
  function Section({ field, filter, facets: { counts, empties }, tip, }) {
91
91
  const { tokens, setTokens } = filter;
92
- const shown = countsShown(filter);
92
+ const shown = countsShown(filter, field.key);
93
93
  const mine = tokens.filter((t) => t.key === field.key);
94
94
  const not = mine.some((t) => t.not);
95
95
  const [more, setMore] = useState(false);
@@ -36,10 +36,13 @@ export declare function filterByTokens<T>(rows: T[], fields: FilterField<T>[], t
36
36
  /** The row's text, for `contains` tokens. */
37
37
  text?: (row: T) => string[]): T[];
38
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. */
39
+ * rows every other field's tokens keep. `counted: false` gives a field's values to pick without
40
+ * counts, for when the server could not count them without reading every row: the box and the
41
+ * panel list the values and show no numbers. */
40
42
  export type FacetCounts = Record<string, {
41
43
  values: Record<string, number>;
42
44
  none?: number;
45
+ counted?: boolean;
43
46
  }>;
44
47
  /**
45
48
  * Each field's values with their counts, shared by the filter box and the filter panel. A field
@@ -50,11 +53,12 @@ export declare function useFacetCounts<T>(filter: LumenFilterState<T>): {
50
53
  counts: Map<string, Map<string, number>>;
51
54
  empties: Map<string, number>;
52
55
  };
53
- /** Whether a count means something: over a server's page only when the server counted. */
56
+ /** Whether a count means something: over a server's page only when the server counted (that
57
+ * field, when `key` is given). */
54
58
  export declare const countsShown: (filter: {
55
59
  partial: boolean;
56
60
  facets: FacetCounts | null;
57
- }) => boolean;
61
+ }, key?: string) => boolean;
58
62
  /** Keeps the tokens and text, and filters `rows` by them. */
59
63
  export declare function useLumenFilter<T>(rows: T[], fields: FilterField<T>[], { text: textOf, initial, partial, facets, }?: {
60
64
  /** Free-text haystack per row (a name, an id), besides the field values. */
@@ -149,8 +149,9 @@ export function useFacetCounts(filter) {
149
149
  return { counts, empties };
150
150
  }, [fields, indexed, tokens, facets]);
151
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;
152
+ /** Whether a count means something: over a server's page only when the server counted (that
153
+ * field, when `key` is given). */
154
+ export const countsShown = (filter, key) => (filter.facets ? key === undefined || filter.facets[key]?.counted !== false : !filter.partial);
154
155
  /** Keeps the tokens and text, and filters `rows` by them. */
155
156
  export function useLumenFilter(rows, fields, { text: textOf, initial = [], partial = false, facets, } = {}) {
156
157
  const [tokens, setRaw] = useState(() => onePerField(initial));
@@ -312,7 +313,7 @@ function NumberCondition({ label, current, tall, count, onSet, }) {
312
313
  export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label = 'Filter', className, saved, onSave, changed, link: given, }) {
313
314
  const { fields, indexed, tokens, setTokens, text, setText, hasText } = filter;
314
315
  // 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
+ const tally = (n, key) => (countsShown(filter, key) ? n : '');
316
317
  // Text counts are over the rows loaded: none over a server's page.
317
318
  const pageTally = (n) => (filter.partial ? '' : n);
318
319
  // A box its card passes no link takes the card's own (every card carries a square).
@@ -530,14 +531,14 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
530
531
  return (_jsxs(CommandItem, { value: `${key}:${value}`, onSelect: () => toggle(key, value), className: tall, children: [_jsx(Checkbox, { checked: on, tabIndex: -1, "aria-hidden": true, className: "pointer-events-none" }), prefix && _jsx("span", { className: "shrink-0 text-muted-foreground", children: prefix }), _jsx("span", { className: "min-w-0 truncate", children: value }), _jsx(Button, { variant: "ghost", size: "xs", className: "-my-1 ml-auto h-6 px-1.5 font-normal text-muted-foreground opacity-0 group-data-selected/command-item:opacity-100 max-md:opacity-100", "aria-label": `Only ${value}`, onMouseDown: (e) => e.preventDefault(), onClick: (e) => {
531
532
  e.stopPropagation();
532
533
  only(key, value);
533
- }, children: "Only" }), _jsx(CommandShortcut, { className: cn('ml-0 w-8 text-right', shortcut), children: tally(count) })] }, `${key}:${value}`));
534
+ }, children: "Only" }), _jsx(CommandShortcut, { className: cn('ml-0 w-8 text-right', shortcut), children: tally(count, key) })] }, `${key}:${value}`));
534
535
  };
535
536
  // A field's "No <field>" row, pinned above its values: the rows with no value (Exclude: any).
536
537
  const emptyRow = (f, include) => {
537
538
  const n = empties.get(f.key) ?? 0;
538
539
  if (!n && !hasEmpty(f.key))
539
540
  return null;
540
- return (_jsxs(CommandItem, { value: `${f.key}:${EMPTY_VALUE}`, onSelect: () => toggleEmpty(f.key, include ? false : undefined), className: tall, children: [_jsx(Checkbox, { checked: hasEmpty(f.key), tabIndex: -1, "aria-hidden": true, className: "pointer-events-none" }), _jsx("span", { className: "min-w-0 truncate text-muted-foreground italic", children: noneOf(f.label) }), _jsx(CommandShortcut, { className: cn('ml-auto w-8 text-right', shortcut), children: tally(n) })] }, `${f.key}:${EMPTY_VALUE}`));
541
+ return (_jsxs(CommandItem, { value: `${f.key}:${EMPTY_VALUE}`, onSelect: () => toggleEmpty(f.key, include ? false : undefined), className: tall, children: [_jsx(Checkbox, { checked: hasEmpty(f.key), tabIndex: -1, "aria-hidden": true, className: "pointer-events-none" }), _jsx("span", { className: "min-w-0 truncate text-muted-foreground italic", children: noneOf(f.label) }), _jsx(CommandShortcut, { className: cn('ml-auto w-8 text-right', shortcut), children: tally(n, f.key) })] }, `${f.key}:${EMPTY_VALUE}`));
541
542
  };
542
543
  const summary = (c) => {
543
544
  const w = chipWords(c, fieldOf(c.key), 'full');
@@ -19,6 +19,7 @@ import { ConnectOffer } from './connections.js';
19
19
  import { usePageKey } from './fold.js';
20
20
  import { useGroup } from './group-button.js';
21
21
  import { useIsMobile } from './hooks/use-mobile.js';
22
+ import { useTell } from './hooks/use-tell.js';
22
23
  import { SourceStatusItems, useJoins } from './joins.js';
23
24
  import { useYourData } from './joins.js';
24
25
  import { lazyPart } from './lazy-part.js';
@@ -249,7 +250,8 @@ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead,
249
250
  savedFiltersRef.current = savedFilters;
250
251
  setTokens(config.filters.default);
251
252
  }, [savedFilters, config.filters.default, setTokens]);
252
- useEffect(() => onTokens?.(filter.tokens), [onTokens, filter.tokens]);
253
+ // Told through a ref, so an app's inline callback never loops (useTell).
254
+ useTell(filter.tokens, onTokens);
253
255
  const group = useGroup(domain, config);
254
256
  // The sort the person set by a header click, held like the chips and grouping (rule C).
255
257
  const [heldSort, setHeldSort] = useState(config.sort);
@@ -262,8 +264,7 @@ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead,
262
264
  setHeldSort(config.sort);
263
265
  }, [savedSort, config.sort]);
264
266
  // A server's page sorts on the server: it hears each sort held.
265
- const onServerSort = paging?.onSort;
266
- useEffect(() => onServerSort?.(heldSort ?? null), [onServerSort, heldSort]);
267
+ useTell(heldSort ?? null, paging?.onSort);
267
268
  const [details, setDetails] = useState(null);
268
269
  const onDetails = onOpen ?? (domain.details?.length ? setDetails : undefined);
269
270
  const openKey = onOpen ? (openedKey ?? null) : details === null ? null : domain.rowKey(details);
@@ -15,6 +15,7 @@ import { DrawerSection } from './drawer-section.js';
15
15
  import { useFloatingHead } from './floating-head.js';
16
16
  import { FreshnessMark, freshnessLine, freshnessOf } from './freshness.js';
17
17
  import { useIsMobile } from './hooks/use-mobile.js';
18
+ import { useTell } from './hooks/use-tell.js';
18
19
  import { IconButton } from './icon-button.js';
19
20
  import { lazyPart } from './lazy-part.js';
20
21
  import { cn } from './lib/utils.js';
@@ -236,8 +237,7 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
236
237
  // server groups it.
237
238
  const groupBy = server && !server.groups ? null : pickedGroup;
238
239
  const served = server?.groups && groupBy ? server.groups : null;
239
- const onGroup = server?.groups?.onGroup;
240
- useEffect(() => onGroup?.(groupBy?.key ?? null), [onGroup, groupBy?.key]);
240
+ useTell(groupBy?.key ?? null, server?.groups?.onGroup);
241
241
  const pivot = server ? false : pickedPivot;
242
242
  // Live columns on screen keep their source reading while the market trades (#861).
243
243
  const load = useLiveLoad(sourceLoad, columns.some((c) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.161",
3
+ "version": "0.1.163",
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.161",
52
+ "@columenlabs/lumen-ui-types": "0.1.163",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",