RFC-008: Search Plugin #
Status: Draft
Author: floor
Type: Plugin / Feature
Created: 2026-05-29
Origin: #59 by @AzzaAzza69
Summary #
Add a search() plugin that provides a ready-to-use search bar with client-side filtering, match navigation, match highlighting, and server-side search integration via the data() plugin.
The consumer experience is zero-config:
const list = createVList({
container: "#app",
items: contacts,
item: { height: 48, template: renderContact },
}, [search()]);
This gives a fully functional search bar — type to filter, arrow keys to navigate matches, Enter to select, Escape to clear. Works with every layout (list, grid, table, tree) and scales from local arrays to server-backed datasets.
The feature set is split into two phases:
| Phase | Scope | Priority |
|---|---|---|
| Phase 1 | Search bar UI, client-side filter/navigate, keyboard, match highlighting, match counter | Core — ship first |
| Phase 2 | Server-side search via data(), debounce, column-aware search (table), fuzzy matching |
Power features |
Motivation #
Search is the most common interaction pattern after scrolling. Every file browser, contact list, data table, and settings panel needs it. Today, vlist users must build search from scratch — wire up an external input, filter items, call setItems(), and manage keyboard focus between the input and the list. This is tedious and error-prone.
@AzzaAzza69 proposed incremental search in #59, describing the core UX: type-ahead with a visible search bar, timeout-based cancel, and Windows Explorer-style invisible mode. This RFC expands that proposal into a full plugin with two modes, server-side integration, and cross-layout support.
Competitive landscape #
| Library | Built-in search | Server-side | Search bar UI | Highlight |
|---|---|---|---|---|
| AG Grid | Quick Filter + column filters | Yes (datasource) | External input | Cell highlight |
| Handsontable | Search plugin | Limited | Built-in bar | Yellow highlight |
| SlickGrid | DataView.setFilter | Manual | External | CSS classes |
| TanStack Table | Column filters | Via query | External | Manual |
| react-arborist | Filter prop | No | External | No |
| vlist (proposed) | search() plugin |
Yes (via data()) |
Built-in bar | Auto <mark> |
Most libraries require the consumer to build the search UI and wire the filter logic. vlist's search() plugin provides both out of the box.
Architecture #
Rendering model #
The search plugin does not own the render pipeline (no setRenderFn). Instead, it operates at the data layer:
User types query → search plugin filters items → calls list.setItems(filtered)
or navigates to match or updates match index + scrollToIndex
This makes search composable with any layout plugin — list, grid, table, tree — because it works upstream of rendering.
Filter mode: replaces the visible item set (like sorting).
Navigate mode: keeps all items visible, highlights and scrolls to matches.
Search bar DOM #
The plugin injects a search bar element into the vlist DOM structure:
root
├── search-bar (injected by search plugin)
│ ├── input
│ ├── match counter ("3 of 47")
│ ├── prev/next buttons
│ └── close button
├── viewport
│ └── content
Position is configurable: top (default), bottom, or none (invisible — keyboard-only, like Windows Explorer type-ahead).
Interaction with tree plugin #
The tree plugin (RFC-007 Phase 2) defines filterTree() for search with ancestor preservation. When both search() and tree() are active, the search plugin delegates filtering to filterTree() instead of calling setItems(). This preserves tree structure during search.
Phase 1: Core Search #
Config #
interface SearchPluginConfig<T> {
mode?: "filter" | "navigate"; // default: "filter"
position?: "top" | "bottom" | "none"; // default: "top"
placeholder?: string; // default: "Search…"
shortcut?: string; // default: "Ctrl+F" / "Cmd+F"
field?: string | ((item: T) => string); // field(s) to search — default: all string values
caseSensitive?: boolean; // default: false
highlight?: boolean; // default: true
minLength?: number; // minimum query length to trigger search — default: 1
cancelTimeout?: number; // auto-close search bar after N ms of inactivity (0 = never) — default: 0
}
Zero-config behavior #
search()
With no config:
- Search bar appears at the top
- Filter mode — non-matching items are hidden
- Searches all string-valued properties of each item
- Case-insensitive
- Match text highlighted with
<mark>in the DOM - Ctrl+F (Cmd+F on Mac) opens/focuses the search bar
- Escape clears and closes
Two modes #
Filter mode (default) — hide non-matching items:
search({ mode: "filter" })
- User types in search bar
- Plugin filters items: keeps items where
field(item)contains the query - Calls
list.setItems(filtered)(orfilterTree()for trees) - List re-renders with only matching items
- Match counter shows "47 results"
- Clear restores original items
Navigate mode — jump between matches:
search({ mode: "navigate" })
- User types in search bar
- Plugin scans items and builds a match index list
- First match is scrolled into view and highlighted
- Enter / ArrowDown jumps to next match, Shift+Enter / ArrowUp to previous
- Match counter shows "3 of 47"
- All items remain visible — only the current match is focused
- Clear removes highlights
Navigate mode is useful for log viewers, code displays, and any context where hiding items would lose context.
Field accessor #
By default, the plugin searches all string-valued properties of each item:
// Searches item.name, item.email, item.role, etc.
search()
Narrow to specific fields:
// Single field
search({ field: "name" })
// Custom accessor
search({ field: (item) => `${item.firstName} ${item.lastName}` })
For table layouts, the plugin should search across all visible column values by default.
Match highlighting #
When highlight: true (default), the plugin wraps matched text in <mark> elements:
<!-- Template returns: "Alice Johnson" -->
<!-- With query "john", rendered as: -->
Alice <mark class="vlist-search-match">John</mark>son
Implementation: After the template renders each item's innerHTML, the plugin performs a post-render pass that wraps matching substrings in <mark> tags. This works for string templates (innerHTML). For templates returning DOM elements, the plugin exposes state.search context for manual highlighting.
Current match (navigate mode) gets an additional class:
<mark class="vlist-search-match vlist-search-match--current">John</mark>
Template state #
The search plugin extends ItemState with search context:
interface SearchState {
matched: boolean; // whether this item matches the current query
query: string; // current search query (empty string if no search)
matchIndex: number; // this item's position in match list (-1 if not matched)
isCurrent: boolean; // whether this is the currently focused match (navigate mode)
}
Accessible as state.search in the template function. Useful for custom highlight rendering:
const template = (item, index, state) => {
const cls = state.search?.isCurrent ? "current-match" : "";
return `<div class="${cls}">${item.name}</div>`;
};
Keyboard #
| Key | Action |
|---|---|
| Ctrl+F / Cmd+F | Open/focus search bar |
| Escape | Clear query and close search bar |
| Enter | Next match (navigate mode) / close and focus first result (filter mode) |
| Shift+Enter | Previous match (navigate mode) |
| ArrowDown | Next match (when search bar is focused) |
| ArrowUp | Previous match (when search bar is focused) |
| Backspace | Remove last character from query |
| Any printable key | Append to query (when search bar is focused) |
When position: "none" (invisible mode), typing any printable key while the list has focus starts the search. Escape or navigation keys (ArrowUp/Down without Shift, PageUp/Down, Home/End) cancel and return to normal list navigation. This matches Windows Explorer's type-ahead behavior and @AzzaAzza69's original proposal.
cancelTimeout: when set to a non-zero value, the search bar auto-closes after N milliseconds of keyboard inactivity. Useful for the invisible mode where there's no explicit close button.
Match counter #
The search bar displays a match counter:
- Filter mode:
"47 results"(or"No results") - Navigate mode:
"3 of 47"(current match index of total matches)
Events #
| Event | Payload |
|---|---|
search:open |
{} — search bar opened/focused |
search:close |
{} — search bar closed |
search:change |
{ query, matches, total } — query or results changed |
search:match |
{ index, item } — navigated to a match (navigate mode) |
CSS Classes #
.vlist-searchon the search bar container.vlist-search--top/.vlist-search--bottomfor position.vlist-search-inputon the input element.vlist-search-counteron the match counter.vlist-search-prev/.vlist-search-nexton navigation buttons.vlist-search-closeon the close button.vlist-search-matchon highlighted match text.vlist-search-match--currenton the currently focused match (navigate mode).vlist--searchingon the vlist root while search is active
Methods #
| Method | Description |
|---|---|
openSearch() |
Open/focus the search bar |
closeSearch() |
Clear and close the search bar |
setQuery(query) |
Set the search query programmatically |
getQuery() |
Get current query |
nextMatch() |
Navigate to next match |
prevMatch() |
Navigate to previous match |
getMatches() |
Get array of matching item indices |
ARIA #
- Search bar:
role="search",aria-label="Search list" - Input:
aria-controlspointing to the list root - Match counter:
aria-live="polite"for screen reader announcements - Current match (navigate mode):
aria-current="true"on the focused match element
Plugin interactions #
| Plugin | Interaction |
|---|---|
| selection | Works — selection operates on filtered items in filter mode |
| scrollbar | Works — scrollbar adjusts to filtered content size |
| scale | Works — compression recalculates after filter |
| grid | Works — grid re-renders with filtered items |
| table | Works — Phase 2 adds column-aware search |
| tree | Works — delegates to filterTree() with ancestor preservation |
| groups | Works — filter mode preserves group structure (groups with no matches collapse) |
| data | Phase 2 — server-side search integration |
| autosize | Works — measurements apply to filtered items |
| snapshots | Works — search state is transient, not persisted in snapshots |
| sortable | Works — drag operates on filtered/visible items |
Bundle size target #
The search bar UI is lightweight DOM (input + counter + 3 buttons). Filtering is a single .filter() call. Target: +2.0–2.5 KB.
Phase 2: Power Features #
Server-side search via `data()` plugin #
When the data() plugin is active, the search plugin can delegate to a server-side search adapter:
const list = createVList({
container: "#app",
item: { height: 48, template: renderContact },
}, [
data({
adapter: {
read: async ({ offset, limit }) => { /* pagination */ },
search: async ({ query, offset, limit }) => {
const res = await fetch(`/api/contacts/search?q=${query}&offset=${offset}&limit=${limit}`);
return res.json(); // { items, total, hasMore }
},
},
}),
search(),
]);
Flow:
- User types query
- Search plugin debounces (configurable, default 300ms)
- Calls data plugin's
searchadapter - Data plugin replaces items with search results
- Subsequent scrolling pages through search results (not full dataset)
- Clear restores the original paginated view
Debounce strategy:
- Client-side filter: no debounce — JS is fast enough for 100K+ items
- Server-side search: 300ms default, configurable via
debounceconfig option - First keystroke shows a loading indicator immediately
search({ debounce: 200 }) // server-side debounce in ms
Column-aware search (table plugin) #
When combined with the table plugin, search can target specific columns:
search({ columns: ["name", "email"] }) // only search these columns
search({ columns: true }) // search all visible columns (default for table)
The plugin reads column definitions from the table plugin to know which fields to search. Highlighted matches appear within the correct cell.
Fuzzy matching #
Optional fuzzy search for more forgiving queries:
search({ fuzzy: true })
Uses a lightweight built-in fuzzy scorer (no external dependency). Matches are ranked by score — best match first. Fuzzy mode works best in navigate mode (ranking is meaningless when filtering hides items).
Implementation: Simple character-by-character scoring — bonus for consecutive matches, word-start matches, and camelCase boundaries. Not a full fuzzy library (Fuse.js-level), but good enough for type-ahead discovery.
Advanced query syntax #
Optional structured queries for power users:
search({ syntax: true })
Supports field-prefixed search:
name:alice role:admin // field-specific search
"exact phrase" // exact match
-archived // exclude term
This is opt-in. Default behavior is plain substring matching.
Open Questions #
Filter mode +
data()without search adapter — ifdata()is active but nosearchadapter is defined, should filter mode work on the currently-loaded items only (with a "partial results" warning), or should it be disabled entirely?Recommendation: work on loaded items with a visual indicator ("Searching loaded items only"). The consumer can add a search adapter later for full coverage.
Highlight implementation — post-render innerHTML replacement with
<mark>is simple but could break event listeners or complex DOM structures in templates. Should highlighting be opt-out, or should we only highlight when the template returns a string (not a DOM element)?Recommendation: highlight only string templates by default. For DOM element templates, expose
state.searchfor manual highlighting. Addhighlight: falseto opt out entirely.Search + groups interaction — in filter mode, should empty groups (all items filtered out) show the group header with a "no results" state, or be hidden entirely?
Recommendation: hide empty groups entirely. The group header with no items is confusing.
groupsplugin should handle this via a "minimum items" check.Invisible mode type-ahead vs. tree type-ahead — the tree plugin (RFC-007) has its own type-ahead for keyboard navigation. When both
search({ position: "none" })andtree()are active, which type-ahead wins?Recommendation: tree type-ahead handles single-character jumps (ARIA pattern). Search type-ahead activates on Ctrl+F or after a configurable key sequence. They serve different purposes — tree type-ahead navigates, search filters/navigates with a persistent query.
Should the search bar steal focus from the list? — when the user opens the search bar, keyboard focus moves to the input. This breaks list keyboard navigation (arrow keys). How to handle?
Recommendation: search bar captures focus while open. Enter or Escape returns focus to the list at the current/matched item. ArrowDown from the search input moves focus to the first match in the list. This matches the VS Code Ctrl+F pattern.
Implementation Order #
Phase 1a: Search bar DOM injection + input handling + CSS
Phase 1b: Client-side filter mode (setItems with filtered array)
Phase 1c: Navigate mode (match index, scrollToIndex, prev/next)
Phase 1d: Match highlighting (<mark> post-render pass)
Phase 1e: Keyboard (Ctrl+F, Escape, Enter, Shift+Enter, invisible mode)
Phase 1f: ARIA (role="search", aria-live counter, aria-current on match)
Phase 1g: Tree plugin integration (delegate to filterTree)
Phase 1h: Tests + docs + vlist-search.css
Phase 2a: data() plugin server-side search adapter
Phase 2b: Column-aware search (table plugin)
Phase 2c: Fuzzy matching
Phase 2d: Advanced query syntax
Estimated Sizes #
| Phase | Estimated gzip delta |
|---|---|
| Phase 1 | +2.0–2.5 KB |
| Phase 2 (server-side + column + fuzzy + syntax) | +1.5–2.0 KB |
| Full search plugin | +3.5–4.5 KB |
Relationship to Issue #59 #
This RFC is a direct evolution of @AzzaAzza69's incremental search proposal (#59). The original request maps to this RFC as follows:
| #59 Feature | RFC-008 |
|---|---|
| Typing starts incremental search | position: "none" invisible mode — typing while list is focused starts search |
| Subsequent keys append to search string | Standard search input behavior |
| Backspace removes last character | Standard input + invisible mode backspace handling |
| Esc/cursor/page keys cancel | Escape closes, arrow keys return to list nav |
cancelTimeout auto-cancel |
cancelTimeout config option (default: 0 = no auto-cancel) |
position: top/bottom/none |
position config option |
columnIndex for table search |
Phase 2 column-aware search via columns config |
| Search bar shows typed keys | Built-in search bar UI with input display |
The RFC adds: server-side search, match highlighting, navigate mode, match counter, tree integration, fuzzy matching, and ARIA accessibility — making the plugin production-ready across all vlist layouts.