Render options as an always-expanded hierarchy, indented by depth
🌐 client — runs in the browser (element JS API) 🖥️ server — LiveView round-trip
Set path_member="path" — tree mode auto-enables; parent/depth are derived from the path. Use tree_path_separator="/" for slash paths.
Give each option a materialized dot-path ("1", "1.1",
"1.1.1", …) and set path_member. Tree mode auto-enables:
the component derives parent and depth from the path and renders the options
depth-first, indented by level. The model is a trimmed lift of
@keenmate/web-treeview's ltree. When your paths use a
different separator (e.g. 1/1/2), set tree_path_separator.
# Give each option a materialized dot-path. Parent + depth are derived from it.
@categories = [
%{value: "fruit", label: "Fruit", path: "1"},
%{value: "pome", label: "Pome fruit", path: "1.1"},
%{value: "apple", label: "Apple", path: "1.1.1"},
%{value: "veg", label: "Vegetables", path: "2"}
]
<.web_multiselect id="tree" options={@categories} path_member="path" />
# Different separator (e.g. 1/1/2 instead of 1.1.2)?
<.web_multiselect options={@categories} path_member="path" tree_path_separator="/" />
Every node is a normal option — flip multiple, show_checkboxes, checkbox_mode and cascade_select_policy live and the selection re-projects.
Every node — branch or leaf — is a normal, selectable option; selection, single/multi
mode, and checkboxes all behave exactly as they do for a flat list. Two orthogonal knobs
govern how branches behave: checkbox_mode controls how checking a
branch behaves; cascade_select_policy controls which values the
selection emits (badges / form / change). Flip everything below on one
live picker, then check Fruit and try removing a badge —
watch how the emitted value changes.
checkbox_mode="independent" — each node toggles on its own; checking a branch selects only that node.checkbox_mode="cascade" — checking a branch checks its whole subtree; a partial branch shows a tristate (dash) box.rolled-up — minimal cover: a fully-selected subtree collapses to its root; a partial branch emits its individually-checked descendants.leaves — only the checked leaf nodes.all — every fully-checked node (branches + leaves), like @keenmate/web-treeview.checkbox_mode:
cascade_select_policy:
getValue():[]
<.web_multiselect id="tree" options={@categories} path_member="path"
show_checkboxes={true} checkbox_mode="cascade" cascade_select_policy="rolled-up" />
// Both are live — flip them any time (in JS) and the current selection re-projects:
el.checkboxMode = "cascade" // independent | cascade
el.cascadeSelectPolicy = "rolled-up" // rolled-up | leaves | all
A node can be marked non-selectable via is_selectable_member
(a data flag). Non-selectable nodes render normally — they are not
greyed out like disabled — but they drop their checkbox, are skipped by
keyboard arrows, and can't be toggled or picked by Select All. The classic use is a
tree where only the leaves are real choices and the branches are pure structure.
# Precompute the flag from the data: a node is a branch when another
# option's path extends it. (The JS-only getIsSelectableCallback, which
# reads the built node's hasChildren, has no HEEx attribute.)
def only_leaves_selectable(items) do
paths = MapSet.new(items, & &1.path)
Enum.map(items, fn item ->
branch? = Enum.any?(paths, &String.starts_with?(&1, item.path <> "."))
Map.put(item, :selectable, not branch?)
end)
end
# <.web_multiselect path_member="path" is_selectable_member="selectable" show_checkboxes={true} />
Action buttons work with tree mode. The built-in select-all /
clear-all honour selectability (non-selectable branches are skipped), and in
cascade mode Select All emits the same rolled-up shape a click
would — selecting everything collapses to just the two roots (Fruit,
Vegetables), not one badge per node. Custom actions take an onClick(ms)
that receives the picker instance.
# Built-in Select All alone: show_select_all={true}. For custom actions, wire
# el.actionButtons in a <script> (JS-only — there is no HEEx attribute for it):
el.actionButtons = [
{ action: "select-all", text: "Select All" },
{ action: "clear-all", text: "Clear All" },
// setSelected is silent by default; { notify: true } fires one aggregate
// `change` so the button press reaches the server (see the round-trip panel).
{ action: "custom", text: "All fruit", onClick: (ms) => ms.setSelected(["fruit"], { notify: true }) }
];
Tune --ms-tree-indent / --ms-tree-base-indent; set --ms-option-min-height and the --ms-option-title-* trio; replace inner content with renderOptionContentCallback(item, ctx).
Each row is indented via an inline --ms-tree-depth custom property
(0 at the top level). Tune the step with --ms-tree-indent and the
first-level offset with --ms-tree-base-indent. Branch/leaf rows also
carry .ms__option--tree-branch / .ms__option--tree-leaf
theming hooks.
web-multiselect {
--ms-tree-indent: 1.25rem; /* per-level step */
--ms-tree-base-indent: 0.75rem; /* first-level inline-start padding */
}
Deep indentation eats horizontal space, so long labels matter. By default a title
wraps to multiple lines (rows grow). Set a consistent row height with
--ms-option-min-height, and/or truncate long titles to a single line with
an ellipsis by flipping the three --ms-option-title-* variables.
.ms__option-title is the label hook. Pair truncation with
enable_option_tooltips to reveal the full text on hover. These apply to
flat lists too — they're option-level, not tree-only.
web-multiselect {
--ms-option-min-height: 56px; /* consistent row height */
/* truncate long titles to one line + ellipsis */
--ms-option-title-white-space: nowrap;
--ms-option-title-overflow: hidden;
--ms-option-title-text-overflow: ellipsis;
}
renderOptionContentCallback works on tree rows too — it replaces the
inner content of a node (icon / title / subtitle) while the component still
draws the tree chrome around it: the checkbox, depth indentation, and the
branch/leaf/selected/indeterminate state classes. In tree mode the callback's context
carries tree metadata so you can branch on it without re-deriving anything:
isTreeNode / isBranch / isLeaf — is it a tree row, and does it have children.childCount — number of direct children (0 for a leaf).level / depth — 1-based level and 0-based indent depth (--ms-tree-depth).path, isSelectable, and isIndeterminate (cascade tristate).// 1) Content — compact label + child count. ctx carries tree metadata
// (isBranch/isLeaf/childCount/level/depth/path/isSelectable/isIndeterminate):
el.renderOptionContentCallback = (item, ctx) => ctx.isBranch
? `<span><strong>${item.label}</strong> <small class="node-count">${ctx.childCount}</small></span>`
: `<span>${item.label}</span>`;
// 2) Full-row background per node type via the branch/leaf theming hooks — the
// render callback only fills the content area (after the checkbox):
el.customStylesCallback = () => `
.ms__option--tree-branch { background: #eef2ff; font-weight: 600; }
.ms__option--tree-leaf { background: #fff; }
.node-count { opacity: .55; font-weight: 400; }
`;
Dense rows via option_height={32}; getBadgeDisplayCallback / getBadgeTooltipCallback / getSearchValueCallback wired in JS; scrollToValue reaches any node (scrollToGroup → false in tree mode).
Everything at once on real data: the whole International Standard Classification
of Occupations (ISCO-08) — 619 groups (10 major → 43 sub-major →
130 minor → 436 unit). The ISCO code is the hierarchy (2211 → path
2.22.221.2211), so it drops straight into path_member. Only the
unit groups (leaves) are selectable. A custom badge shows just the
leaf title (prefixed .. / when it has ancestors), with the
full breadcrumb in the badge tooltip; a getSearchValueCallback
widens the built-in filter to match the title, code, and breadcrumb;
long titles truncate; virtual scroll keeps 600+ rows smooth.
The tree is always fully expanded, so scrollToValue reaches
any node by its value (here the ISCO code) — and it works with virtual scroll (the row
need not be rendered). scrollToGroup does not apply in tree mode and
returns false. If a search has filtered the tree, the node
may be hidden — call clearSearch() first to reveal it, then scroll.
el.open();
el.scrollToValue('2211'); // any node by value — tree is always expanded
el.scrollToIndex(el /* count-1 */); // last visible node
el.scrollToGroup('x'); // → false in tree mode
// If a search filtered the node out, reveal it first:
el.clearSearch();
el.scrollToValue('2211');
The value that reaches your handle_event/3 is whatever
value_member points at — nothing tree-specific. Point it at the
path and the select / change events carry
the materialized paths ("1.1", "1.1.2")
instead of the ids, which is handy when the path is your server-side key. The display is
unaffected — badges still use display_value_member. Pick below and watch the
“server round-trip” panel (bottom-right) log the paths.
value_member="path"
value_member="path" + checkbox_mode="cascade"
# The value the server receives is whatever value_member points at.
# Point it at the path and change/select events carry materialized paths.
<.web_multiselect
options={@categories}
path_member="path"
value_member="path" # ← wire value = the path, not the id
checkbox_mode="cascade" /> # optional: rolled-up sends one parent path per full branch
def handle_event("web_multiselect:change", %{"values" => paths}, socket) do
paths # => ["1.1", "1.1.2"] (or ["1.1"] with cascade rolled-up)
{:noreply, socket}
end
(pick an option anywhere on this page)