Externalize the whole search with a searchCallback — the library stays lean
🌐 client — runs in the browser (element JS API) 🖥️ server — LiveView round-trip
Set el.searchCallback (JS) to delegate the entire search to your own engine — here, a FlexSearch index.
The built-in search is a lean case-insensitive substring match. When you need
fuzzy / partial / accent-insensitive / ranked matching, don't bloat the component —
externalize the whole search via searchCallback and back it with your own index. Here that's
FlexSearch (the same indexer
@keenmate/web-treeview uses), loaded client-side. It is never part of the
keen_web_multiselect bundle. From LiveView you can also handle search server-side
via the hook's search event — this page shows the client-side route.
// FlexSearch lives entirely in your app — never in the multiselect bundle.
import FlexSearch from "https://esm.sh/flexsearch@0.8.212";
// Fold diacritics so accented labels match plain ASCII (ü→u, é→e), applied to
// both the indexed text and the query so they meet on the same normalized form.
const fold = (s) => String(s || "").normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
// The wrapper feeds options via the `data-options` attribute, which upstream parses
// into `optionsSource` — read that when the JS `options` property is empty.
const opts = (Array.isArray(el.options) && el.options.length)
? el.options
: JSON.parse(el.optionsSource || "[]");
const index = new FlexSearch.Index({ tokenize: "full" }); // "full" = substring matching
opts.forEach((o, i) => index.add(i, fold(`${o.label} ${o.value} ${o.full_title}`)));
el.searchCallback = (term) =>
(index.search(fold(term), { limit: 80 }) || []).map((i) => opts[i]);
The demo below is a flat list of cities with accented names. FlexSearch Charset.Normalize folds
accents; tokenize: "full" matches partial tokens. Try zurich → Zürich,
sao → São Paulo, krak → Kraków, munch →
München. The built-in substring search would miss all of these.
External search now composes with tree mode — matches keep their ancestors, and the index covers title + code + breadcrumb.
The whole ISCO-08 occupation tree, searched through FlexSearch. The component rebuilds the hierarchy from
the returned matches, keeping each match's ancestors so indentation stays coherent. The
index covers each occupation's title, ISCO code, and full breadcrumb — search
2211 non, welder, or health. Only unit groups (leaves) are selectable.
Set search_event="…" (with hook=true) and the wrapper installs the
searchCallback for you — each query is tunneled to handle_event/3. No JavaScript.
The two demos above search in the browser (you set el.searchCallback in JS).
This one searches on the server: every keystroke is debounced, pushed to this LiveView,
matched against a catalog the browser never receives, and the dropdown is filled from your
{:reply, %{results: [...]}, socket}. It's the same request/reply the hook uses for async
search — one attribute, one handler. Stale queries auto-drop via the upstream AbortSignal
contract, so a slow reply never overwrites fresher results.
The catalog is programming languages, matched on name and paradigm. Try beam →
Elixir/Erlang/Gleam, jvm → Kotlin/Scala/Clojure, functional,
systems, or just rust.
# 1. Opt in — the hook installs a searchCallback that tunnels each query to the server.
<.web_multiselect
id="server-langs"
hook={true}
search_event="lang_search"
search_debounce={200}
min_search_length={1}
/>
# 2. Handle the query — you MUST reply with %{results: [...]} (not {:noreply, ...}).
def handle_event("lang_search", %{"id" => "server-langs", "query" => q}, socket) do
results = MyApp.Catalog.search(q) # your DB / API / index — stays on the server
{:reply, %{results: results}, socket}
end
nil · 0
queries handled. The full
40-item catalog stays on the server — only matches cross the wire.
(pick an option anywhere on this page)