← Back to Examples

💧 Elixir Only

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

Declarative first — server logic, not client logic
The Custom Rendering and Data & API pages wire the element from an inline <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.

EO01 · Theme every select from config (no per-instance JS)

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"]} />

EO02 · Per-instance theming with CSS variables

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"]} />

EO03 · Cascading selects (LiveView-driven)

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.

Why push_event instead of just re-rendering 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).
Selected: org = —, unit = —

EO04 · Async search — LiveView tunneled (Elixir fetches GitHub)

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.

Why route through the server? (1) attach an auth token without leaking it to the client, (2) transform the response (filter, cache, enrich), (3) reach any backend API that doesn't allow CORS from the browser.

EO05 · Action button → server → selection (with a loader)

hook=true · action-button onClick uses ctx.element.pushToServer · reply → ctx.controller.setSelected(reply.values, { notify: true })

A real-world shape: customers pay online, so statuses settle with a delay. The ⚠️ Payment issues action button doesn't know the answer — it asks the server. Because the lookup takes a second or two, the callback shows a loader (ctx.controller.showMessage(…, { duration: 0 })) while it waits, then selects whatever the reply returns. The button reaches the server through ctx.element.pushToServer — the bridge the hook installs — since action callbacks can't call pushEventTo themselves.

This is the page's one bit of per-instance JS: action buttons carry function callbacks (onClick), which can't be serialized as HEEx attributes. But it's the same server round-trip as EO03/EO04 under the hood — the handle_event/3 below does the work and replies with {:reply, %{values: ids}, socket}.
Selected customers:
[]
# HEEx — options declarative; hook={true} installs the pushToServer bridge:
<.web_multiselect id="customers" hook={true} multiple options={@customers} />

// JS — the ONE callback on this page. Action-button onClick can't be an attribute
// (it's a function), but the work it triggers is a plain LiveView round-trip:
el.actionButtons = [
  { action: 'custom', text: '⚠️ Payment issues',
    onClick: (_ms, ctx) => {
      ctx.controller.showMessage('Loading recent payment issues…', { duration: 0 }); // loader
      ctx.element.pushToServer('payment_issues', {}, (reply) => {
        ctx.controller.hideMessage();
        ctx.controller.setSelected(reply.values, { notify: true });                   // select
      });
    } },
  { action: 'clear-all', text: 'Clear' },
];

# Elixir — the logic lives here: simulate a 1–2s upstream call, reply with ids:
def handle_event("payment_issues", %{"id" => "customers"}, socket) do
  Process.sleep(1500)
  {:reply, %{values: recent_issue_ids()}, socket}
end

EO06 · Client-side REST — fetch options from an external API (with a header)

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})}
Cross-origin note: a custom request header (anything beyond the CORS "simple" set) makes the browser send a preflight 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).
Opting in, overriding &amp; extending
Because the shared sheet is plain CSS in the shadow root, per-instance differences are just CSS scoping — no JS. Opt in or diverge with :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.
🔌 Server round-trip
0
The LiveView server saw 0 event(s) from the wrapper.
(pick an option anywhere on this page)