← Back to Examples

🔔 Events, Handlers & Interceptors

DOM events, the on* property twin, and before* veto interceptors

🌐 client — runs in the browser (element JS API) 🖥️ server — LiveView round-trip

EV01 · Events — addEventListener & the on* twin

Client-only: DOM change/select/deselect events and their on* property twins — wired in JS, no wrapper attribute.

Every notification is available two ways and both fire for the same action: the bubbling DOM events select / deselect / change, and the JS properties onSelect / onDeselect / onChange. They're events, not callbacks — the return value is ignored. This picker wires both so you can watch them pair up.

[prop] = on* property handler  ·  [event] = addEventListener
Pick an option — [prop] and [event] entries log here…
Show code
// Property handlers (the on* form) — since upstream 2.0.0 these install real
// event listeners, so each receives the CustomEvent (read e.detail.*), not a
// bare argument. Return value ignored.
el.onSelect   = (e) => log('prop', 'onSelect', e.detail.option.label);
el.onDeselect = (e) => log('prop', 'onDeselect', e.detail.option.label);
el.onChange   = (e) => log('prop', 'onChange', e.detail.selectedValues);

// The very same notifications as bubbling DOM events
el.addEventListener('change', (e) => {
  // e.detail = { option, selectedOptions, selectedValues }
  log('event', 'change', e.detail.selectedValues);
});

EV02 · beforeSelectCallback — block a selection

Client-only interceptor: set el.beforeSelectCallback in JS and return false to veto — no wrapper attribute.

An interceptor: runs before an option is added and returns false to block it. Here Full-time and Part-time are mutually exclusive — picking one while the other is selected is vetoed. The veto is silent (no event fires); the message below is produced by the callback itself.

Try selecting Full-time, then Part-time.
Selections and blocks log here…
Show code
const EXCLUSIVE = ['full', 'part'];

el.beforeSelectCallback = (option, selected) => {
  // block if the option conflicts with something already selected
  const conflict = EXCLUSIVE.includes(option.value)
    && selected.find(o => EXCLUSIVE.includes(o.value) && o.value !== option.value);
  if (conflict) {
    showMessage(`Blocked: can't pick ${option.label} while ${conflict.label} is selected`);
    return false;            // ← veto
  }
  // return undefined ⇒ allow
};

EV03 · beforeDeselectCallback — protect a required item

Client-only interceptor: set el.beforeDeselectCallback in JS and return false to protect an item — no wrapper attribute.

The mirror interceptor on the way out. Team Lead is pre-selected and required — trying to remove it is vetoed, while the other members can be toggled freely. Note the veto applies only to interactive removal: programmatic setSelected() and the Clear-All button bypass it.

Team Lead can't be removed; the others can.
Try removing Team Lead, then a developer…
Show code
el.beforeDeselectCallback = (option) => {
  if (option.value === 'lead') {
    showMessage('Team Lead is required — cannot be removed');
    return false;            // ← veto removal
  }
};
el.setSelected(['lead']);    // pre-select (bypasses the veto)

EV04 · showMessage() — surface a message over the component

Client-only: el.showMessage(text, { variant }) / el.hideMessage(). A beforeSelect callback returning a string vetoes and shows it as a warning toast.

showMessage surfaces a transient toast anchored to the control (pinned over the sheet while a fullscreen overlay is open). This picker caps the selection at 3 — the beforeSelectCallback returns a string past the cap, which both vetoes the pick and shows the reason.

EV4b · Add new — turn the picker into a creation tool

Client-only: allow_add_new + add_new_text turn a no-match search into a clickable prompt; the addNewCallback (async, cancelable) and the add event are wired in JS with no wrapper attribute.

With allow-add-new on, a search that finds nothing shows a clickable "Add new …" prompt instead of the empty message. Choosing it (click or Enter) fires the add event. The prompt label is set statically via add-new-text (the {value} placeholder is the typed text) or dynamically via getAddNewTextCallback. addNewCallback is async and cancelable — return a rich option to create + select it, or null to abort (try adding a name that already exists, e.g. "Anna").

Try typing "Dana", then click the prompt (or press Enter).
The add event and resulting selection log here…
Show code
<web-multiselect allow-add-new add-new-text='Add new member: "{value}"'></web-multiselect>

// Async + cancelable. Return a rich option to create + select it,
// or null/undefined to abort (no add, no `add` event, search kept).
el.addNewCallback = async (value) => {
  if (await api.exists(value)) return null;        // ← cancel
  return await api.create(value);                  // ← rich option → create + select
};

// …and/or just listen for the intent (fires whether or not a callback ran):
el.addEventListener('add', (e) => {
  // e.detail = { value, option?, selectedOptions, selectedValues }
  log('event', 'add', e.detail.value);
});

// Dynamic label instead of the {value} template:
el.getAddNewTextCallback = (v) => `Create "${v}"`;

EV4c · Add new — async creation with a rich option

Client-only: an async addNewCallback plus add_new_pending_text shows a spinner while the create round-trips; the returned rich option renders like the seeded rows.

A real creation usually hits a server. Here addNewCallback is async with a deliberate ~1.2 s delay, so you can watch the picker show a spinner + "Adding …" in the prompt while it works, then drop the created member into the list. The new option comes back as a rich object (avatar + role subtitle) and renders exactly like the seeded members.

Seeded with the Hornes — Audrey, Benjamin & Jerry. Type Bobby Briggs (not in the list) and pick the prompt to add him.
The add event logs here after the delay…
Show code
<web-multiselect allow-add-new subtitle-member="subtitle" icon-member="icon"
  add-new-text='➕ Add new member: "{value}"'
  add-new-pending-text='Adding "{value}" to the project…'></web-multiselect>

el.options = [
  { value: 'audrey',   label: 'Audrey Horne',   subtitle: "One Eyed Jack's",        icon: '👩' },
  { value: 'benjamin', label: 'Benjamin Horne', subtitle: 'Great Northern Hotel',   icon: '👨' },
  { value: 'jerry',    label: 'Jerry Horne',    subtitle: "Horne's Dept. Store",    icon: '🧑' },
];

// Async: a short delay shows the pending spinner. Returns a RICH option
// (subtitle + icon) so the created member renders like every other row.
el.addNewCallback = async (value) => {
  await new Promise(r => setTimeout(r, 1200));      // ← server round-trip
  return {
    value: value.trim().toLowerCase().replace(/\s+/g, '-'),
    label: value.trim(),
    subtitle: 'New member · just added',
    icon: '🆕',
  };
};

EV05 · Callback vs event — the one rule

The line is drawn by whether the component uses the return value:

  • Event (fire-and-forget, return ignored): onSelect, onDeselect, onChange, and the bare DOM select/deselect/change events.
  • Interceptor callback (return consumed to cancel): before*Callback — beforeSelectCallback, beforeDeselectCallback, beforeSearchCallback.
  • Data/behavior callback (return consumed as a value): get*Callback / render*Callback / searchCallback.
Wrapper note:
In rc05 the fire-and-forget notifications were renamed from *Callback to on* (breaking, no alias), and the action-button predicates from isVisibleCallback/isDisabledCallback to getIsVisibleCallback/getIsDisabledCallback. The DOM event names (select/deselect/change) are unchanged, so the KeenWebMultiselectHook is unaffected.

🧩 Wrapper-specific extras (not in upstream)

Below are demos that exist only in this wrapper's example page — they have no upstream EV counterpart: an extra client-only callback, and the LiveView-native server-side binding path.

keydownCallback — a keyboard hook

Client-only: set el.keydownCallback = (ctx) => …; return true to mark the key fully handled — no wrapper attribute.

Runs on every keydown before all built-in handling, with a context (event, key, isOpen, focusedOption, selectedValues, …) and an imperative controller. Here Ctrl+A selects every option while the dropdown is open.

Open the dropdown, then press Ctrl+A.

🔌 Server-side binding — crossing to LiveView

Everything above is client-only. The wrapper's KeenWebMultiselectHook is the LiveView-native path: add hook=true and the same select/deselect/change events cross the wire to your handle_event/3 as "web_multiselect:select" / ":deselect" / ":change" (payloads %{"id" => id, "value" => v, "values" => vs}). From there the server owns the state — no JavaScript.

What can and can't cross the wire:
  • [event] The three fire-and-forget notifications forward automatically — the server reacts after the fact.
  • [interceptor] beforeSelect/beforeDeselect do not — a veto is synchronous and a server round-trip can't answer in time. For server-authoritative rules you enforce after the change and correct the element with Keenmate.WebMultiselect.push_update/3 (last demo below).

Live server state — derived on the server keen extra

hook={true} forwards change to handle_event("web_multiselect:change", …) — the server owns the state.

The server receives every change, keeps the selection in assigns, and renders data the client never had: a price lookup and total from a server-side catalog.

Select products — the server looks up prices and totals them…
Show server code
# app.js:  hooks: { KeenWebMultiselectHook }

<.web_multiselect id="srv-cart" hook={true} options={@catalog} />

# The change event crosses the wire; the server owns the state:
def handle_event("web_multiselect:change", %{"id" => "srv-cart", "values" => values}, socket) do
  {:noreply, assign(socket, :cart, values)}
end

# ...the total is then derived server-side from a catalog the client never sees.

Server event log — select / deselect / change keen extra

hook={true} — all three events land in handle_event("web_multiselect:select|deselect|change", …) on the server.

All three events land in handle_event/3. Here the server stamps each with a sequence number and renders them newest-first — proof they're genuinely server-side, not the browser console.

Select / deselect — server-logged entries appear here…

Server-authoritative rule — enforce &amp; correct via push_update/3 keen extra

hook={true} forwards change; the server corrects the element with Keenmate.WebMultiselect.push_update/3.

The closest thing to a server veto. The server allows the change optimistically, then if the rule is violated it corrects the element with Keenmate.WebMultiselect.push_update(socket, "srv-limit", value: kept). Here: pick at most 3, enforced entirely on the server.

Selecting a 4th item is reverted by the server…
Show server code
# Server-authoritative "max 3": allow optimistically, then correct.
# push_update/3 is the sanctioned server→client channel (wraps push_event).
def handle_event("web_multiselect:change", %{"id" => "srv-limit", "values" => values}, socket)
    when length(values) > 3 do
  kept = Enum.take(values, 3)

  {:noreply,
   socket
   |> assign(:limit_msg, "Rejected #{length(values)} > 3 — reverted to the first 3")
   |> Keenmate.WebMultiselect.push_update("srv-limit", value: kept)}
end
🔌 Server round-trip
0
The LiveView server saw 0 event(s) from the wrapper.
(pick an option anywhere on this page)