keen_web_multiselect

Copy Markdown View Source

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.4

  • Tree of options — render options as an always-expanded hierarchy — give each option a materialized dot-path ("1", "1.1", "1.1.1", …) and set path_member; tree mode auto-enables and the wrapper derives parent/level from the path, rendering depth-first and indented. New typed attributes path_member, parent_path_member, level_member, has_children_member, and tree_path_separator cover the surface. There's no collapse — reach for @keenmate/web-treeview when you need expand/collapse. Documented in the new guides/tree_of_options.md (on hexdocs and in the package) and demoed on /examples/tree.
  • Cascade checkboxes and a value policy — checkbox_mode + cascade_select_policy — set checkbox_mode="cascade" and checking a node toggles its whole subtree, while a partially-selected branch shows a tristate box. Orthogonally, cascade_select_policy chooses which values a cascade selection emits: rolled-up (default) collapses a fully-selected subtree to one "complete node" value and surfaces individual descendants under partial branches, leaves emits only checked leaves, all emits every checked node. Those emitted values drive the badges, form value, and LiveView change event — so a whole selected branch can reach your server as a single value.
  • Per-node selectability — is_selectable_member — point it at a boolean key and a falsy value makes that node non-selectable: it renders normally (not greyed out like disabled) but has no checkbox, is skipped by keyboard focus, and can't be picked by Select All. Ideal for a leaves-only tree where branches are pure structure; a only_leaves_selectable/1 helper in the guide precomputes the flag from whether an option has children.
  • Badge full title — show the fully-qualified breadcrumb on badges — new full_title_member reads a breadcrumb that ships with your data (e.g. "Fruit / Pome fruit / Apple", never computed); turn on show_badge_full_title={true} and badges render it instead of the display value, falling back to the display value for options without one. Pairs naturally with tree mode to disambiguate leaves that share a name across branches.
  • Adjustable row height and long-title handling — CSS variables, no new attributes — deep tree indentation makes long labels likely, so you can set a consistent row height with --ms-option-min-height and/or truncate titles to one line with --ms-option-title-white-space: nowrap + -overflow: hidden + -text-overflow: ellipsis (pair with enable_option_tooltips={true} to reveal the full text on hover). These are option-level, so they work on flat lists too, and are scoped per-instance via the forwarded class/style.
  • External search now composes with tree mode — a JS-side searchCallback that returns matching options rebuilds the hierarchy from those matches, keeping their ancestors, so you can externalize the whole search over a tree. Demoed on the new /examples/search-index page (a client-side FlexSearch index over the full ISCO-08 occupation classification) which stays entirely in the demo app — never in the wrapper bundle.
  • Programmatic selection can announce itself — el.setSelected(values, {notify: true})setSelected stays silent by default (so restoring saved state or a server-authoritative correction doesn't fire change or bounce in a loop), but the new opt-in fires a single aggregate change for a deliberate gesture like a custom action button that should reach the same handle_event a manual pick does.
  • Bundled @keenmate/web-multiselect upgraded to 1.12.0-rc07 — carries all of the above at the component level plus more pronounced option checkboxes (a dedicated mid-grey #8f8f8f border via --ms-checkbox-border-color), and fixes: row height no longer jumps as filtering crosses the virtual-scroll threshold, a tree no longer blanks out after clearing a search, and option rows no longer text-select on click-drag. KeenWebMultiselect.upstream_version/0 now reports "1.12.0-rc07".

What's New in v1.0.0-rc.3

  • Docs — a dedicated Theming guide — The component is styled entirely through CSS custom properties, so how those variables work is core knowledge — and the wrapper shipped none of it. The new Theming guide (in the Hex package and on hexdocs) explains the two-tier cascade where each component token --ms-* falls back to a shared design token --base-*, then walks the three integration paths: inheriting pure-admin's tokens for free, defining your own --base-* layer shared across KeenMate components, or running standalone with the built-in light-dark() fallbacks and per-instance --ms-* overrides from HEEx class/style. Dark-mode signals, --ms-rem sizing, and the unlayered-reset footgun are covered too.
  • Package — the live examples site is now linked from Hexmix.exs gained a homepage_url and an "Examples site" link pointing at keen-web-multiselect.keenmate.dev, so the running demo gallery is one click from the package page and hexdocs sidebar.
  • LLM / coding-agent docs — an ai/ folder ships in the package — A flat-text knowledge base (ai/INDEX.txt + topic files + a 12-recipe ai/cookbook.txt) written for the wrapper, so a coding agent can learn <.web_multiselect>, the LiveView hook, push_update/3, search_event, forms, and theming from deps/keen_web_multiselect/ai/. On hexdocs it's fronted by a Using with AI agents guide, plus ex_doc's generated llms.txt index.

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.0 constraint. Until 1.0.0 is 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.

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_placeholdersearch-placeholder, badges_display_modebadges-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-rc07"

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.