DOM events, the on* property twin, and before* veto interceptors
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) — 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);
});
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
};
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)
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.
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).
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.
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.
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.
# 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