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 v2.0.0-rc.1
Aligned with upstream @keenmate/web-multiselect2.0.0-rc02.
- Bundled upstream — the 2.0.0 "core adoption" major — the bundled
@keenmate/web-multiselectjumps from1.12.0-rc08to2.0.0-rc02, rebuilt on the shared@keenmate/web-components-core(BlissElement). The dropdown engine, CSS, tree mode, and virtual scroll are unchanged; what moved into the core is the element plumbing — attribute parsing, reactivity, positioning, logging, and registration. For you it's mostly invisible:priv/static/multiselect.{js,css,d.ts}were re-bundled (still one self-contained file — the core is inlined), andKeenmate.WebMultiselect.upstream_version/0now reports"2.0.0-rc02". - Form integration is hook-free and rock-solid —
<.web_multiselect field={@form[:tags]}>inside a<.form phx-change ... phx-submit ...>just works with no hook: the element is a real form-associated control that writestags[]hidden inputs and now exposes a native.form, so LiveView'sphx-changelive validation andphx-submitboth see the selection like a native<select multiple>. The core rework briefly hid the form association behind a private field; upstream2.0.0-rc02restored a publicformgetter, so the wrapper dropped its old internals-reading shim and keeps only a defensiveclosest("form")fallback for pre-2.0 bundles. The README "Form integration" section was rewritten to show the hook-free pattern, and it's covered end-to-end by the form e2e suite. - Custom
value_memberevents forward clean scalars — if you key options by your own field (e.g.value_member="userId") and forward events through the LV hook,select/deselectnow push the extracted scalar value — not the whole option map. Previously the hook fell back to the raw option object when it couldn't find avalue/id, which crashed scalar-expecting server handlers (to_string/1on aMap). It now uses the element's own extractedselectedValuesplus the configured value-member, guaranteeing a scalar payload; a custom-value_membercase in the e2e suite guards it. - New
data_options_*attributes for HTML-authored option data — upstream 2.0.0 lets the rawdata-optionsattribute carry JSON, CSV, or plain-text; the wrapper exposes it viadata_options_format(jsondefault /csv/plain) plusdata_options_splitteranddata_options_row_splitterfor custom field/row delimiters. These only matter if you hand-write a rawdata-optionsstring — the normaloptions={...}prop always emits JSON, so the default path is untouched.
What's New in v1.0.0-rc.5
Aligned with upstream @keenmate/web-multiselect1.12.0-rc08.
- Independently sizable panels —
dropdown_width/selected_popover_width— the control input, the options dropdown, and the "N selected" popover are three separate panels, and you can now size the latter two independently of the input and of each other. Both are typed-attribute sugar over CSS variables:dropdown_widthwrites--ms-dropdown-width(which otherwise tracks the input width) andselected_popover_widthwrites--ms-selected-popover-width(intrinsic32rem). Set the variables at app/theme level to size every picker at once, or use the attributes to override a single instance. Documented inguides/theming.md("Independent panel widths") and demoed as section 14 on/examples/tree. - Tree metadata in the custom node renderer —
renderOptionContentCallback(item, ctx)— on tree rows the JS-side render callback's context now carries the hierarchy:isTreeNode,isBranch,isLeaf,childCount,level,depth,path,isSelectable, andisIndeterminate(the cascade tristate). A custom renderer can branch on node type without re-deriving it — e.g. draw a child-count badge on branches and a plain label on leaves — while the component still draws the tree chrome (checkbox, indentation, state classes) around your content. Demoed as the new section 12 ("Custom Node Rendering"). - Bundled
@keenmate/web-multiselectupgraded to1.12.0-rc08— beyond the two features above, rc08 adds a counter-chip tooltip, makes the cascade counter count the rolled-up cover (rather than the emit policy), and fixes two rough edges: a livecheckbox_mode/cascade_select_policyswitch now re-projects the current selection instead of needing a rebuild, and badge hover no longer washes out to white.priv/static/multiselect.{js,css,d.ts}were re-bundled andKeenmate.WebMultiselect.upstream_version/0now reports"1.12.0-rc08".
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 — no other wiring, and no hook required:
<.form for={@form} phx-change="validate" phx-submit="save">
<.web_multiselect field={@form[:tags]} options={@tag_options} />
<.button>Save</.button>
</.form>
That's the whole setup. It works because <web-multiselect> is a real form-associated custom element: it writes hidden inputs named after @form[:tags] (tags[], one per value) into the form, and — since it exposes a native .form — LiveView's phx-change delegation picks up every selection change. So phx-change (live validation) and phx-submit both see the selected values in params[form_name]["tags"], exactly like a native <select multiple>. Pre-selected values from the FormField render as badges on mount.
The optional hook={true} is orthogonal to this — add it only when you also want the raw select/deselect/change events pushed to the server as web_multiselect:* messages, or to drive the element from the server via push_update (see Driving the component from the server). Plain <.form> binding needs none of that.
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.
Theming
The component is styled entirely through CSS custom properties, in a two-tier
cascade: component tokens (--ms-*) each fall back to a shared design token
(--base-*), so it works out of the box, inherits a design system when one is
present, and is overridable per-instance from HEEx via class / style.
See the Theming guide for the three integration paths:
- With pure-admin — the multiselect inherits the
--base-*tokens pure-admin provides, matching palette and dark mode with zero configuration. - With other KeenMate components (no pure-admin) — define the
--base-*layer yourself once as a single source of truth; every component reads it. - Standalone — built-in
light-dark()fallbacks give a working light/dark theme; override--ms-*to restyle just the multiselect.
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-rc08"
For LLMs and coding agents
A flat-text knowledge base for coding agents ships in the package under the ai/
folder — modelled on the upstream component's ai/ layout but written for this
wrapper. Browse it in the
repository, or
read it from deps/keen_web_multiselect/ai/ in a consuming app: start at
ai/INDEX.txt (keyword index + common questions) or ai/cookbook.txt
(copy-paste recipes).
On hexdocs, see the Using with AI agents page. ex_doc also publishes a
machine-readable llms.txt for the package (the llms.txt
convention), so agents that fetch hexdocs.pm/keen_web_multiselect/llms.txt get a
structured index of the docs.
License
MIT.