🔽 Select & Dropdown Components
This section documents the reusable dropdown and select components in lib/components/ui/select/.
There is no shared base component: SimpleSelect, SearchSelect and TreeSelect each
self-contain their open/close state, click-outside dismissal, keyboard navigation, and dropdown
positioning (position: fixed, so the dropdown is never clipped by overflow parents). The shared
logic that exists lives in plain TypeScript — optionFilter.ts (filtering, ranking and
keyboard-step helpers: SearchSelect uses all of them, SimpleSelect the step helpers), types.ts
(SelectOption) and treeSelect.ts (the tree shapes of TreeSelect). Every specialized select
composes one of the generic ones — except FxProviderSelect, a self-contained route picker, and
AssetPickerPanel, built on SelectPopover.
🏗️ Component Hierarchy
graph TD
SS["<b>SimpleSelect</b><br/><small>Fixed option list · Checkmark active<br/>Custom rendering via snippets</small>"]
SrS["<b>SearchSelect</b><br/><small>+ Substring search, ranked matches<br/>+ Section titles · Inline search · Create footer</small>"]
TS["<b>TreeSelect</b><br/><small>Two levels · Groups open and close<br/>Search across both levels · Full keyboard model</small>"]
SrS --> CSS["<b>CurrencySearchSelect</b><br/><small>+ Flag emoji · ISO code + name<br/>📡 <code>/utilities/currencies</code></small>"]
SrS --> IPS["<b>ImportPluginSelect</b><br/><small>+ Broker icon · Plugin description<br/>📡 <code>/brokers/import/plugins</code></small>"]
SrS --> BSS["<b>BrokerSearchSelect</b><br/><small>+ BrokerIcon · Role icon<br/>Brokers passed by the caller</small>"]
SrS --> MORE["<b>More specialized selects</b><br/><small>Asset · Country · Sector · User<br/>(same <code>SearchSelect</code> composition)</small>"]
TS --> ATS["<b>AssetTypeSelect</b><br/><small>+ Base types at the root · ETF and Crowdfunding families<br/>+ Composite type icons · <code>buildAssetTypeTree()</code></small>"]
TS --> STS["<b>SignalTreeSelect</b><br/><small>charts/ · signals panel picker<br/>Thin adapter since D-K1 · prefix <code>signal-tree</code></small>"]
FPS["<b>FxProviderSelect</b><br/><small>Self-contained FX route picker<br/>📡 <code>/fx/providers</code></small>"]
APP["<b>AssetPickerPanel</b><br/><small>Portfolio assets · single or multi choice<br/>On <code>SelectPopover</code> + <code>CheckMenu</code></small>"]
style SS fill:#e8f5e9,stroke:#2e7d32
style SrS fill:#e8f5e9,stroke:#2e7d32
style TS fill:#e8f5e9,stroke:#2e7d32
style CSS fill:#fff3e0,stroke:#e65100
style IPS fill:#fff3e0,stroke:#e65100
style BSS fill:#fff3e0,stroke:#e65100
style MORE fill:#fff3e0,stroke:#e65100
style ATS fill:#fff3e0,stroke:#e65100
style FPS fill:#fff3e0,stroke:#e65100,stroke-dasharray: 5 5
style APP fill:#fff3e0,stroke:#e65100,stroke-dasharray: 5 5
style STS fill:#f5f5f5,stroke:#9e9e9e
Generic selects, then domain-specific wrappers:
- 🟢 SimpleSelect / SearchSelect / TreeSelect — generic, self-contained selects with rendering
- 🟠 Specialized selects — domain data and rendering on top of a generic one; the dashed
FxProviderSelectandAssetPickerPanelcompose none of them - ⚪ SignalTreeSelect — lives in
charts/;TreeSelectwas generalized from it, and it is now a thin adapter overTreeSelect
BaseDropdown is gone
BaseDropdown.svelte was deleted (03/09) as an orphaned abstraction — the generic selects
now own their open/close and positioning logic directly. Docs or code that present
BaseDropdown as the foundation of this hierarchy are stale.
📋 SimpleSelect
A basic dropdown for selecting from a fixed list of options, similar to a native <select>.
- Renders a button trigger showing the selected value
- Displays options in a dropdown with checkmark on the active item
- Supports disabled state and custom item rendering via snippets
- Keyboard navigable
Used in: Filter dropdowns, form fields with small option sets (e.g., theme selector, page size).
🔎 SearchSelect
A dropdown with an integrated search box. Typing narrows the options, and the options that survive are ranked, best match first.
- Matching is a case-insensitive substring test — not a fuzzy one — on
value,labelandsearchText, and on theicononly when the icon is the symbol (an emoji or a flag the user can paste). A URL or a file path never matches: its characters are ours, not the user's, and a one-letter query would match every/icons/…path. - Section titles (
SelectOption.header) never match a query, are skipped by the keyboard, and are dropped when the search empties their section. - Configurable
maxVisibleItems(default: 8) with a scrollable list;dropdownPositionistop,bottomorauto. inlineSearchmode — the search input replaces the trigger while open (everySearchSelectwrapper on this page uses it); otherwise a separate search box with a clear (✕) button heads the list.loadingshows a loading row; theitemandselectedItemsnippets customise the rows and the trigger.createLabel+onCreateNewadd a sticky Create new footer that receives the typed query;createLabelFor(query)lets the footer name what it will create.- Keyboard: ↑/↓ skip titles and disabled rows and stop at the ends (no wrap-around), Enter picks the highlighted row, Escape closes; a printable key on the closed trigger opens it and starts the search.
- Reopening: only a touch or pen close blocks it for 200 ms (
closedByTouch), as a ghost click on the trigger can follow the tap; a mouse or keyboard close never does. Separately, with a value set, Enter is ignored for 200 ms after the trigger takes focus (triggerFocusedAt).
Used in: the SearchSelect-based wrappers below, plus direct uses such as the import wizard's
ImportAssetPicker.
🏅 Ranking the matches
The query decides which options survive; how each one matched decides their order (R13). Every survivor gets a tier, and the list is sorted by tier:
| Tier | The query… | Query csv against the import plugins |
|---|---|---|
| 0 | starts the value or the label |
— |
| 1 | starts a word inside the value or the label |
Generic CSV (broker_generic_csv) |
| 2 | appears inside the value or the label, mid-word |
— |
| 3 | was found only in searchText or in the emoji icon |
Avanza, DEGIRO, … — their descriptions say "CSV export" |
- An option's tier is the better of what its
valueand itslabelsay. A word starts after any character that is not a letter or a digit, in any script — a space,_,-,/,.,(— and every occurrence counts, not only the first one. searchTextand the icon can widen the result set but never promote an option — a match found only there is always tier 3 — because descriptions share vocabulary: the import plugins' descriptions all mention CSV, so before the ranking aCSVquery kept the whole list in source order — Generic CSV sixteenth, below the fold, with the highlight (the row Enter picks) on the first plugin of the list.- The sort is stable (ties keep the caller's order) and runs inside each section: rows are
reordered between two titles — the rows ahead of the first title form a section of their own —
and titles never move, so a row never leaves its section. The caller's structure — the sections
of
AssetSelectorImportAssetPicker— stays as decided. - Only the order depends on the tier, never the set. An empty (or blank) query keeps the source order, and the caller's array is never reordered in place.
A new query starts at the top. On every change of the query — typing, deleting, or the clear (✕) button — the highlight returns to the first selectable row and the list scrolls back to the top. The effect is keyed on the query value rather than on input events, precisely so that the clear button, which changes the query without typing, behaves the same way.
The rules live in optionFilter.ts (filterOptions()) and are unit-tested in
optionFilter.test.ts and SearchSelect.test.ts; e2e/select-components.spec.ts reproduces the
CSV case end to end on ImportPluginSelect.
🌳 TreeSelect
A two-level searchable select: groups that open and close, items inside them, a search box
that crosses both levels, and a full keyboard model. It was generalized from the signals panel's
indicator picker, charts/SignalTreeSelect.svelte, by decision D-K1 (workstream K, R15): the
mechanics moved as they were, and the four things that tied them to signals became props — the row
content (item and groupLabel snippets), the test ids (testIdPrefix), the model (inline
groups) and the trigger (showSelected + selectedItem).
The data shapes live in treeSelect.ts, so data builders can describe a tree without importing a
component:
interface TreeSelectItem {
value: string;
searchText: string; // matched as given: callers pass it lower-cased
}
interface TreeSelectGroup<T extends TreeSelectItem = TreeSelectItem> {
key: string;
label: string;
subtitle: string;
items: T[];
inline?: boolean; // items sit at the root: no group row, nothing to open or close
icon?: string; // for a custom groupLabel snippet; the default group row ignores it
}
Groups themselves are never selectable.
| Prop | Default | Purpose |
|---|---|---|
value |
'' |
Selected value (bindable) |
groups |
— | The tree |
item |
— | Snippet rendering a row (required) |
groupLabel |
label + subtitle | Snippet for the content of a group row; the chevron and the count badge stay the component's |
showSelected |
false |
true makes it a form field: the closed trigger shows the value through selectedItem. false keeps it an action picker that always shows its placeholder |
selectedItem |
— | Snippet for the closed trigger; used only with showSelected |
placeholder |
'' |
Text of the closed trigger (falls back to Select) |
testId |
— | Root test id; the trigger is ${testId}-button |
testIdPrefix |
'tree-select' |
Rows are ${prefix}-option-{value}, group rows ${prefix}-group-{key} |
flat |
false |
Ignore the grouping and render one flat listbox |
defaultExpanded |
'first' |
Which group opens by itself: the first collapsible one (only while none is open), the one holding the value ('selected'), or none ('none') |
searchPlaceholder, noMatchesText |
Search, No data available | Texts of the search box and of an empty result |
minDropdownWidth |
390 |
Lower bound of the dropdown width in px; it never shrinks below the trigger nor grows past the viewport |
triggerBorderClass |
the signals panel's strong border | Border of the closed trigger; a form field passes the border of its neighbours |
onchange |
— | Called with the chosen value |
Behaviour worth knowing:
- Search is a case-insensitive substring test on
searchTextalone, in source order — the tier ranking ofSearchSelectdoes not apply here. Groups left without matches disappear; while a query is active every surviving group is shown open, and the keyboard walks the items only. Each keystroke, and the clear button, move the highlight back to the first entry. - Keyboard: ↑/↓ move and wrap around at the ends (inherited from the signals picker —
SearchSelectstops instead), Home/End jump, Enter toggles a group or selects an item, → opens a closed group, ← closes an open one, Escape closes. On the closed trigger a printable key opens the dropdown and seeds the search, and ↑ opens it on the last entry. - Opening expands the
defaultExpandedgroup and, withshowSelected, highlights the selected item. - ARIA: the trigger is a
comboboxthat announces atree(alistboxwhenflat); rows aretreeitems (options), driven througharia-activedescendant. - Dropdown:
position: fixed, at most 420 px tall, opening upwards when less than 360 px remain below the trigger and more room is available above it.
The signals panel. charts/SignalTreeSelect.svelte, where these mechanics were born, is now a
thin adapter over TreeSelect: it supplies only SignalOptionContent as row content, the
signal-tree test-id prefix and the signals.selector.* texts, and stays an action picker whose
first family opens by itself. Its props and exported types did not change, so neither did
ChartSignalsSection. The panel's contract — ${testId}-button, signal-tree-group-* rows
publishing aria-expanded, signal-tree-option-*, the first group opening by itself, which
e2e/gallery.spec.ts relies on — is pinned by the characterization net
charts/SignalTreeSelect.test.ts, written against the pre-move baseline and required to stay green
unedited.
Used in: AssetTypeSelect and SignalTreeSelect.
💰 CurrencySearchSelect
A specialized SearchSelect for currency selection.
- Shows the flag emoji (or the symbol), the ISO code with its symbol, and the currency name
- Loads the list through the shared
currencyStore(session cache, reloaded when the language changes) - Searchable by code, name, symbol (€, $, £), ISO-2 country codes and localized country names
- Optional shortcuts at the top of the list — All currencies (
includeAll), Back to default (defaultCurrency), Original value (originalCurrency) — andconfiguredOnly, which keeps only the currencies at either end of a configured FX pair (fxRoutesStore.getConfiguredCurrencySet()), plusvalueanddefaultCurrency; the currency a chain route passes through is left out, since syncing a chain stores only the composed rate of its own pair
Used in: currency fields across the app — FX pair creation (base/quote), broker form, asset
modal, dashboard and asset detail target currency, settings.
Data source: GET /api/v1/utilities/currencies — ISO 4217 reference list.
🔌 FxProviderSelect
Despite its name and its folder, not a SearchSelect: a self-contained FX route picker for
currency pairs, which composes none of the generic selects.
- Selected routes in an
OrderableList— drag & drop sets their priority - Picker: routes found by DFS over the currency graph — a section of direct (1-step) routes and chain routes in collapsible sections by step count — with its own full-text search, where every space-separated token must match
- Provider info bar with icons, tooltips and a link to each provider's
docs_url - The
MANUALprovider is never offered (backend-only sentinel)
Used in: FxPairAddModal — creating and editing a currency pair.
Data source: GET /api/v1/fx/providers, through currencyGraphStore.
📥 ImportPluginSelect
A SearchSelect for BRIM import plugins.
- Shows each plugin with its broker icon, name and description
- Searches the plugin code and name — and the description, which only ever earns the lowest tier (see ranking)
compatiblePluginsrestricts and orders the list: the plugins compatible with an uploaded file, by backend priority- The plugin list is fetched once and cached at module level, shared by every instance
Used in: the import wizard (one parser per file) and the broker form (default import plugin).
Data source: GET /api/v1/brokers/import/plugins — registered BRIM provider plugins.
🏦 BrokerSearchSelect
A SearchSelect specialized for broker selection.
- Shows
BrokerIcon+ broker name for each option, with the user's role icon inlineSearchmode — type directly in the trigger to filter- The caller passes the
brokerslist;disabledIdsgreys out rows, andcreateLabel+onCreateNewadd a Create new footer
Used in: the transaction form (broker, and source/destination brokers of transfers), the import wizard (global broker) and the files page (broker assignment). Data source: none of its own — the list comes from the caller.
🏷️ AssetTypeSelect
The Type field of the asset modal (R14 + R15): a TreeSelect over the two-level asset type
taxonomy — base types at the root, the ETF and Crowdfunding families as groups, each listing
its generic member first. Props: value (bindable), testId, placeholder, onchange.
- The tree comes from
buildAssetTypeTree(t)inutils/assetTypes.ts, which walksASSET_TYPE_MENU_ORDER— hand-written: the enum's own order, with each family placed where its container sits. Runs of standalone types becomeinlinegroups (keys__root-N), so they sit at the root with no group row; each family becomes a group keyed by its container type (ETF,CROWDFUND), labelledassets.typeSections.<FAMILY>and drawn with the container's icon.ASSET_TYPE_FAMILYmaps each subtype to its container. - The generic member stays an ordinary, selectable row, first in its group. Groups are never
selectable, so the group row ETF opens the family, while the row ETF inside it is the choice
"an ETF of mixed or unstated content". It carries the hint
assets.typeHints.<FAMILY>— Mixed or unstated content, P2P and business lending. - Search runs on the enum value, the label and the hint, lower-cased: a code-minded user can type
etf_bond, everybody else the label. - TreeSelect settings:
showSelected(it is a form field),defaultExpanded="selected"(the family holding the current value opens by itself),minDropdownWidth={300}, the form-field border and theassets.typeSelect.*search and empty texts. - Rows show the type icon from
getAssetTypeIconUrl(), the label and the hint; members of a family are indented. - Test ids: trigger
${testId}-button—asset-modal-type-buttoninAssetModal— group rowsasset-type-tree-group-{FAMILY}, optionsasset-type-tree-option-{TYPE}.
It replaced the SimpleSelect fed by buildAssetTypeOptions(), which no longer exists. The type
filters of the asset list keep their own flat lists.
The menu is complete because a test says so
ASSET_TYPE_MENU_ORDER is written by hand to group the families, so it is no longer complete by
construction: a type added to the backend enum and forgotten here would silently be missing from
the dialog. utils/__tests__/assetTypeTables.test.ts reads the array — as text, because
generated.ts is gitignored — together with the Python enum, and fails when they differ. The
same gate covers PNG_MAP, the icon files on disk, the badge colours (each one the colour of
what the asset contains), the type filter of AssetTable, the families (every one contiguous
in the menu, its container first) and the tree buildAssetTypeTree() builds from them.
🖼️ Composite type icons
A subtype is drawn with a composite: its family's icon, whole, with a small pastille in the
bottom-right corner showing what it contains (decision D52, delivered as R16) — ETF_STOCK is the
ETF tag with the stock pastille. The container keeps saying what the instrument is, the pastille
says what it holds.
The pastille is the icon of the base type the subtype contains — PNG_MAP[primaryAssetType(x)] —
with one exception: ETF_MONETARY contains no base type and borrows liquidity. The mapping is
written out in ASSET_TYPE_CONTENT_ICON, next to PNG_MAP and ASSET_TYPE_FAMILY in
utils/assetTypes.ts, so that the exception stays visible.
The composites are static PNG files, not an overlay drawn at runtime: some twenty call sites draw
the type icon with three different technologies (Svelte <img>, hand-built HTML table cells,
ECharts rich text), and all of them go through getAssetTypeIconUrl() — so one file per subtype
reaches every one of them unchanged. An asset with a custom icon keeps its own.
scripts/compose_asset_type_icons.py generates them. It reads the three maps from assetTypes.ts
as text, refuses to run when PNG_MAP does not name the composite
{container file}-{content file} or when a base icon is missing, and writes every composite into
both frontend/static/icons/asset-types/ and mkdocs_src/docs/static/icons/asset-types/:
pipenv run python scripts/compose_asset_type_icons.py # regenerate the composites
pipenv run python scripts/compose_asset_type_icons.py --check # compare with the files on disk, write nothing
Rerun it whenever a base icon or one of the three maps changes. The same gate test holds the naming
convention, checks that every composite ships to both folders, and holds every pastille to the
content rule, ETF_MONETARY being the declared exception. There are seven composites today:
etf-stock, etf-bond, etf-commodity, etf-real-estate, etf-crypto, etf-liquidity
(ETF_MONETARY) and crowdfunding-real-estate.
🧺 AssetPickerPanel
A picker of portfolio assets: instruments out of the user's asset catalogue. Not to be confused
with ui/media/AssetPickerModal.svelte, which picks an image file. One panel, two modes:
mode="multi"— the Asset Global lab's +. Rows are checked, and one press adds them all (onadd, in the order they were checked). Theselectedassets are left out of the list,roomcaps how many rows can be checked,fullLabelis the note shown once they fill it, and the caller supplies thetriggersnippet. Select visible checks the visible rows up toroom, or unchecks them when there is nothing left to check. The search wants every word of the query, accents aside (pickerRows()).mode="single"— a drop-in forSearchSelect: the same test ids (the root${testId},${testId}-trigger,${testId}-search,search-select-option-{id},search-select-header-__section:{key}), the same search (filterOptions(), with its ranking) and the same keyboard (stepSelectable()). One click chooses and closes.sectionsandrestLabelwork as inAssetSelect; it also takesplaceholder,loading,disabled,dropdownPosition(default:auto) anddropdownMinWidth(default: 280). The trigger is one line at a fixed height: thew-4 h-4icon,ticker · nameand, for an inactive asset, the inactive badge.
Both modes share:
- A search box, and the Type and Currency filters (two
CheckMenus). verdicts, a map ofPickerVerdicts, read and never computed: anineligibleasset is listed read-only, with its reasons, in the${testId}-blockedsection titledblockedLabel; awarningone can be chosen and shows ⚠. An asset without a verdict is selectable.- The caller's order: the panel never sorts the rows (in single mode, a query ranks the matches, as
SearchSelectdoes). searchText, what a query matches besides the name. The default,assetSearchText(), is the ISIN, the ticker and the other codes — never the currency or the type (P3/A6).- No amounts: a row shows the icon, the name, the type and the currency.
In single mode the current value is never dropped: the trigger shows it whatever its verdict or the filters.
It is built on three files beside it:
SelectPopover.svelte— the dropdown shell, moved from the lab. It closes on a completed click outside, not on the press, and on Escape. An optionalplacementplaces it in viewport coordinates throughdropdownPlacement().CheckMenu.svelte— a compact multi-choice filter menu (nothing chosen means every value), with the test ids${testId}-button,${testId}-panel,${testId}-clearand${testId}-{value}.assetPicker.ts— the pure helpersapplyFilters(),foldForSearch(),pickerRows(),toggleVisibleRows(),visibleRowsAllChecked(),assetSearchText(),assetSelectOrder()(AssetSelect's order: active assets first, then by name withlocaleCompare) anddropdownPlacement(), and the typesPickerAsset,PickerVerdict,PickerSectionandSelectionFilters.
No production file of ui/select/ imports from components/risk/: the last block of
AssetPickerPanel.test.ts reads every source of the folder, tests aside, and fails on such an
import.
Used in: risk/LabAssetPicker.svelte (multi mode), on the Correlation tab of the Assets
page, and risk/BenchmarkSelect.svelte (single mode, BenchmarkSelect.svelte:197), the benchmark
picker of the Correlation tab and of the Dashboard's Risk tab. Planned, not done yet: the
Asset Comparison chart signal is to adopt the single mode.
Data source: none of its own — the caller passes assets and verdicts; the currency menu
takes its flags from currencyStore.