keen_web_multiselect
Phoenix LiveView wrapper for @keenmate/web-multiselect — a themeable multi-select web component with typeahead, virtual scrolling, async search, and full keyboard navigation.
One package covers both plain HEEx and LiveView. The upstream JS + CSS are bundled, so no npm install is required.
What's New in v1.0.0-rc.2
- Docs — every component attribute is now documented — The
web_multiselect/1reference gained descriptions for the 46 attributes that previously rendered with a blank Description column in hexdocs (the behavior, badges, search, member, and virtual-scroll groups), each noting the relevant upstream default where useful, plus a short "Attribute defaults" preamble explaining thenil-means-omit convention. The install snippet was also corrected from the stale~> 0.1to~> 1.0(with a note on requiring~> 1.0.0-rcto opt into the current release candidate). Documentation-only — no code or behavior change.
What's New in v1.0.0-rc.1
- Component —
<.web_multiselect>covers the full upstream API as a pure render —Keenmate.WebMultiselect.Components.web_multiselect/1declares a typedattr/3for every documented<web-multiselect>attribute (booleans,values:-whitelisted enums, integers, JSON option lists), mapping snake_case in HEEx to kebab-case on the element (search_placeholder→search-placeholder). Booleans render as explicit"true"/"false"because several upstream booleans default totrueand need a real opt-out, not HTML presence. No GenServer, no state — the same call works identically in a dead view and a LiveView. - One-command installer —
mix keen_web_multiselect.install— Wires a standard esbuild Phoenix app for you: imports the bundledmultiselect.js+ hook intoassets/js/app.js, registersKeenWebMultiselectHookon yourLiveSocket(merging into the stockhooks: {...colocatedHooks}object), and importsmultiselect.cssintoassets/css/app.css. Idempotent and conservative — anything it can't confidently patch is left untouched and printed as a manual step.--dry-runpreviews. - Bundled assets — no npm install — The upstream
@keenmate/web-multiselectbuild (currently 1.12.0-rc05) ships inside the Hex package'spriv/static/alongsidemultiselect.d.tsand the LV hook;Keenmate.WebMultiselect.upstream_version/0reports which upstream you're getting. Wire it via the installer, an esbuild import fromdeps/, or aPlug.Staticmount. - LiveView events — opt in with
hook={true}— Sethook={true}and the hook forwards the component'sselect/deselect/changeevents to the server as"web_multiselect:select"/":deselect"/":change"with payload{id, value, values}, sohandle_event/3matches by id.hook={true}resolves to the bundled"KeenWebMultiselectHook"; pass a string for a custom hook. Omit it for plain HEEx where the form's hidden input is enough. - Server-driven updates —
Keenmate.WebMultiselect.push_update/3— Push new options or a new selection from the LiveView process:push_update(socket, "region", options: opts, value: []). It's the sanctioned path across thephx-update="ignore"boundary (which otherwise blocks LV from morphing option changes onto the element); only the keys you pass are sent, so it covers cascades, resets, and server-authoritative corrections cleanly. - Server-side search —
search_event—<.web_multiselect search_event="search_repos" hook={true} />installs an asyncsearchCallbackthat tunnels each query to your LiveView; reply with{:reply, %{results: [...]}, socket}and the dropdown fills — zero JavaScript. Superseded queries are dropped client-side (the upstreamAbortSignalcontract), andsearch_debouncecollapses keystroke bursts. - Form integration —
field={@form[:tags]}— Pass aPhoenix.HTML.FormFieldandFormHelpers.assign_from_field/1fillsid/name/value; explicit assigns win. The value flows into the upstreaminitial-valuesattribute, and the component writes a hidden input named after the field, sophx-change/phx-submitsee the selection inparams[form_name]["tags"]exactly like a native<select multiple>. - LiveView morph compatibility — three quirks handled for you — Putting a self-rendering custom element in LiveView needs three fixes the wrapper applies automatically:
phx-update="ignore"is emitted whenever:idis set (keeps morphdom out of the component's shadow children);data-ready=""is pre-emitted so the placeholder doesn't flash on WS connect; and a.formgetter is polyfilled onto the element (without it, Phoenix'sphx-changedelegation silently drops the component's CustomEvents). The polyfill installs even if you never opt into the hook.
Install
def deps do
[
{:keen_web_multiselect, "~> 1.0"}
]
end
Currently a release candidate. Only
1.0.0-rc.*is published so far, and Mix skips pre-releases for a plain~> 1.0constraint. Until1.0.0is final, opt in by requiring the pre-release explicitly:{:keen_web_multiselect, "~> 1.0.0-rc"}
Wire up the assets
The bundled JS and CSS live in this library's priv/static/ directory.
Quick start — the installer
On a standard esbuild Phoenix app, let the installer wire everything for you:
mix keen_web_multiselect.install
It edits assets/js/app.js (imports + LiveSocket hook registration) and
assets/css/app.css (stylesheet import), idempotently — re-running is safe. Pass
--dry-run to preview. Anything it can't confidently patch (an unusual LiveSocket
setup, an importmap app with no assets/js/app.js) is left untouched and printed as a
manual step. To wire it by hand, use one of the two paths below.
Path A — import from deps/ (esbuild, the Phoenix default)
In assets/js/app.js:
import KeenWebMultiselectHook from "../../deps/keen_web_multiselect/priv/static/keen_web_multiselect_hook.js";
import "../../deps/keen_web_multiselect/priv/static/multiselect.js";
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { KeenWebMultiselectHook },
params: { _csrf_token: csrfToken }
});
In assets/css/app.css:
@import "../../deps/keen_web_multiselect/priv/static/multiselect.css";
Path B — serve directly from the dep's priv/static
In your endpoint, add another Plug.Static:
plug Plug.Static,
at: "/keen_web_multiselect",
from: {:keen_web_multiselect, "priv/static"},
gzip: false,
only: ~w(multiselect.js multiselect.css keen_web_multiselect_hook.js)
Then reference /keen_web_multiselect/multiselect.js from your layout <script type="module"> tag and the CSS from a <link>.
Use the component
Import the component in the module where you render templates (a LiveView, a LiveComponent, or your MyAppWeb.html_helpers/0):
import Keenmate.WebMultiselect.Components
Declarative — no JavaScript needed
<.web_multiselect id="answer" multiple={false}>
<option value="yes">Yes</option>
<option value="no">No</option>
<option value="maybe" selected>Maybe</option>
</.web_multiselect>
Programmatic
<.web_multiselect
id="languages"
placeholder="Pick a language"
search_placeholder="Search…"
options={[
%{value: "js", label: "JavaScript", icon: "🟨"},
%{value: "ts", label: "TypeScript", icon: "🔷"},
%{value: "py", label: "Python", icon: "🐍"}
]}
value={["py"]}
/>
LiveView events
Set hook={true} and the hook will forward the upstream select, deselect, and change events to your LiveView (pass a string instead to name a custom hook):
<.web_multiselect
id="tags"
hook={true}
options={@tag_options}
value={@selected_tags}
/>
def handle_event("web_multiselect:change", %{"id" => "tags", "values" => values}, socket) do
{:noreply, assign(socket, :selected_tags, values)}
end
def handle_event("web_multiselect:select", %{"id" => "tags", "value" => value}, socket) do
# ...
end
Driving the component from the server
Because the element renders phx-update="ignore", LiveView's DOM patcher won't push
new options or a new selection to it. Use Keenmate.WebMultiselect.push_update/3
(the sanctioned channel — it sends the event KeenWebMultiselectHook listens for):
# Cascading selects — parent changed, swap the child's options and clear it
def handle_event("web_multiselect:change", %{"id" => "country", "values" => [c]}, socket) do
{:noreply, Keenmate.WebMultiselect.push_update(socket, "region", options: regions(c), value: [])}
end
# Server-authoritative rule — allow optimistically, then correct
def handle_event("web_multiselect:change", %{"id" => "tags", "values" => v}, socket) when length(v) > 3 do
{:noreply, Keenmate.WebMultiselect.push_update(socket, "tags", value: Enum.take(v, 3))}
end
Only the keys you pass are sent: value: [] clears the selection without touching
the options; options: opts swaps options and leaves the selection to the component.
The target needs hook={true} and a matching id.
Server-side search
Set search_event and the hook installs an async searchCallback that runs each
query through your LiveView — no JavaScript. Reply from handle_event/3 with
{:reply, %{results: [...]}, socket}; the results (in the usual option shape)
populate the dropdown:
<.web_multiselect
id="repos"
hook={true}
search_event="search_repos"
search_placeholder="Search GitHub…"
/>
def handle_event("search_repos", %{"id" => "repos", "query" => q}, socket) do
results =
q
|> MyApp.GitHub.search_repos()
|> Enum.map(&%{value: &1.id, label: &1.full_name})
{:reply, %{results: results}, socket}
end
The reply must use {:reply, %{results: ...}, socket} (not {:noreply, ...}) — the
hook resolves the pending search with reply.results. Queries that are superseded by
a newer keystroke are dropped client-side (the rc04 AbortSignal contract), so a slow
stale reply never overwrites fresher results. Pair with search_debounce to collapse
keystroke bursts into a single round-trip.
Form integration
Pass a Phoenix.HTML.FormField and the component fills in id, name, and the initial value:
<.simple_form for={@form} phx-change="validate">
<.web_multiselect
field={@form[:tags]}
options={@tag_options}
hook={true}
/>
</.simple_form>
The underlying <web-multiselect> writes a hidden input named after @form[:tags], so phx-change and phx-submit see the selected values in params[form_name]["tags"] just like a native <select>.
Attributes
Every documented attribute from the upstream component is exposed as a typed attr/3. See Keenmate.WebMultiselect.Components.web_multiselect/1 for the full list, or the upstream usage docs for what each one does.
Snake_case in HEEx maps to kebab-case on the rendered element: search_placeholder → search-placeholder, badges_display_mode → badges-display-mode, etc.
Versioning
keen_web_multiselect versions are independent of @keenmate/web-multiselect. The bundled upstream version is reported by:
Keenmate.WebMultiselect.upstream_version()
#=> "1.12.0-rc05"
License
MIT.