← 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

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:
Emitted 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

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

12. Custom Node Rendering

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

13. No Badges — Selections in a Popover

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.

Want zero chrome instead?
Use 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.

14. Independent Panel Widths

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.

Theme once, override locally:
Set the variables at app/theme level — 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; }

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)