@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 +2 -1
- package/dist/index.js +2 -1
- package/dist/lumen-filter-panel.d.ts +30 -0
- package/dist/lumen-filter-panel.js +167 -0
- package/dist/lumen-filter.d.ts +26 -2
- package/dist/lumen-filter.js +49 -31
- package/dist/lumen-layout.d.ts +12 -1
- package/dist/lumen-layout.js +9 -3
- package/package.json +2 -2
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
|
+
}
|
package/dist/lumen-filter.d.ts
CHANGED
|
@@ -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>>;
|
package/dist/lumen-filter.js
CHANGED
|
@@ -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
|
|
275
|
-
// Over one page of a server's rows a count is not the real one: show none.
|
|
276
|
-
const tally = (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
|
|
386
|
-
|
|
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:
|
|
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:
|
|
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 &&
|
package/dist/lumen-layout.d.ts
CHANGED
|
@@ -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
|
package/dist/lumen-layout.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|