Packages
Phoenix LiveView wrapper for the @keenmate/web-multiselect custom element (bundled JS+CSS, typed attrs for every documented option, optional LV hook).
Current section
Files
Jump to
Current section
Files
keen_web_multiselect
README.md
README.md
# keen_web_multiselect
Phoenix LiveView wrapper for [`@keenmate/web-multiselect`](https://github.com/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.5
_Aligned with upstream `@keenmate/web-multiselect` `1.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_width` writes `--ms-dropdown-width` (which otherwise tracks the input width) and `selected_popover_width` writes `--ms-selected-popover-width` (intrinsic `32rem`). Set the variables at app/theme level to size every picker at once, or use the attributes to override a single instance. Documented in `guides/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`, and `isIndeterminate` (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-multiselect` upgraded to `1.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 live `checkbox_mode` / `cascade_select_policy` switch 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 and `Keenmate.WebMultiselect.upstream_version/0` now reports `"1.12.0-rc08"`.
## 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"`.
## Install
```elixir
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:
>
> ```elixir
> {: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:
```sh
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`:
```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`:
```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`:
```elixir
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`):
```elixir
import Keenmate.WebMultiselect.Components
```
### Declarative — no JavaScript needed
```heex
<.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
```heex
<.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):
```heex
<.web_multiselect
id="tags"
hook={true}
options={@tag_options}
value={@selected_tags}
/>
```
```elixir
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):
```elixir
# 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:
```heex
<.web_multiselect
id="repos"
hook={true}
search_event="search_repos"
search_placeholder="Search GitHub…"
/>
```
```elixir
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:
```heex
<.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](https://github.com/keenmate/web-multiselect/blob/main/docs/usage.md) 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](guides/theming.md)** 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:
```elixir
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](https://github.com/keenmate/keen-web-multiselect/tree/HEAD/ai), 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](https://llmstxt.org)
convention), so agents that fetch `hexdocs.pm/keen_web_multiselect/llms.txt` get a
structured index of the docs.
## License
MIT.