@columenlabs/lumen-ui 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/NOTICE +12 -0
- package/README.md +4 -2
- package/dist/add-column.d.ts +23 -0
- package/dist/add-column.js +62 -0
- package/dist/api-fetch.d.ts +9 -0
- package/dist/api-fetch.js +60 -0
- package/dist/app-switcher.d.ts +21 -0
- package/dist/app-switcher.js +12 -0
- package/dist/app-views.d.ts +18 -0
- package/dist/app-views.js +41 -0
- package/dist/assistant-context.d.ts +72 -0
- package/dist/assistant-context.js +30 -0
- package/dist/assistant-panel.d.ts +58 -0
- package/dist/assistant-panel.js +244 -0
- package/dist/assistant-screen.d.ts +124 -0
- package/dist/assistant-screen.js +252 -0
- package/dist/card-switch.d.ts +16 -0
- package/dist/card-switch.js +16 -0
- package/dist/color-dot.d.ts +14 -0
- package/dist/color-dot.js +19 -0
- package/dist/confirm.d.ts +18 -0
- package/dist/confirm.js +5 -0
- package/dist/connections.d.ts +45 -0
- package/dist/connections.js +275 -0
- package/dist/coverage-calendar-map.d.ts +27 -0
- package/dist/coverage-calendar-map.js +84 -0
- package/dist/coverage-cells.d.ts +55 -0
- package/dist/coverage-cells.js +85 -0
- package/dist/coverage-map.d.ts +112 -0
- package/dist/coverage-map.js +428 -0
- package/dist/details-table.d.ts +20 -0
- package/dist/details-table.js +13 -0
- package/dist/drawer-section.d.ts +27 -0
- package/dist/drawer-section.js +23 -0
- package/dist/floating-head.d.ts +2 -0
- package/dist/floating-head.js +57 -0
- package/dist/fold.d.ts +16 -0
- package/dist/fold.js +70 -0
- package/dist/form-issues.d.ts +32 -0
- package/dist/form-issues.js +60 -0
- package/dist/formula-input.d.ts +17 -0
- package/dist/formula-input.js +104 -0
- package/dist/frame-script.d.ts +7 -0
- package/dist/frame-script.js +103 -0
- package/dist/freshness.d.ts +24 -0
- package/dist/freshness.js +99 -0
- package/dist/group-button.d.ts +33 -0
- package/dist/group-button.js +67 -0
- package/dist/growth-chart.d.ts +77 -0
- package/dist/growth-chart.js +195 -0
- package/dist/hooks/use-keyboard-fit.d.ts +15 -0
- package/dist/hooks/use-keyboard-fit.js +29 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +15 -0
- package/dist/icon-button.d.ts +21 -0
- package/dist/icon-button.js +22 -0
- package/dist/icon-picker.d.ts +14 -0
- package/dist/icon-picker.js +36 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +90 -0
- package/dist/integrations.d.ts +57 -0
- package/dist/integrations.js +73 -0
- package/dist/joins.d.ts +128 -0
- package/dist/joins.js +767 -0
- package/dist/kit-config.d.ts +13 -0
- package/dist/kit-config.js +15 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.js +5 -0
- package/dist/lumen-boundary.d.ts +7 -0
- package/dist/lumen-boundary.js +10 -0
- package/dist/lumen-code-editor-impl.d.ts +2 -0
- package/dist/lumen-code-editor-impl.js +16 -0
- package/dist/lumen-code-editor.d.ts +11 -0
- package/dist/lumen-code-editor.js +11 -0
- package/dist/lumen-code.d.ts +22 -0
- package/dist/lumen-code.js +75 -0
- package/dist/lumen-drawer.d.ts +30 -0
- package/dist/lumen-drawer.js +27 -0
- package/dist/lumen-echart.d.ts +58 -0
- package/dist/lumen-echart.js +129 -0
- package/dist/lumen-filter.d.ts +75 -0
- package/dist/lumen-filter.js +546 -0
- package/dist/lumen-finder.d.ts +62 -0
- package/dist/lumen-finder.js +275 -0
- package/dist/lumen-form.d.ts +34 -0
- package/dist/lumen-form.js +81 -0
- package/dist/lumen-geo-map.d.ts +9 -0
- package/dist/lumen-geo-map.js +135 -0
- package/dist/lumen-grid.d.ts +49 -0
- package/dist/lumen-grid.js +203 -0
- package/dist/lumen-layout.d.ts +150 -0
- package/dist/lumen-layout.js +661 -0
- package/dist/lumen-links.d.ts +96 -0
- package/dist/lumen-links.js +213 -0
- package/dist/lumen-page-state.d.ts +20 -0
- package/dist/lumen-page-state.js +15 -0
- package/dist/lumen-selector.d.ts +139 -0
- package/dist/lumen-selector.js +308 -0
- package/dist/lumen-source.d.ts +137 -0
- package/dist/lumen-source.js +327 -0
- package/dist/lumen-tab-drawer.d.ts +27 -0
- package/dist/lumen-tab-drawer.js +26 -0
- package/dist/lumen-tabs.d.ts +21 -0
- package/dist/lumen-tabs.js +6 -0
- package/dist/lumen-toast.d.ts +11 -0
- package/dist/lumen-toast.js +16 -0
- package/dist/lumen-trend.d.ts +18 -0
- package/dist/lumen-trend.js +157 -0
- package/dist/lumen-view-card.d.ts +35 -0
- package/dist/lumen-view-card.js +50 -0
- package/dist/lumen-view.d.ts +172 -0
- package/dist/lumen-view.js +645 -0
- package/dist/lumen-widget-charts.d.ts +57 -0
- package/dist/lumen-widget-charts.js +69 -0
- package/dist/lumen-widget.d.ts +720 -0
- package/dist/lumen-widget.js +609 -0
- package/dist/market/compare-chart.d.ts +34 -0
- package/dist/market/compare-chart.js +292 -0
- package/dist/market/indicator-picker.d.ts +29 -0
- package/dist/market/indicator-picker.js +125 -0
- package/dist/market/indicators.d.ts +63 -0
- package/dist/market/indicators.js +314 -0
- package/dist/market/live.d.ts +120 -0
- package/dist/market/live.js +444 -0
- package/dist/market/lumen-chart.d.ts +88 -0
- package/dist/market/lumen-chart.js +645 -0
- package/dist/market/market-widget.d.ts +45 -0
- package/dist/market/market-widget.js +346 -0
- package/dist/market/option-chain.d.ts +19 -0
- package/dist/market/option-chain.js +53 -0
- package/dist/market/price-chart.d.ts +128 -0
- package/dist/market/price-chart.js +619 -0
- package/dist/mcp-settings.d.ts +20 -0
- package/dist/mcp-settings.js +65 -0
- package/dist/nav.d.ts +77 -0
- package/dist/nav.js +73 -0
- package/dist/note-line.d.ts +13 -0
- package/dist/note-line.js +5 -0
- package/dist/notifications.d.ts +9 -0
- package/dist/notifications.js +166 -0
- package/dist/origin-legend.d.ts +14 -0
- package/dist/origin-legend.js +38 -0
- package/dist/overview.d.ts +84 -0
- package/dist/overview.js +105 -0
- package/dist/page-inputs.d.ts +14 -0
- package/dist/page-inputs.js +16 -0
- package/dist/page.d.ts +32 -0
- package/dist/page.js +45 -0
- package/dist/pager.d.ts +10 -0
- package/dist/pager.js +14 -0
- package/dist/prefs.d.ts +71 -0
- package/dist/prefs.js +126 -0
- package/dist/query-client.d.ts +7 -0
- package/dist/query-client.js +19 -0
- package/dist/refresh.d.ts +4 -0
- package/dist/refresh.js +19 -0
- package/dist/rename-field.d.ts +11 -0
- package/dist/rename-field.js +26 -0
- package/dist/reset-button.d.ts +9 -0
- package/dist/reset-button.js +10 -0
- package/dist/route-error.d.ts +42 -0
- package/dist/route-error.js +229 -0
- package/dist/schedules.d.ts +88 -0
- package/dist/schedules.js +221 -0
- package/dist/settings.d.ts +51 -0
- package/dist/settings.js +181 -0
- package/dist/shell.d.ts +99 -0
- package/dist/shell.js +137 -0
- package/dist/source-health.d.ts +12 -0
- package/dist/source-health.js +58 -0
- package/dist/sourced-card.d.ts +10 -0
- package/dist/sourced-card.js +69 -0
- package/dist/spotlight.d.ts +23 -0
- package/dist/spotlight.js +138 -0
- package/dist/status-health.d.ts +16 -0
- package/dist/status-health.js +41 -0
- package/dist/status.d.ts +9 -0
- package/dist/status.js +12 -0
- package/dist/summary-cards.d.ts +56 -0
- package/dist/summary-cards.js +270 -0
- package/dist/symbol-sheet-context.d.ts +4 -0
- package/dist/symbol-sheet-context.js +5 -0
- package/dist/symbol-sheet.d.ts +9 -0
- package/dist/symbol-sheet.js +24 -0
- package/dist/tasks.d.ts +29 -0
- package/dist/tasks.js +278 -0
- package/dist/theme.css +606 -0
- package/dist/tile.d.ts +37 -0
- package/dist/tile.js +23 -0
- package/dist/ui/alert-dialog.d.ts +18 -0
- package/dist/ui/alert-dialog.js +41 -0
- package/dist/ui/alert.d.ts +10 -0
- package/dist/ui/alert.js +27 -0
- package/dist/ui/avatar.d.ts +11 -0
- package/dist/ui/avatar.js +22 -0
- package/dist/ui/badge.d.ts +9 -0
- package/dist/ui/badge.js +27 -0
- package/dist/ui/breadcrumb.d.ts +11 -0
- package/dist/ui/breadcrumb.js +27 -0
- package/dist/ui/button-group.d.ts +11 -0
- package/dist/ui/button-group.js +27 -0
- package/dist/ui/button.d.ts +10 -0
- package/dist/ui/button.js +38 -0
- package/dist/ui/card.d.ts +11 -0
- package/dist/ui/card.js +24 -0
- package/dist/ui/chart.d.ts +44 -0
- package/dist/ui/chart.js +117 -0
- package/dist/ui/checkbox.d.ts +4 -0
- package/dist/ui/checkbox.js +8 -0
- package/dist/ui/collapsible.d.ts +5 -0
- package/dist/ui/collapsible.js +12 -0
- package/dist/ui/combobox.d.ts +24 -0
- package/dist/ui/combobox.js +60 -0
- package/dist/ui/command.d.ts +18 -0
- package/dist/ui/command.js +34 -0
- package/dist/ui/dialog.d.ts +23 -0
- package/dist/ui/dialog.js +50 -0
- package/dist/ui/dropdown-menu.d.ts +29 -0
- package/dist/ui/dropdown-menu.js +50 -0
- package/dist/ui/emoji-picker.d.ts +6 -0
- package/dist/ui/emoji-picker.js +28 -0
- package/dist/ui/empty.d.ts +11 -0
- package/dist/ui/empty.js +33 -0
- package/dist/ui/field.d.ts +24 -0
- package/dist/ui/field.js +65 -0
- package/dist/ui/hover-card.d.ts +6 -0
- package/dist/ui/hover-card.js +13 -0
- package/dist/ui/input-group.d.ts +16 -0
- package/dist/ui/input-group.js +56 -0
- package/dist/ui/input.d.ts +3 -0
- package/dist/ui/input.js +6 -0
- package/dist/ui/item.d.ts +23 -0
- package/dist/ui/item.js +67 -0
- package/dist/ui/kbd.d.ts +3 -0
- package/dist/ui/kbd.js +9 -0
- package/dist/ui/label.d.ts +4 -0
- package/dist/ui/label.js +7 -0
- package/dist/ui/popover.d.ts +10 -0
- package/dist/ui/popover.js +25 -0
- package/dist/ui/progress.d.ts +4 -0
- package/dist/ui/progress.js +9 -0
- package/dist/ui/resizable.d.ts +7 -0
- package/dist/ui/resizable.js +13 -0
- package/dist/ui/scroll-area.d.ts +5 -0
- package/dist/ui/scroll-area.js +10 -0
- package/dist/ui/select.d.ts +15 -0
- package/dist/ui/select.js +36 -0
- package/dist/ui/separator.d.ts +4 -0
- package/dist/ui/separator.js +7 -0
- package/dist/ui/sheet.d.ts +14 -0
- package/dist/ui/sheet.js +54 -0
- package/dist/ui/sidebar.d.ts +69 -0
- package/dist/ui/sidebar.js +206 -0
- package/dist/ui/skeleton.d.ts +2 -0
- package/dist/ui/skeleton.js +6 -0
- package/dist/ui/slider.d.ts +4 -0
- package/dist/ui/slider.js +9 -0
- package/dist/ui/sonner.d.ts +3 -0
- package/dist/ui/sonner.js +24 -0
- package/dist/ui/spinner.d.ts +2 -0
- package/dist/ui/spinner.js +7 -0
- package/dist/ui/switch.d.ts +6 -0
- package/dist/ui/switch.js +7 -0
- package/dist/ui/table.d.ts +10 -0
- package/dist/ui/table.js +31 -0
- package/dist/ui/tabs.d.ts +7 -0
- package/dist/ui/tabs.js +22 -0
- package/dist/ui/textarea.d.ts +3 -0
- package/dist/ui/textarea.js +6 -0
- package/dist/ui/toggle-group.d.ts +10 -0
- package/dist/ui/toggle-group.js +22 -0
- package/dist/ui/toggle.d.ts +9 -0
- package/dist/ui/toggle.js +25 -0
- package/dist/ui/tooltip.d.ts +7 -0
- package/dist/ui/tooltip.js +16 -0
- package/dist/view-edit-cell.d.ts +49 -0
- package/dist/view-edit-cell.js +198 -0
- package/dist/view-editor.d.ts +52 -0
- package/dist/view-editor.js +1081 -0
- package/dist/view-formulas.d.ts +61 -0
- package/dist/view-formulas.js +247 -0
- package/dist/view-icons.d.ts +20 -0
- package/dist/view-icons.js +70 -0
- package/dist/view-model.d.ts +390 -0
- package/dist/view-model.js +316 -0
- package/dist/whats-new.d.ts +3 -0
- package/dist/whats-new.js +53 -0
- package/dist/widget-controls.d.ts +9 -0
- package/dist/widget-controls.js +26 -0
- package/dist/widget-data.d.ts +4 -0
- package/dist/widget-data.js +32 -0
- package/dist/widget-settings.d.ts +516 -0
- package/dist/widget-settings.js +290 -0
- package/dist/widget-thumbs.d.ts +3 -0
- package/dist/widget-thumbs.js +29 -0
- package/dist/workspace-keys.d.ts +12 -0
- package/dist/workspace-keys.js +13 -0
- package/dist/workspaces.d.ts +57 -0
- package/dist/workspaces.js +473 -0
- package/package.json +82 -4
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { CONTAINS_KEY, meetsNumber } from '@columenlabs/lumen-ui-types/views';
|
|
3
|
+
// The one filter box every view uses, redone after p2e's:
|
|
4
|
+
// one chip per field in the sans face, `Account IRA, Roth ×`, included or excluded as a whole
|
|
5
|
+
// (`Symbol not AAPL`). A chip's tap opens its field: Include / Exclude, then its values ticked,
|
|
6
|
+
// with Only and the rows each keeps; unticking one leaves every other filter alone. The box's
|
|
7
|
+
// empty part (or +N) opens every filter on, each with ×, then the fields to add. Different
|
|
8
|
+
// fields: all must match (AND). Free text matches the row's text and any field value; picking
|
|
9
|
+
// "Text contains" keeps it as a chip. It filters the rows already loaded, on the client. The box
|
|
10
|
+
// is one row that never grows and always keeps room to type; crowded chips fold to `Sector: 3`,
|
|
11
|
+
// then behind +N. Phones get a bottom sheet.
|
|
12
|
+
import { Command as CommandPrimitive } from 'cmdk';
|
|
13
|
+
import { ChevronLeftIcon, ChevronRightIcon, SaveIcon, SearchIcon, TextSearchIcon, XIcon, } from 'lucide-react';
|
|
14
|
+
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
15
|
+
import { useIsMobile } from './hooks/use-mobile.js';
|
|
16
|
+
import { cn } from './lib/utils.js';
|
|
17
|
+
import { LINK_NAMES, LinkDot, LinkPicker, linkVar, useAutoFilterLink, } from './lumen-links.js';
|
|
18
|
+
import { Button } from './ui/button.js';
|
|
19
|
+
import { Checkbox } from './ui/checkbox.js';
|
|
20
|
+
import { CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandShortcut, } from './ui/command.js';
|
|
21
|
+
import { Input } from './ui/input.js';
|
|
22
|
+
import { InputGroup, InputGroupAddon, InputGroupButton } from './ui/input-group.js';
|
|
23
|
+
import { Popover, PopoverAnchor, PopoverContent } from './ui/popover.js';
|
|
24
|
+
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet.js';
|
|
25
|
+
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
|
|
26
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
|
|
27
|
+
const OP_SIGN = { gt: '>', lt: '<', eq: '=', between: '' };
|
|
28
|
+
export const tokenText = (t) => `${t.not ? '-' : ''}${t.key}:${t.op ? OP_SIGN[t.op] : ''}${t.value}`;
|
|
29
|
+
/** A number condition as people read it: `> 100`, `0 – 500`. */
|
|
30
|
+
const conditionText = (t) => t.op === 'between' ? t.value.replace('..', ' – ') : `${OP_SIGN[t.op ?? 'eq']} ${t.value}`;
|
|
31
|
+
/** `field:>100`, `field:<0`, `field:=5`, `field:0..500` typed out for a number field. */
|
|
32
|
+
const NUMBER_TYPED = /^(\w+):(>|<|=)?(-?\d+(?:\.\d+)?)(?:\.\.(-?\d+(?:\.\d+)?))?$/;
|
|
33
|
+
/** Same field and value, included or not: a value sits in one chip only. */
|
|
34
|
+
const same = (a, b) => a.key === b.key && a.value.toLowerCase() === b.value.toLowerCase();
|
|
35
|
+
const token = (key, value, not) => not ? { key, value, not } : { key, value };
|
|
36
|
+
/** The `contains` chip's field: free text over the row's own text. */
|
|
37
|
+
const CONTAINS = {
|
|
38
|
+
key: CONTAINS_KEY,
|
|
39
|
+
label: 'Text',
|
|
40
|
+
icon: TextSearchIcon,
|
|
41
|
+
values: () => [],
|
|
42
|
+
};
|
|
43
|
+
/** How long a `contains` value may be (the token's limit). */
|
|
44
|
+
const CONTAINS_MAX = 40;
|
|
45
|
+
/** Keeps the rows whose values pass every field's tokens, skipping the field `except`.
|
|
46
|
+
* `values[CONTAINS_KEY]` holds the row's text, which `contains` tokens read. */
|
|
47
|
+
function passes(values, nums, tokens, fields, except) {
|
|
48
|
+
for (const f of fields) {
|
|
49
|
+
if (f.key === except)
|
|
50
|
+
continue;
|
|
51
|
+
const of = tokens.filter((t) => t.key === f.key);
|
|
52
|
+
if (f.number) {
|
|
53
|
+
if (!of.every((t) => meetsNumber(t.op, t.value, nums[f.key] ?? null)))
|
|
54
|
+
return false;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const have = values[f.key] ?? [];
|
|
58
|
+
const inc = of.filter((t) => !t.not).map((t) => t.value.toLowerCase());
|
|
59
|
+
const exc = of.filter((t) => t.not).map((t) => t.value.toLowerCase());
|
|
60
|
+
if (inc.length && !have.some((v) => inc.includes(v)))
|
|
61
|
+
return false;
|
|
62
|
+
if (exc.length && have.some((v) => exc.includes(v)))
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (except === CONTAINS_KEY)
|
|
66
|
+
return true;
|
|
67
|
+
const hay = values[CONTAINS_KEY] ?? [];
|
|
68
|
+
const holds = (w) => hay.some((v) => v.includes(w));
|
|
69
|
+
const words = tokens.filter((t) => t.key === CONTAINS_KEY);
|
|
70
|
+
const inc = words.filter((t) => !t.not).map((t) => t.value.toLowerCase());
|
|
71
|
+
const exc = words.filter((t) => t.not).map((t) => t.value.toLowerCase());
|
|
72
|
+
if (inc.length && !inc.some(holds))
|
|
73
|
+
return false;
|
|
74
|
+
if (exc.length && exc.some(holds))
|
|
75
|
+
return false;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
/** A row value as filter text; a value that came back as a number or object (bad data) never throws. */
|
|
79
|
+
const lower = (v) => String(v ?? '').toLowerCase();
|
|
80
|
+
/** The rows that pass `tokens` (a widget's own filter), by the same rules as the filter box. */
|
|
81
|
+
export function filterByTokens(rows, fields, tokens,
|
|
82
|
+
/** The row's text, for `contains` tokens. */
|
|
83
|
+
text) {
|
|
84
|
+
if (!tokens.length)
|
|
85
|
+
return rows;
|
|
86
|
+
const used = fields.filter((f) => tokens.some((t) => t.key === f.key));
|
|
87
|
+
return rows.filter((row) => passes(Object.fromEntries([
|
|
88
|
+
...used.map((f) => [f.key, f.number ? [] : f.values(row).map(lower)]),
|
|
89
|
+
[CONTAINS_KEY, (text?.(row) ?? []).map(lower)],
|
|
90
|
+
]), Object.fromEntries(used.filter((f) => f.number).map((f) => [f.key, f.number?.(row) ?? null])), tokens, used));
|
|
91
|
+
}
|
|
92
|
+
/** A field is included or excluded as a whole: where a field holds both, as
|
|
93
|
+
* filters saved before that do, its included values stay. */
|
|
94
|
+
function onePerField(tokens) {
|
|
95
|
+
const inc = new Set(tokens.filter((t) => !t.not && !t.op).map((t) => t.key));
|
|
96
|
+
return tokens.some((t) => t.not && inc.has(t.key))
|
|
97
|
+
? tokens.filter((t) => !(t.not && inc.has(t.key)))
|
|
98
|
+
: tokens;
|
|
99
|
+
}
|
|
100
|
+
/** How many values a field lists in the menu before typing narrows it. */
|
|
101
|
+
const PER_FIELD = 8;
|
|
102
|
+
/** Keeps the tokens and text, and filters `rows` by them. */
|
|
103
|
+
export function useLumenFilter(rows, fields, { text: textOf, initial = [], } = {}) {
|
|
104
|
+
const [tokens, setRaw] = useState(() => onePerField(initial));
|
|
105
|
+
const setTokens = useCallback((next) => setRaw(onePerField(next)), []);
|
|
106
|
+
const [text, setText] = useState('');
|
|
107
|
+
const indexed = useMemo(() => rows.map((row) => ({
|
|
108
|
+
row,
|
|
109
|
+
values: Object.fromEntries([
|
|
110
|
+
...fields.map((f) => [f.key, f.number ? [] : f.values(row).map(lower)]),
|
|
111
|
+
[CONTAINS_KEY, (textOf?.(row) ?? []).map(lower)],
|
|
112
|
+
]),
|
|
113
|
+
nums: Object.fromEntries(fields.filter((f) => f.number).map((f) => [f.key, f.number?.(row) ?? null])),
|
|
114
|
+
})), [rows, fields, textOf]);
|
|
115
|
+
const results = useMemo(() => {
|
|
116
|
+
const q = text.trim().toLowerCase();
|
|
117
|
+
// A half-typed token ("sym", "symbol:", "-") narrows the menu, not the rows.
|
|
118
|
+
const free = q && !q.startsWith('-') && !q.includes(':') && !fields.some((f) => f.key.startsWith(q))
|
|
119
|
+
? q
|
|
120
|
+
: '';
|
|
121
|
+
return indexed
|
|
122
|
+
.filter(({ values, nums }) => {
|
|
123
|
+
if (!passes(values, nums, tokens, fields))
|
|
124
|
+
return false;
|
|
125
|
+
if (!free)
|
|
126
|
+
return true;
|
|
127
|
+
return Object.values(values)
|
|
128
|
+
.flat()
|
|
129
|
+
.some((v) => v.includes(free));
|
|
130
|
+
})
|
|
131
|
+
.map(({ row }) => row);
|
|
132
|
+
}, [indexed, fields, tokens, text]);
|
|
133
|
+
/** Whether rows have text of their own, so the box offers "Text contains". */
|
|
134
|
+
const hasText = !!textOf;
|
|
135
|
+
return { fields, rows, indexed, tokens, setTokens, text, setText, results, hasText };
|
|
136
|
+
}
|
|
137
|
+
/** A chip's widest (max-w-60), and the gap between chips (gap-1). */
|
|
138
|
+
const CHIP_MAX = 240;
|
|
139
|
+
const GAP = 4;
|
|
140
|
+
/** Room the box always keeps for typing (desktop) or for the tap target (phone). */
|
|
141
|
+
const TYPE_ROOM = 112;
|
|
142
|
+
const TAP_ROOM = 64;
|
|
143
|
+
/** Which chips fit one row of `avail` px (after p2e's filter): a chip with 2+
|
|
144
|
+
* values folds to a count when its values pass the chip's cap or the row is tight (biggest saving
|
|
145
|
+
* first); then chips from the end move behind "+N". */
|
|
146
|
+
function planChips(avail, full, short, more) {
|
|
147
|
+
const width = full.map((w) => Math.min(w, CHIP_MAX));
|
|
148
|
+
const count = full.map(() => false);
|
|
149
|
+
full.forEach((w, i) => {
|
|
150
|
+
if (w > CHIP_MAX && (short[i] ?? 0) < CHIP_MAX) {
|
|
151
|
+
count[i] = true;
|
|
152
|
+
width[i] = short[i] ?? 0;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
const total = (n) => width.slice(0, n).reduce((s, w) => s + w + GAP, 0);
|
|
156
|
+
const order = full
|
|
157
|
+
.map((_, i) => i)
|
|
158
|
+
.filter((i) => !count[i] && (short[i] ?? 0) < (width[i] ?? 0))
|
|
159
|
+
.sort((a, b) => (width[b] ?? 0) - (short[b] ?? 0) - ((width[a] ?? 0) - (short[a] ?? 0)));
|
|
160
|
+
for (const i of order) {
|
|
161
|
+
if (total(width.length) <= avail)
|
|
162
|
+
break;
|
|
163
|
+
count[i] = true;
|
|
164
|
+
width[i] = short[i] ?? 0;
|
|
165
|
+
}
|
|
166
|
+
let shown = width.length;
|
|
167
|
+
if (total(shown) > avail) {
|
|
168
|
+
shown = 0;
|
|
169
|
+
while (shown < width.length && total(shown + 1) + more <= avail)
|
|
170
|
+
shown++;
|
|
171
|
+
}
|
|
172
|
+
return { shown, count };
|
|
173
|
+
}
|
|
174
|
+
/** A chip's words: `Account IRA, Roth`, `Symbol not AAPL`, `Sector: 3`, `Symbol not in: 2`,
|
|
175
|
+
* `Unrealized > 0`, `Text “div”`. */
|
|
176
|
+
function chipWords(chip, field, form) {
|
|
177
|
+
const name = field?.label ?? chip.key;
|
|
178
|
+
const condition = field?.number && chip.tokens[0];
|
|
179
|
+
if (condition)
|
|
180
|
+
return { name, verb: '', shown: conditionText(condition), all: '' };
|
|
181
|
+
const quote = chip.key === CONTAINS_KEY;
|
|
182
|
+
const all = chip.tokens.map((t) => (quote ? `“${t.value}”` : t.value)).join(', ');
|
|
183
|
+
const counted = form === 'count' && chip.tokens.length > 1;
|
|
184
|
+
const verb = chip.not ? (counted ? 'not in:' : 'not') : counted ? ':' : '';
|
|
185
|
+
return { name, verb, shown: counted ? String(chip.tokens.length) : all, all };
|
|
186
|
+
}
|
|
187
|
+
const chipTitle = (w) => `${w.name} ${w.verb === ':' ? '' : `${w.verb} `}${w.all || w.shown}`.replace(/\s+/g, ' ');
|
|
188
|
+
/** One field's chip: its name, values and ×, in the sans face. A tap opens the field's values. */
|
|
189
|
+
function FilterChip({ chip, field, form, linkColor, className, onEdit, onRemove, }) {
|
|
190
|
+
const words = chipWords(chip, field, form);
|
|
191
|
+
const title = chipTitle(words);
|
|
192
|
+
const tint = linkColor ?? null;
|
|
193
|
+
return (_jsxs("span", { "data-slot": "lumen-filter-chip", title: title, className: cn('inline-flex h-6 max-w-60 shrink-0 items-center overflow-hidden rounded-md border border-border bg-muted text-sm', chip.not && 'border-destructive/35 bg-destructive/10', className), style: tint
|
|
194
|
+
? {
|
|
195
|
+
background: `color-mix(in srgb, ${linkVar(tint)} 14%, transparent)`,
|
|
196
|
+
borderColor: `color-mix(in srgb, ${linkVar(tint)} 35%, transparent)`,
|
|
197
|
+
}
|
|
198
|
+
: undefined, children: [_jsxs(Button, { variant: "ghost", size: "xs", className: "h-full min-w-0 shrink gap-1 rounded-none pr-0.5 pl-1.5 font-normal hover:bg-transparent", "aria-label": `Edit ${title}`, onClick: onEdit, children: [tint && _jsx(LinkDot, { color: tint, className: "size-2" }), _jsxs("span", { className: "shrink-0 text-muted-foreground", children: [words.name, words.verb === ':' && ':'] }), words.verb && words.verb !== ':' && (_jsx("span", { className: "shrink-0 text-destructive", children: words.verb })), _jsx("span", { className: "min-w-0 truncate tabular-nums", children: words.shown })] }), _jsx(Button, { variant: "ghost", size: "icon-xs", className: "size-5 shrink-0 text-muted-foreground hover:bg-transparent hover:text-foreground [&_svg]:size-3", "aria-label": `Remove ${title}`, onClick: onRemove, children: _jsx(XIcon, {}) })] }));
|
|
199
|
+
}
|
|
200
|
+
const OPS = [
|
|
201
|
+
{ op: 'lt', label: 'Less' },
|
|
202
|
+
{ op: 'gt', label: 'Greater' },
|
|
203
|
+
{ op: 'between', label: 'Between' },
|
|
204
|
+
];
|
|
205
|
+
const isNumber = (v) => /^-?\d+(\.\d+)?$/.test(v.trim());
|
|
206
|
+
/** A number field's condition in the menu, two lines: Less than, Greater
|
|
207
|
+
* than or Between, then the number (0 by default). Each change filters at once. */
|
|
208
|
+
function NumberCondition({ label, current, tall, count, onSet, }) {
|
|
209
|
+
const [from = '0', to = '0'] = current?.value.split('..') ?? [];
|
|
210
|
+
const [op, setOp] = useState(current?.op === 'eq' ? 'gt' : (current?.op ?? 'gt'));
|
|
211
|
+
const [a, setA] = useState(from);
|
|
212
|
+
const [b, setB] = useState(current?.op === 'between' ? to : '0');
|
|
213
|
+
const value = (o, x, y) => o === 'between' ? `${x.trim()}..${y.trim()}` : x.trim();
|
|
214
|
+
const set = (o, x, y) => {
|
|
215
|
+
if (isNumber(x) && (o !== 'between' || isNumber(y)))
|
|
216
|
+
onSet(o, value(o, x, y));
|
|
217
|
+
};
|
|
218
|
+
const box = cn('min-w-0 flex-1 px-2 tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none', tall ? 'h-10 text-base' : 'h-8 text-sm');
|
|
219
|
+
const ok = isNumber(a) && (op !== 'between' || isNumber(b));
|
|
220
|
+
return (_jsxs("div", { className: "flex flex-col gap-1.5 px-2 py-1", onKeyDown: (e) => {
|
|
221
|
+
e.stopPropagation();
|
|
222
|
+
if (e.key === 'Enter')
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
}, children: [_jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: current ? op : '', onValueChange: (v) => {
|
|
225
|
+
if (!v)
|
|
226
|
+
return;
|
|
227
|
+
setOp(v);
|
|
228
|
+
set(v, a, b);
|
|
229
|
+
}, "aria-label": `${label} condition`, className: "w-full", children: OPS.map((o) => (_jsx(ToggleGroupItem, { value: o.op, className: cn('flex-1 font-normal', tall ? 'h-10 text-base' : 'h-8 text-sm'), children: o.label }, o.op))) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { type: "number", inputMode: "decimal", className: box, "aria-label": `${label} ${op === 'between' ? 'from' : 'value'}`, value: a, onFocus: (e) => e.target.select(), onChange: (e) => {
|
|
230
|
+
setA(e.target.value);
|
|
231
|
+
set(op, e.target.value, b);
|
|
232
|
+
} }), op === 'between' && (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-muted-foreground text-sm", children: "and" }), _jsx(Input, { type: "number", inputMode: "decimal", className: box, "aria-label": `${label} to`, value: b, onFocus: (e) => e.target.select(), onChange: (e) => {
|
|
233
|
+
setB(e.target.value);
|
|
234
|
+
set(op, a, e.target.value);
|
|
235
|
+
} })] })), _jsx("span", { className: "w-16 shrink-0 text-right text-muted-foreground text-xs tabular-nums", children: current && ok ? `${count(op, value(op, a, b))} rows` : '' })] })] }));
|
|
236
|
+
}
|
|
237
|
+
export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label = 'Filter', className, saved, onSave, changed, link: given, }) {
|
|
238
|
+
const { fields, indexed, tokens, setTokens, text, setText, hasText } = filter;
|
|
239
|
+
// A box its card passes no link takes the card's own (every card carries a square).
|
|
240
|
+
const link = useAutoFilterLink(tokens, given);
|
|
241
|
+
// Phone: the box is one tappable row and the menu is a bottom sheet.
|
|
242
|
+
const mobile = useIsMobile();
|
|
243
|
+
const [open, setOpen] = useState(false);
|
|
244
|
+
// The field the menu is in (a chip's tap opens its field), with its own search text.
|
|
245
|
+
const [field, setField] = useState(null);
|
|
246
|
+
const [search, setSearch] = useState('');
|
|
247
|
+
// Include or Exclude picked for a field before it has a value.
|
|
248
|
+
const [pendingNot, setPendingNot] = useState(false);
|
|
249
|
+
// The values picked when a field opened stay first while it is open, so ticking one never moves the list.
|
|
250
|
+
const [pinned, setPinned] = useState(() => new Set());
|
|
251
|
+
const box = useRef(null);
|
|
252
|
+
const input = useRef(null);
|
|
253
|
+
const measure = useRef(null);
|
|
254
|
+
const menuBox = useRef(null);
|
|
255
|
+
const fieldOf = (key) => (key === CONTAINS_KEY ? CONTAINS : fields.find((f) => f.key === key));
|
|
256
|
+
// One chip per field, in the order they were added.
|
|
257
|
+
const chips = useMemo(() => {
|
|
258
|
+
const out = [];
|
|
259
|
+
for (const t of tokens) {
|
|
260
|
+
const chip = out.find((c) => c.key === t.key);
|
|
261
|
+
if (chip)
|
|
262
|
+
chip.tokens.push(t);
|
|
263
|
+
else
|
|
264
|
+
out.push({ key: t.key, not: !!t.not, tokens: [t] });
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}, [tokens]);
|
|
268
|
+
const chipOf = (key) => chips.find((c) => c.key === key);
|
|
269
|
+
// A leading `-` excludes what is picked next (a field with values keeps its own Include/Exclude).
|
|
270
|
+
const typed = (field ? search : text).trim().toLowerCase();
|
|
271
|
+
const negate = typed.startsWith('-');
|
|
272
|
+
const q = negate ? typed.slice(1) : typed;
|
|
273
|
+
// `field:value` typed out opens that field, searching its values.
|
|
274
|
+
const [qKey, qValue] = !field && q.includes(':') ? q.split(':', 2) : [null, q];
|
|
275
|
+
const typedField = qKey ? fields.find((f) => f.key === qKey || f.key.startsWith(qKey)) : null;
|
|
276
|
+
const active = field ?? typedField?.key ?? null;
|
|
277
|
+
const needle = field ? q : (qValue ?? '');
|
|
278
|
+
const notFor = (key) => {
|
|
279
|
+
const chip = chipOf(key);
|
|
280
|
+
return chip ? chip.not : key === field ? pendingNot : negate;
|
|
281
|
+
};
|
|
282
|
+
const toggle = (key, value) => {
|
|
283
|
+
const t = { key, value };
|
|
284
|
+
if (tokens.some((x) => same(x, t))) {
|
|
285
|
+
setTokens(tokens.filter((x) => !same(x, t)));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
setTokens([...tokens, token(key, value, notFor(key))]);
|
|
289
|
+
if (!field)
|
|
290
|
+
setText('');
|
|
291
|
+
else
|
|
292
|
+
setSearch('');
|
|
293
|
+
};
|
|
294
|
+
const only = (key, value) => {
|
|
295
|
+
setTokens([...tokens.filter((x) => x.key !== key), token(key, value, notFor(key))]);
|
|
296
|
+
};
|
|
297
|
+
const setNot = (key, not) => {
|
|
298
|
+
setPendingNot(not);
|
|
299
|
+
setTokens(tokens.map((t) => (t.key === key ? token(key, t.value, not) : t)));
|
|
300
|
+
};
|
|
301
|
+
// A number field holds one condition: a new one replaces it.
|
|
302
|
+
const setCondition = (key, op, value) => setTokens([...tokens.filter((x) => x.key !== key), { key, value, op }]);
|
|
303
|
+
// Rows a number condition keeps, given the other fields' tokens.
|
|
304
|
+
const numberCount = (key, op, value) => indexed.filter((r) => passes(r.values, r.nums, tokens, fields, key) &&
|
|
305
|
+
meetsNumber(op, value, r.nums[key] ?? null)).length;
|
|
306
|
+
const removeChip = (key) => {
|
|
307
|
+
setTokens(tokens.filter((t) => t.key !== key));
|
|
308
|
+
if (field === key)
|
|
309
|
+
setField(null);
|
|
310
|
+
};
|
|
311
|
+
const focus = () => {
|
|
312
|
+
if (!mobile)
|
|
313
|
+
input.current?.focus();
|
|
314
|
+
};
|
|
315
|
+
// A chip's tap opens its field; the box's empty part, or +N, opens every filter.
|
|
316
|
+
const openField = (key) => {
|
|
317
|
+
setField(key);
|
|
318
|
+
setPinned(new Set(tokens.filter((t) => t.key === key).map((t) => t.value)));
|
|
319
|
+
setSearch('');
|
|
320
|
+
setPendingNot(false);
|
|
321
|
+
if (key)
|
|
322
|
+
setText('');
|
|
323
|
+
setOpen(true);
|
|
324
|
+
focus();
|
|
325
|
+
};
|
|
326
|
+
const clear = () => {
|
|
327
|
+
setTokens([]);
|
|
328
|
+
setText('');
|
|
329
|
+
setField(null);
|
|
330
|
+
};
|
|
331
|
+
const sameTokens = (a, b) => {
|
|
332
|
+
const keyOf = (ts) => ts.map(tokenText).sort().join('\n');
|
|
333
|
+
return keyOf(a) === keyOf(b);
|
|
334
|
+
};
|
|
335
|
+
const dirty = !!onSave && (!!changed || !sameTokens(tokens, saved ?? []));
|
|
336
|
+
// Each field's values with counts. A field counts the rows the other fields' tokens keep, so a
|
|
337
|
+
// chip can take more values of its own field.
|
|
338
|
+
const counts = useMemo(() => {
|
|
339
|
+
const out = new Map();
|
|
340
|
+
for (const f of fields) {
|
|
341
|
+
if (f.number)
|
|
342
|
+
continue;
|
|
343
|
+
const m = new Map();
|
|
344
|
+
for (const { row, values, nums } of indexed) {
|
|
345
|
+
if (!passes(values, nums, tokens, fields, f.key))
|
|
346
|
+
continue;
|
|
347
|
+
for (const v of new Set(f.values(row)))
|
|
348
|
+
m.set(v, (m.get(v) ?? 0) + 1);
|
|
349
|
+
}
|
|
350
|
+
out.set(f.key, m);
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}, [fields, indexed, tokens]);
|
|
354
|
+
const picked = (key, value) => tokens.some((t) => same(t, { key, value }));
|
|
355
|
+
/** A field's values matching `n`: picked ones first, then the most common. In the open field
|
|
356
|
+
* "picked" means picked when it opened, so the order holds still while values are ticked. */
|
|
357
|
+
const valuesOf = (key, n, limit) => {
|
|
358
|
+
const first = (v) => (key === field ? pinned.has(v) : picked(key, v));
|
|
359
|
+
const m = new Map(counts.get(key) ?? []);
|
|
360
|
+
for (const t of tokens)
|
|
361
|
+
if (t.key === key && !m.has(t.value))
|
|
362
|
+
m.set(t.value, 0);
|
|
363
|
+
return [...m]
|
|
364
|
+
.filter(([v]) => !n || v.toLowerCase().includes(n))
|
|
365
|
+
.sort((a, b) => Number(first(b[0])) - Number(first(a[0])) || b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
366
|
+
.slice(0, limit);
|
|
367
|
+
};
|
|
368
|
+
// Free text (no `field:`) can stay as a "Text contains" chip.
|
|
369
|
+
const word = hasText && q && !active && !q.includes(':') ? q.slice(0, CONTAINS_MAX).trim() : '';
|
|
370
|
+
const containsWord = hasText && active === CONTAINS_KEY && needle ? needle.slice(0, CONTAINS_MAX).trim() : '';
|
|
371
|
+
const wordOf = (w, not) => w
|
|
372
|
+
? indexed.filter((r) => passes(r.values, r.nums, tokens, fields, CONTAINS_KEY) &&
|
|
373
|
+
(r.values[CONTAINS_KEY] ?? []).some((v) => v.includes(w)) !== not).length
|
|
374
|
+
: 0;
|
|
375
|
+
// One row: the chips that fit show, the rest wait behind "+N".
|
|
376
|
+
const [fit, setFit] = useState({
|
|
377
|
+
shown: chips.length,
|
|
378
|
+
count: [],
|
|
379
|
+
});
|
|
380
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-measures when the chips change.
|
|
381
|
+
useLayoutEffect(() => {
|
|
382
|
+
const row = box.current;
|
|
383
|
+
const sizes = measure.current;
|
|
384
|
+
if (!row || !sizes)
|
|
385
|
+
return;
|
|
386
|
+
const run = () => {
|
|
387
|
+
const reserved = [...row.querySelectorAll('[data-reserved]')].reduce((s, el) => s + el.offsetWidth, 0);
|
|
388
|
+
// The typing room never goes.
|
|
389
|
+
const avail = row.clientWidth - reserved - (mobile ? TAP_ROOM : TYPE_ROOM) - GAP * 2;
|
|
390
|
+
const widths = (form) => [...sizes.querySelectorAll(`[data-form=${form}]`)].map((el) => el.offsetWidth);
|
|
391
|
+
const more = sizes.querySelector('[data-more]')?.offsetWidth ?? 32;
|
|
392
|
+
setFit(planChips(avail, widths('full'), widths('count'), more));
|
|
393
|
+
};
|
|
394
|
+
run();
|
|
395
|
+
const ro = new ResizeObserver(run);
|
|
396
|
+
ro.observe(row);
|
|
397
|
+
return () => ro.disconnect();
|
|
398
|
+
}, [chips, mobile]);
|
|
399
|
+
const shownChips = chips.slice(0, fit.shown);
|
|
400
|
+
const hiddenChips = chips.slice(fit.shown);
|
|
401
|
+
const chipProps = (c) => ({
|
|
402
|
+
chip: c,
|
|
403
|
+
field: fieldOf(c.key),
|
|
404
|
+
linkColor: link?.color,
|
|
405
|
+
onEdit: () => openField(c.key),
|
|
406
|
+
onRemove: () => removeChip(c.key),
|
|
407
|
+
});
|
|
408
|
+
const onKeyDown = (e) => {
|
|
409
|
+
const empty = !(field ? search : text);
|
|
410
|
+
if (e.key === 'Backspace' && empty) {
|
|
411
|
+
// In a field, Backspace goes back to every filter; else it drops the last chip.
|
|
412
|
+
if (field)
|
|
413
|
+
setField(null);
|
|
414
|
+
else if (tokens.length)
|
|
415
|
+
setTokens(tokens.slice(0, -1));
|
|
416
|
+
}
|
|
417
|
+
else if (e.key === 'Escape') {
|
|
418
|
+
if (field) {
|
|
419
|
+
e.preventDefault();
|
|
420
|
+
setField(null);
|
|
421
|
+
}
|
|
422
|
+
else if (!mobile)
|
|
423
|
+
setOpen(false);
|
|
424
|
+
}
|
|
425
|
+
else if (e.key === 'Enter') {
|
|
426
|
+
const raw = (field ? `${field}:${search}` : text).trim();
|
|
427
|
+
// `field:>100`, `field:0..500` typed out on a number field sets its condition.
|
|
428
|
+
const n = NUMBER_TYPED.exec(raw);
|
|
429
|
+
const nf = n && fields.find((x) => x.number && x.key === n[1]?.toLowerCase());
|
|
430
|
+
if (n?.[3] && nf) {
|
|
431
|
+
e.preventDefault();
|
|
432
|
+
const op = n[4] ? 'between' : n[2] === '>' ? 'gt' : n[2] === '<' ? 'lt' : 'eq';
|
|
433
|
+
setCondition(nf.key, op, n[4] ? `${n[3]}..${n[4]}` : n[3]);
|
|
434
|
+
setText('');
|
|
435
|
+
setSearch('');
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
// `field:value` (or `-field:value`) typed out in full adds the token even when no row has it.
|
|
439
|
+
const m = /^(-?)(\w+):(\S+)$/.exec(raw);
|
|
440
|
+
const f = m && fields.find((x) => !x.number && x.key === m[2]?.toLowerCase());
|
|
441
|
+
if (m?.[3] && f && !valuesOf(f.key, m[3].toLowerCase(), 1).length) {
|
|
442
|
+
e.preventDefault();
|
|
443
|
+
toggle(f.key, m[3]);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
setOpen(true);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
// The shell's item keeps a tick at its end for pickers; the filter shows its own.
|
|
451
|
+
const tall = cn('[&>svg:last-child]:hidden', mobile && 'min-h-11');
|
|
452
|
+
const shortcut = 'tracking-normal tabular-nums';
|
|
453
|
+
// A value row: its tick, the value, Only and the rows it keeps.
|
|
454
|
+
const valueRow = (key, value, count, prefix) => {
|
|
455
|
+
const on = picked(key, value);
|
|
456
|
+
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) => {
|
|
457
|
+
e.stopPropagation();
|
|
458
|
+
only(key, value);
|
|
459
|
+
}, children: "Only" }), _jsx(CommandShortcut, { className: cn('ml-0 w-8 text-right', shortcut), children: count })] }, `${key}:${value}`));
|
|
460
|
+
};
|
|
461
|
+
const summary = (c) => {
|
|
462
|
+
const w = chipWords(c, fieldOf(c.key), 'full');
|
|
463
|
+
return (_jsxs("span", { className: "ml-auto min-w-0 truncate pl-3 text-right", children: [c.not && _jsx("span", { className: "text-destructive", children: "not " }), _jsx("span", { className: "tabular-nums", children: w.shown })] }));
|
|
464
|
+
};
|
|
465
|
+
const activeField = active ? fieldOf(active) : undefined;
|
|
466
|
+
const activeChip = active ? chipOf(active) : undefined;
|
|
467
|
+
const menu = activeField ? (_jsxs(_Fragment, { children: [_jsxs(CommandGroup, { className: "px-1 py-0.5", children: [_jsxs("div", { className: "flex items-center gap-1 px-1 py-1", children: [_jsx(Button, { variant: "ghost", size: "icon-xs", "aria-label": "All filters", onMouseDown: (e) => e.preventDefault(), onClick: () => {
|
|
468
|
+
setField(null);
|
|
469
|
+
setText('');
|
|
470
|
+
focus();
|
|
471
|
+
}, 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: (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: wordOf(containsWord, notFor(CONTAINS_KEY)) })] })), tokens
|
|
472
|
+
.filter((t) => t.key === CONTAINS_KEY)
|
|
473
|
+
.map((t) => valueRow(CONTAINS_KEY, t.value, wordOf(t.value, !!t.not)))] })) : (!activeField.number && (_jsx(CommandGroup, { className: "px-1 py-0.5", children: 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: () => {
|
|
474
|
+
setTokens([
|
|
475
|
+
...tokens.filter((t) => !same(t, { key: CONTAINS_KEY, value: word })),
|
|
476
|
+
token(CONTAINS_KEY, word, chipOf(CONTAINS_KEY)?.not ?? negate),
|
|
477
|
+
]);
|
|
478
|
+
setText('');
|
|
479
|
+
}, 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: 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
|
|
480
|
+
.filter((f) => f.label.toLowerCase().startsWith(q) || f.key.startsWith(q))
|
|
481
|
+
.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))) })), fields
|
|
482
|
+
.filter((f) => !f.number)
|
|
483
|
+
.map((f) => {
|
|
484
|
+
const vs = valuesOf(f.key, q, PER_FIELD);
|
|
485
|
+
return (vs.length > 0 && (_jsx(CommandGroup, { heading: f.label, className: "px-1 py-0.5", children: vs.map(([v, n]) => valueRow(f.key, v, n)) }, f.key)));
|
|
486
|
+
})] })) : (_jsxs(_Fragment, { children: [chips.length > 0 && (_jsx(CommandGroup, { heading: "Filters", className: "px-1 py-0.5", children: chips.map((c) => {
|
|
487
|
+
const f = fieldOf(c.key);
|
|
488
|
+
return (_jsxs(CommandItem, { value: `chip:${c.key}`, onSelect: () => openField(c.key), className: tall, children: [link?.color ? (_jsx(LinkDot, { color: link.color })) : (f && _jsx(f.icon, { className: "text-muted-foreground" })), _jsx("span", { className: "shrink-0", children: f?.label ?? c.key }), summary(c), _jsx(Button, { variant: "ghost", size: "icon-xs", className: "-my-1 shrink-0 text-muted-foreground", "aria-label": `Remove ${f?.label ?? c.key} filter`, onMouseDown: (e) => e.preventDefault(), onClick: (e) => {
|
|
489
|
+
e.stopPropagation();
|
|
490
|
+
removeChip(c.key);
|
|
491
|
+
}, children: _jsx(XIcon, {}) })] }, c.key));
|
|
492
|
+
}) })), _jsx(CommandGroup, { heading: chips.length ? 'Add' : undefined, className: "px-1 py-0.5", children: fields
|
|
493
|
+
.filter((f) => !chipOf(f.key))
|
|
494
|
+
.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(CommandShortcut, { className: shortcut, children: f.number ? '' : (counts.get(f.key)?.size ?? 0) })] }, f.key))) })] }));
|
|
495
|
+
// A field's last row: the card's colour (every chip of a coloured card is shared) and Remove.
|
|
496
|
+
const footer = activeChip && (_jsxs("div", { className: "flex items-center gap-1 border-t px-2 py-1.5", children: [link && (_jsxs("span", { className: "flex items-center gap-1 text-muted-foreground text-xs", children: [_jsx(LinkPicker, { color: link.color, label: activeField?.label ?? activeChip.key, onPick: link.onPick }), link.color
|
|
497
|
+
? `Shared with ${LINK_NAMES[link.color].toLowerCase()} cards`
|
|
498
|
+
: 'Share with cards'] })), _jsx(Button, { variant: "ghost", size: "xs", className: "ml-auto font-normal text-muted-foreground hover:text-destructive", onMouseDown: (e) => e.preventDefault(), onClick: () => removeChip(activeChip.key), children: "Remove filter" })] }));
|
|
499
|
+
const clearButton = (dirty || tokens.length > 0 || text) && (_jsxs(InputGroupAddon, { align: "inline-end", "data-reserved": true, className: "gap-0.5", children: [dirty && (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(InputGroupButton, { size: "icon-xs", "aria-label": "Save filters to view", onClick: () => onSave?.(tokens), children: _jsx(SaveIcon, {}) }) }), _jsx(TooltipContent, { children: "Save filters to view" })] })), (tokens.length > 0 || text) && (_jsx(InputGroupButton, { size: "icon-xs", "aria-label": "Clear filter", onClick: clear, children: _jsx(XIcon, {}) }))] }));
|
|
500
|
+
// Off-screen copies of every chip, to measure what fits.
|
|
501
|
+
const sizes = (_jsxs("div", { ref: measure, "aria-hidden": true, inert: true, className: "pointer-events-none invisible absolute top-0 left-0 flex h-0 gap-1 overflow-hidden", children: [chips.flatMap((c) => ['full', 'count'].map((form) => (_jsx("span", { "data-form": form, className: "flex shrink-0", children: _jsx(FilterChip, { ...chipProps(c), form: form, className: "max-w-none" }) }, `${c.key}:${form}`)))), _jsxs(Button, { "data-more": true, variant: "outline", size: "xs", className: "h-6 shrink-0 px-2", children: ["+", chips.length] })] }));
|
|
502
|
+
const chipRow = (_jsxs(_Fragment, { children: [shownChips.map((c, i) => (_jsx(FilterChip, { ...chipProps(c), form: fit.count[i] ? 'count' : 'full', className: cn(i === 0 && '-ml-1') }, c.key))), hiddenChips.length > 0 && (_jsxs(Button, { variant: "outline", size: "xs", className: "h-6 shrink-0 px-2 font-normal tabular-nums", "aria-label": `${hiddenChips.length} more filters`, onClick: () => openField(null), children: ["+", hiddenChips.length] }))] }));
|
|
503
|
+
const hint = tokens.length ? 'Type to filter…' : placeholder;
|
|
504
|
+
const searchHint = activeField
|
|
505
|
+
? `Search ${activeField.label.toLowerCase()}…`
|
|
506
|
+
: hasText
|
|
507
|
+
? 'Search text or values…'
|
|
508
|
+
: 'Search values…';
|
|
509
|
+
if (mobile) {
|
|
510
|
+
return (_jsxs("div", { className: cn('relative w-full', className), children: [sizes, _jsxs(InputGroup, { ref: box, "data-slot": "lumen-filter", className: "flex-nowrap gap-1 overflow-hidden bg-background", children: [_jsx(InputGroupAddon, { "data-reserved": true, children: _jsx(InputGroupButton, { size: "icon-xs", "aria-label": label, onClick: () => openField(null), children: _jsx(SearchIcon, {}) }) }), chipRow, _jsx(Button, { variant: "ghost", className: "h-full min-w-16 flex-1 justify-start px-1 font-normal text-muted-foreground hover:bg-transparent", "aria-label": label, onClick: () => openField(null), children: hint }), clearButton] }), _jsx(Sheet, { open: open, onOpenChange: (o) => {
|
|
511
|
+
setOpen(o);
|
|
512
|
+
if (!o)
|
|
513
|
+
setField(null);
|
|
514
|
+
}, children: _jsxs(SheetContent, { side: "bottom", className: "max-h-[85svh] gap-0 rounded-t-xl",
|
|
515
|
+
// The keyboard would cover the menu: search waits for a tap.
|
|
516
|
+
onOpenAutoFocus: (e) => e.preventDefault(), children: [_jsxs(SheetHeader, { className: "flex-row items-center pb-2", children: [_jsx(SheetTitle, { children: "Filters" }), _jsx(SheetDescription, { className: "sr-only", children: "Pick values to filter the rows." }), tokens.length > 0 && (_jsx(Button, { variant: "ghost", size: "sm", className: "mr-8 ml-auto font-normal text-muted-foreground", onClick: clear, children: "Clear all" }))] }), _jsxs(CommandPrimitive, { shouldFilter: false, loop: true, className: "flex min-h-0 flex-1 flex-col gap-2 px-3 pb-[max(1rem,env(safe-area-inset-bottom))]", children: [_jsx(CommandInput, { value: field ? search : text, onValueChange: field ? setSearch : setText, onKeyDown: onKeyDown, placeholder: searchHint, "aria-label": label, className: "text-base" }), _jsx(CommandList, { className: "max-h-none min-h-0 flex-1", "aria-label": "Filter suggestions", children: menu }), footer] })] }) })] }));
|
|
517
|
+
}
|
|
518
|
+
return (_jsxs(CommandPrimitive, { shouldFilter: false, className: cn('relative w-full', className), loop: true, children: [sizes, _jsxs(Popover, { open: open, onOpenChange: (o) => {
|
|
519
|
+
setOpen(o);
|
|
520
|
+
if (!o)
|
|
521
|
+
setField(null);
|
|
522
|
+
}, children: [_jsx(PopoverAnchor, { asChild: true, children: _jsxs(InputGroup, { ref: box, "data-slot": "lumen-filter", className: "flex-nowrap gap-1 overflow-hidden bg-background", children: [_jsx(InputGroupAddon, { "data-reserved": true, children: _jsx(SearchIcon, {}) }), chipRow, _jsx(CommandPrimitive.Input, { ref: input, "data-slot": "input-group-control", className: "h-full min-w-16 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground", placeholder: activeField ? searchHint : hint, "aria-label": label, value: field ? search : text, onValueChange: (v) => {
|
|
523
|
+
if (field)
|
|
524
|
+
setSearch(v);
|
|
525
|
+
else
|
|
526
|
+
setText(v);
|
|
527
|
+
setOpen(true);
|
|
528
|
+
}, onKeyDown: onKeyDown, onFocus: () => setOpen(true), onClick: () => setOpen(true),
|
|
529
|
+
// Focus moving into the menu's number row keeps the menu open.
|
|
530
|
+
onBlur: (e) => {
|
|
531
|
+
if (!menuBox.current?.contains(e.relatedTarget)) {
|
|
532
|
+
setOpen(false);
|
|
533
|
+
setField(null);
|
|
534
|
+
}
|
|
535
|
+
} }), clearButton] }) }), _jsxs(PopoverContent, { ref: menuBox, align: "start", className: "w-80 max-w-[calc(100vw-2rem)] p-0", onOpenAutoFocus: (e) => e.preventDefault(),
|
|
536
|
+
// The click that opens the menu lands on the box: it must not count as "outside".
|
|
537
|
+
onInteractOutside: (e) => {
|
|
538
|
+
if (box.current?.contains(e.target))
|
|
539
|
+
e.preventDefault();
|
|
540
|
+
},
|
|
541
|
+
// Clicks in the menu must not blur the input first (number inputs still take focus).
|
|
542
|
+
onMouseDown: (e) => {
|
|
543
|
+
if (!e.target.closest('input'))
|
|
544
|
+
e.preventDefault();
|
|
545
|
+
}, children: [_jsx(CommandList, { "aria-label": "Filter suggestions", children: menu }), footer] })] })] }));
|
|
546
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type ComponentType, type ReactNode } from 'react';
|
|
2
|
+
import { type LumenOption } from './lumen-selector.js';
|
|
3
|
+
/** A chip under the box (Pages, Banks, Symbols); `All` is always first. */
|
|
4
|
+
export type LumenFinderScope = string;
|
|
5
|
+
/** What one kind of nav page (the nav's `pageType`) shows as: its group, its chip and its icon. */
|
|
6
|
+
export interface LumenFinderPageType {
|
|
7
|
+
group: string;
|
|
8
|
+
scope: LumenFinderScope;
|
|
9
|
+
icon: Icon;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* How Find reads in an app: its chips, how each kind of nav page groups, where
|
|
13
|
+
* Settings sections sit and the box's placeholder. Every field has a default for a plain app.
|
|
14
|
+
*/
|
|
15
|
+
export interface LumenFinderConfig {
|
|
16
|
+
/** The chips after All; Pages and Settings by default. */
|
|
17
|
+
scopes?: LumenFinderScope[];
|
|
18
|
+
/** Nav pages by `pageType`; a type not listed (or none) is a page. */
|
|
19
|
+
pageTypes?: Record<string, LumenFinderPageType>;
|
|
20
|
+
/** The chip Settings sections show under; `Settings` by default. */
|
|
21
|
+
settingsScope?: LumenFinderScope;
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
}
|
|
24
|
+
/** One row of Find: a LumenSelector option that goes somewhere or does something. */
|
|
25
|
+
export interface LumenFinderRow extends LumenOption {
|
|
26
|
+
group: string;
|
|
27
|
+
/** The chip it shows under (Pages, Symbols, Banks); a scope with no chip shows under All only. */
|
|
28
|
+
scope: LumenFinderScope;
|
|
29
|
+
/** Where it goes: a path on this app, or another app's address. ⌘↵ opens it in a new tab. */
|
|
30
|
+
href?: string;
|
|
31
|
+
/** What it does instead (opens a sheet or a chart). */
|
|
32
|
+
run?: () => void;
|
|
33
|
+
/** Made for what was typed (a ticker's chains): shown first, whatever the query. */
|
|
34
|
+
pinned?: boolean;
|
|
35
|
+
}
|
|
36
|
+
interface FinderState {
|
|
37
|
+
open: boolean;
|
|
38
|
+
query: string;
|
|
39
|
+
}
|
|
40
|
+
/** Whether Find is open and what is typed in it; null outside the shell. */
|
|
41
|
+
export declare const useLumenFinder: () => FinderState | null;
|
|
42
|
+
/** Opens Find; null outside the shell. */
|
|
43
|
+
export declare const useOpenLumenFinder: () => ((open: boolean) => void) | null;
|
|
44
|
+
/** Adds rows to Find while the caller is mounted. Pass a memoized list (or null for none). */
|
|
45
|
+
export declare function useLumenFinderRows(rows: LumenFinderRow[] | null): void;
|
|
46
|
+
type Icon = ComponentType<{
|
|
47
|
+
className?: string;
|
|
48
|
+
}>;
|
|
49
|
+
/** Find for the shell: ⌘K, the rows apps add and the dialog. LumenShell mounts it. */
|
|
50
|
+
export declare function LumenFinder({ app, settings, openSettings, config, children, }: {
|
|
51
|
+
app: string;
|
|
52
|
+
settings: {
|
|
53
|
+
id: string;
|
|
54
|
+
title: string;
|
|
55
|
+
}[];
|
|
56
|
+
openSettings: (id: string) => void;
|
|
57
|
+
config?: LumenFinderConfig;
|
|
58
|
+
children: ReactNode;
|
|
59
|
+
}): import("react").JSX.Element;
|
|
60
|
+
/** The header's Find button. */
|
|
61
|
+
export declare function LumenFinderButton(): import("react").JSX.Element | null;
|
|
62
|
+
export {};
|