Render options as an always-expanded hierarchy, indented by depth
Set path_member="path" — tree mode auto-enables; parent/depth are derived from the path.
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.
# 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" />
Every node is a normal option — multiple={false} and show_checkboxes={true} work as on a flat list.
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.
Typing filters to matches plus their ancestors, so indentation stays coherent.
Typing filters the hierarchy to the matching nodes plus their ancestors, so the indentation of the results still makes sense.
tree_path_separator="/" — when your paths use 1/1/2 instead of 1.1.2.
When your paths use a different separator (e.g. 1/1/2), set
tree_path_separator.
<.web_multiselect options={@categories} path_member="path" tree_path_separator="/" />
full_title_member="full_title" + show_badge_full_title={true} — badges show the fully-qualified path.
A full title is a fully-qualified label that ships with the data
(never computed here). Set full_title_member and turn on
show_badge_full_title, and badges render Fruit / Pome fruit / Apple
instead of just Apple — disambiguating leaves that share a name across branches.
<.web_multiselect
options={@categories} path_member="path"
full_title_member="full_title" show_badge_full_title={true} />
is_selectable_member="selectable" — branches render normally but aren't selectable.
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} />
Tune --ms-tree-indent (per-level step) and --ms-tree-base-indent (first-level offset).
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 */
}
Set --ms-option-min-height for a consistent row height; flip the --ms-option-title-* trio to truncate.
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;
}
Dense rows via option_height={32}; getBadgeDisplayCallback / getBadgeTooltipCallback / getSearchValueCallback wired in JS.
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.
Both are live — flip checkbox_mode / cascade_select_policy (JS props) any time and the current selection re-projects.
Two orthogonal knobs. checkbox_mode controls how checking a branch
behaves; cascade_select_policy controls which values the selection
emits (badges / form / change). Flip them below on a single live picker,
then check Fruit and try removing a badge — watch how the
emitted value changes in each mode.
checkbox_mode="independent" (default) — 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 (default) — 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 (default) | cascade
el.cascadeSelectPolicy = "rolled-up" // rolled-up (default) | leaves | all
Built-in Select All via show_select_all={true}; custom actions are JS-only (el.actionButtons). Select All is cascade-aware.
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 }) }
];
JS-only: renderOptionContentCallback(item, ctx) — in tree mode ctx carries tree metadata (isBranch, childCount, level, depth, path, isIndeterminate…).
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; }
`;
badges_display_mode="count" + show_counter={true} — a compact chip that opens the selected-items popover.
For a deep tree, a wall of badges gets noisy fast. Set badges_display_mode="count"
and the component drops the individual badges for a single "N selected" chip.
Click it to open the selected-items popover — the full list, each with its own
remove button — without touching the options dropdown. The popover reuses badge rendering, so a
full_title_member + getBadgeDisplayCallback gives each row its
breadcrumb, which is what makes leaves that share a name distinguishable.
badges_display_mode="none" together with show_counter={true}:
no badges and no "N selected" text — just a compact [N] counter next to the toggle
that opens the same popover.
<.web_multiselect badges_display_mode="count" show_counter={true}
options={@categories} path_member="path" show_checkboxes={true} />
// Breadcrumb per popover row (the popover reuses badge rendering):
el.fullTitleMember = "full_title";
el.getBadgeDisplayCallback = (item) => item.full_title || item.label;
// Drop the "N selected" text too with badges_display_mode="none" — just the [N] chip.
dropdown_width / selected_popover_width set the panel widths (or the --ms-dropdown-width / --ms-selected-popover-width vars at theme level).
The control, the options dropdown, and the selected-items popover are three separate panels —
and they can each have their own width. Here the input is a narrow 15rem, the
tree dropdown opens to a roomy 60rem (deep breadcrumbs need the space), and the
badges popover sits at 30rem. Both panel widths are just CSS variables
(--ms-dropdown-width, --ms-selected-popover-width); the
dropdown_width / selected_popover_width attributes are sugar that set
those variables on the element.
web-multiselect { --ms-dropdown-width: 60rem }
— and every picker follows; the attributes then override a single instance. Defaults: the
dropdown tracks the input width, the popover is an intrinsic 32rem.
<.web_multiselect
style="width: 15rem;" {!-- the control (input) --}
dropdown_width="60rem" {!-- → --ms-dropdown-width --}
selected_popover_width="30rem" {!-- → --ms-selected-popover-width --}
badges_display_mode="count" show_counter={true} ... />
/* Or theme every picker at once — the same variables, at app level: */
web-multiselect { --ms-dropdown-width: 60rem; --ms-selected-popover-width: 30rem; }
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)