← Back to Examples

🌳 Tree of Options

Render options as an always-expanded hierarchy, indented by depth

1. Basic Tree

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" />
No collapse:
The tree is always fully expanded — there is no chevron/toggle. Reach for @keenmate/web-treeview when you need expand/collapse.

2. Single-Select & Checkboxes

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.

3. Ancestor-Preserving Search

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.

4. Custom Path Separator

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="/" />

5. Full Breadcrumb on Badges

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} />

6. Selectable Leaves Only

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} />

7. Styling the Indentation

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 */
}

8. Row Height & Long Labels

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;
}

9. Real Data — Full ISCO-08 Classification

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.

Source: International Standard Classification of Occupations, ISCO-08 (© International Labour Organization) — demo data.

10. Cascade Checkboxes & Value Policy

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" />

11. Action Buttons

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 }) }
];

Sending paths to the server (LiveView) keen extra

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
🔌 Server round-trip
0
The LiveView server saw 0 event(s) from the wrapper.
(pick an option anywhere on this page)