<.web_multiselect> can render its options as a hierarchy instead of a flat
list. Give each option a materialized path and the component indents it by
depth, rendering parents immediately before their children.
The tree is always fully expanded — there is no collapse or chevron. If you
need expand/collapse, use @keenmate/web-treeview
(this feature reuses its ltree model so the ordering matches).
Shape your data with a path
Each option gets a materialized dot-path. The root level is a single
segment ("1", "2"), children extend the parent's path ("1.1", "1.2"),
grandchildren extend again ("1.1.1"), and so on. Parent and depth are derived
from the path — you don't supply them.
@categories [
%{value: "fruit", label: "Fruit", path: "1"},
%{value: "apple", label: "Apple", path: "1.1"},
%{value: "gala", label: "Gala", path: "1.1.1"},
%{value: "fuji", label: "Fuji", path: "1.1.2"},
%{value: "pear", label: "Pear", path: "1.2"},
%{value: "veg", label: "Vegetables", path: "2"},
%{value: "carrot", label: "Carrot", path: "2.1"}
]Turn on tree mode
Point path_member at the key that holds the path. That's it — tree mode
auto-enables:
<.web_multiselect
id="categories"
options={@categories}
path_member="path" />Renders (indented by depth):
Fruit
Apple
Gala
Fuji
Pear
Vegetables
CarrotEvery node — branch or leaf — is a normal, selectable option. Selection,
checkboxes (show_checkboxes), icons, subtitles, and single/multi mode all work
exactly as they do for a flat list.
Options
| Attribute | Purpose |
|---|---|
path_member | Key holding the materialized dot-path. Setting it enables tree mode. |
parent_path_member | Key holding the parent path. Optional — derived from the path when unset. |
level_member | Key holding the depth/level. Optional — derived from the path when unset. |
has_children_member | Key holding a precomputed hasChildren flag. Optional — derived from the tree when unset. |
is_selectable_member | Key holding a per-option selectability flag (see below). Optional. |
tree_path_separator | Separator used in paths. Defaults to ".". |
checkbox_mode | "independent" (default) or "cascade" — cascade checks a node's whole subtree with a tristate box (see below). |
cascade_select_policy | In cascade mode, which values a selection emits: "rolled-up" (default), "leaves", or "all". |
Using a different separator:
<.web_multiselect
id="categories"
options={@categories_slash}
path_member="path"
tree_path_separator="/" />Selectable leaves only
Sometimes a branch is pure structure — a heading, not a real choice. Mark it
non-selectable with is_selectable_member, pointing at a boolean key in your
data. A non-selectable node is not the same as a disabled one: it renders
normally (no grey-out), but it has no checkbox, is skipped by keyboard
arrows, and can't be toggled or picked by Select All.
@categories [
%{value: "fruit", label: "Fruit", path: "1", selectable: false},
%{value: "apple", label: "Apple", path: "1.1", selectable: true},
%{value: "gala", label: "Gala", path: "1.1.1", selectable: true},
%{value: "veg", label: "Vegetables", path: "2", selectable: false},
%{value: "carrot", label: "Carrot", path: "2.1", selectable: true}
]<.web_multiselect
id="categories"
options={@categories}
path_member="path"
is_selectable_member="selectable"
show_checkboxes={true} />For a "leaves are the only real choices" tree, precompute the flag from whether
an option has children (a node is a branch when some other option's path starts
with "#{path}."):
def only_leaves_selectable(options) do
paths = MapSet.new(options, & &1.path)
Enum.map(options, fn opt ->
has_children? = Enum.any?(paths, &String.starts_with?(&1, opt.path <> "."))
Map.put(opt, :selectable, not has_children?)
end)
endThe equivalent JS-side predicate
getIsSelectableCallback— which receives the built tree node and can read its derivedhasChildren(so(node) => !node.hasChildrenis a one-liner) — is JavaScript-only and has no HEEx attribute. From LiveView, precomputeis_selectable_memberas above.
Use disabled_member (which does grey the row out) for options that are
genuinely unavailable rather than merely structural.
Cascade checkboxes and the value policy
By default a checkbox toggles only its own node (checkbox_mode="independent").
Set checkbox_mode="cascade" and checking a node toggles its whole subtree,
while a partially-selected branch shows a tristate (dash) box. Tree +
multiple only.
Orthogonally, cascade_select_policy decides which values a cascade selection
emits — the values you get as badges, in the form, and in change events:
| Policy | A fully-checked subtree emits… | Why |
|---|---|---|
rolled-up (default) | the subtree's root (one value) | Minimal cover — a partial branch instead emits its individually-checked descendants. Rolls to the nearest selectable descendant when the root itself is non-selectable (e.g. a leaves-only tree). |
leaves | every checked leaf | You only care about the concrete leaf items. |
all | every fully-checked node (branches + leaves) | Matches @keenmate/web-treeview. |
<.web_multiselect
id="categories"
options={@categories}
path_member="path"
show_checkboxes={true}
checkbox_mode="cascade"
cascade_select_policy="rolled-up" />So with rolled-up, checking a parent whose entire subtree is selected gives you
a single value (the parent) rather than one badge per descendant; a
partially-selected branch surfaces exactly the descendants you checked. The
emitted values are a pure projection of the selection, so the form value and
change payload always reflect the policy.
Search
With enable_search on (the default), typing filters the hierarchy to the
matching nodes plus their ancestors, so the indentation of the results still
makes sense. Searching gala on the data above shows:
Fruit
Apple
GalaFull breadcrumb on badges
A leaf can share a name across branches (two "Gala" nodes under different parents),
which makes a plain badge ambiguous. If your data carries a full title — a
fully-qualified label like "Fruit / Pome fruit / Apple" — point full_title_member
at it and turn on show_badge_full_title; badges then show the full title instead of
the display value (falling back to the display value for options without one). The
component never computes the full title — it must ship with the data.
<.web_multiselect
id="categories"
options={@categories} {!-- each option also has a :full_title --}
path_member="path"
full_title_member="full_title"
show_badge_full_title={true} />An explicit JS-side getBadgeDisplayCallback still takes precedence over the full title.
Styling the indentation
Each row is indented via an inline --ms-tree-depth custom property (0 for
top-level nodes). Tune the step and base offset with CSS variables on the
element:
web-multiselect {
--ms-tree-indent: 1.25rem; /* per-level indent */
--ms-tree-base-indent: 0.75rem; /* inline-start padding of a top-level row */
}Branch and leaf rows also carry .ms__option--tree-branch /
.ms__option--tree-leaf classes as theming hooks if you want to style them
differently (e.g. bolder branch labels).
Row height and long labels
Deep indentation eats horizontal space, so long labels are common in trees. By
default a title wraps to multiple lines (the row grows). Two option-level CSS
variables let you change that — they apply to flat lists too, and follow the same
label-hook approach as @keenmate/web-treeview:
web-multiselect {
/* A consistent minimum row height (rows still grow if content is taller). */
--ms-option-min-height: 3.5rem;
/* …or truncate long titles to a single line with an ellipsis. */
--ms-option-title-white-space: nowrap;
--ms-option-title-overflow: hidden;
--ms-option-title-text-overflow: ellipsis;
}.ms__option-title is the label hook. When you truncate, pair it with
enable_option_tooltips={true} so the full label shows on hover:
<.web_multiselect
id="categories"
options={@categories}
path_member="path"
enable_option_tooltips={true}
style="--ms-option-title-white-space: nowrap; --ms-option-title-overflow: hidden; --ms-option-title-text-overflow: ellipsis;" />(Virtual-scroll rows use the fixed --ms-option-height / option_height instead
of --ms-option-min-height.)
LiveView
Tree options work with the LiveView hook and push_update/3 like any other
option list — push a new list (with paths) and the tree rebuilds. See the
README for hook setup.