← Back to Examples

🔔 Events, Handlers & Interceptors

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

1 · Events — addEventListener & the on* twin

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) — return value ignored
el.onSelect   = (option)          => log('prop', 'onSelect', option.label);
el.onDeselect = (option)          => log('prop', 'onDeselect', option.label);
el.onChange   = (selectedOptions) => log('prop', 'onChange', selectedOptions.map(o => o.value));

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

2 · beforeSelectCallback — block a selection

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
};

3 · beforeDeselectCallback — protect a required item

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)

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*CallbackbeforeSelectCallback, 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.

🔌 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

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

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

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