DOM events, the on* property twin, and before* veto interceptors
🌐 client — runs in the browser (element JS API) 🖥️ server — LiveView round-trip
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.
on* property handler ·
[event] = addEventListener
// 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);
});
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.
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
};
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.
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)
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.
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").
<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}"`;
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.
Bobby Briggs (not in the list) and pick the prompt to add him.
<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: '🆕',
};
};
The line is drawn by whether the component uses the return value:
onSelect,
onDeselect, onChange, and the bare DOM
select/deselect/change events.
before*Callback
— beforeSelectCallback, beforeDeselectCallback,
beforeSearchCallback.
get*Callback / render*Callback / searchCallback.
*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.
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.
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.
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.
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).
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.
# 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.
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.
(pick an option anywhere on this page)