Pure HEEx + config. No inline scripts, no per-instance JS callbacks — the wrapper's declarative surface.
🌐 client — runs in the browser (element JS API) 🖥️ server — LiveView round-trip
<script> (wait(id).then(el => …)). Here it's HEEx and
config: options and selection as attributes, a shared shadow-DOM theme from
config, one-off tweaks via CSS variables, and server-driven data through the
hook. The lone exception is EO05's action button — onClick is a
function, so it can't be an attribute — but even there the actual work is a LiveView
round-trip (pushToServer → handle_event), so the logic stays in Elixir.
config :shadow_styles + <.shadow_styles/> in the layout · opt in with class="elixir-themed"
Set the shared CSS once in config and drop
<.shadow_styles/> in your root layout. The wrapper compiles it into one
stylesheet and adopts it into every <web-multiselect>'s shadow DOM
(adoptedStyleSheets) — which ordinary page CSS can't reach. This theme is
scoped with :host(.elixir-themed), so a select opts in just by adding a class.
And it's flash-free: with shared styles configured the wrapper adds
upstream's defer attribute (2.1.0) to each select, so it reserves space but
builds nothing until the registry has adopted the sheet — then releases the gate. The themed
pill badges paint in one shot, with no default-style flash beforehand.
# config/config.exs — configure the shared look ONCE
config :keen_web_multiselect,
shadow_styles: {:file, "priv/static/assets/ms-elixir-theme.css"}
<%!-- root layout <head>, once --%>
<Keenmate.WebMultiselect.Components.shadow_styles />
/* priv/static/assets/ms-elixir-theme.css */
:host(.elixir-themed) .ms__badge {
border-radius: 999px;
background: linear-gradient(135deg, #7c3aed, #ec4899);
border-color: transparent;
}
:host(.elixir-themed) .ms__badge-text,
:host(.elixir-themed) .ms__badge-remove {
background: transparent; color: #fff; border-color: transparent;
}
<.web_multiselect
id="langs"
class="elixir-themed"
options={@langs}
value={["js", "ts", "py"]} />
pure HEEx style="--ms-…" — scoped to one element, no config, no JS
For a one-off look, set the --ms-* / --base-* custom properties
right on the element. Custom properties pierce the shadow DOM, so this needs no callback at
all. Prefer this for anything variables can express; reach for the shared
adopted-stylesheet theme above only for the arbitrary shadow-DOM CSS residual.
<.web_multiselect
id="langs"
style="--ms-accent-color:#0ea5e9; --ms-badge-border-radius:9999px; --ms-input-border-radius:0.5rem;"
options={@langs}
value={["go", "rust"]} />
hook="KeenWebMultiselectHook" · server-driven options={@business_units} · push_event "web_multiselect:update"
Three coupled multiselects. Selecting an organization sends a change
event through the LV hook; the server looks up the matching business units and pushes the
new options back to the dependent select via
push_event(socket, "web_multiselect:update", %{id, options, value}). Same
flow from unit → department. No inline <script> — the whole cascade is
server state.
options={@business_units}? The
wrapper auto-emits phx-update="ignore"
on the element so morphdom doesn't tear down the component's internally-managed children.
That ignore also blocks LV from morphing the data-options
attribute on subsequent renders — so the canonical way to mutate option-list or selection
state from the server is through the hook's handleEvent
channel (equivalently Keenmate.WebMultiselect.push_update/3).
—, unit = —
search_event="github_search" · server replies {:reply, %{results: [...]}, socket}
Set search_event="github_search"
on the wrapper. The hook installs a searchCallback
that pushes %{"query" => q, "id" => id}
to the LV; the server fetches GitHub, returns {:reply, %{results: [...]}, socket}, and the reply resolves the promise in the browser.
hook=true · data-fetch-url="…" · data-fetch-headers={Jason.encode!(%{…})} — no <script>
Sometimes the options live behind a plain HTTP endpoint and there's no reason to relay
them through the LiveView. Point the element at the URL with data-fetch-url
and the hook fetches it on mount, assigning the JSON straight to options
(raw rows are consumed as-is via value_member/display_value_member).
It stays fully declarative — the attributes pass through the component's
:rest, so there's no per-instance JavaScript.
To add an HTTP header — an API key, an Authorization: Bearer …, a custom
X-* — pass data-fetch-headers a JSON object. Crucially the value is
rendered server-side from @assigns/config, so a secret is minted
in Elixir and never hardcoded into a JS bundle. Here we send a demo X-Demo-Header.
# HEEx — fully declarative. hook={true} installs the data-fetch-* wiring, and the
# element fetches its OWN options from the public API on mount. No <script>, no callback.
<.web_multiselect
id="rest-users"
hook={true}
multiple
value_member="id"
display_value_member="name"
subtitle_member="email"
placeholder="Loading users…"
data-fetch-url="https://jsonplaceholder.typicode.com/users"
data-fetch-headers={Jason.encode!(%{"X-Demo-Header" => "hello-from-elixir"})} />
# The header value is rendered SERVER-SIDE (from assigns/config), so an API key is
# minted in Elixir and never hardcoded in a JS asset:
# data-fetch-headers={Jason.encode!(%{"Authorization" => "Bearer " <> @api_token})}
OPTIONS first — the API must
answer it with a matching Access-Control-Allow-Headers, or the fetch is
blocked. jsonplaceholder reflects requested headers, so the demo works; your own API needs
the CORS headers configured. For any endpoint that doesn't allow browser CORS,
route the fetch through the LiveView instead (see EO04).
:host(.your-class)
(here, .elixir-themed); override one select with a more specific
:host(#id) … rule or inline style="--ms-…" variables. To extend the
sheet at runtime you can import the hook and call
registerShadowStyles(getShadowStyles() + extra), but you rarely need to. See the
theming guide.
(pick an option anywhere on this page)