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.
checkbox_mode="cascade" checks a node's whole subtree (tristate on partial branches); cascade_select_policy picks which values it emits.
Set checkbox_mode="cascade" and checking a branch checks its
whole subtree; a partially-selected branch shows a tristate
(dash) box. Independent of the interaction, cascade_select_policy decides
which values the selection emits (badges / form / change):
rolled-up (default) — the minimal cover: a
fully-selected subtree collapses to its root ("complete node"); a partially-selected
branch emits its individually-checked descendants (rolling to the nearest selectable
descendant when the complete node itself is non-selectable).
leaves — only the checked leaf nodes.all — every fully-checked node (branches + leaves), like @keenmate/web-treeview.<.web_multiselect
options={@categories} path_member="path" show_checkboxes={true}
checkbox_mode="cascade" cascade_select_policy="rolled-up" />
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 }) }
];
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)