← Back to Examples

🌳 Tree of Options

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

🌐 client — runs in the browser (element JS API) 🖥️ server — LiveView round-trip

TR01 · The Tree Model

Set path_member="path" — tree mode auto-enables; parent/depth are derived from the path. Use tree_path_separator="/" for slash paths.

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. When your paths use a different separator (e.g. 1/1/2), set tree_path_separator.

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

# Different separator (e.g. 1/1/2 instead of 1.1.2)?
<.web_multiselect options={@categories} path_member="path" tree_path_separator="/" />
No collapse:
The tree is always fully expanded — there is no chevron/toggle. Reach for @keenmate/web-treeview when you need expand/collapse.

TR02 · Selection, Checkboxes & Cascade

Every node is a normal option — flip multiple, show_checkboxes, checkbox_mode and cascade_select_policy live and the selection re-projects.

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. Two orthogonal knobs govern how branches behave: checkbox_mode controls how checking a branch behaves; cascade_select_policy controls which values the selection emits (badges / form / change). Flip everything below on one live picker, then check Fruit and try removing a badge — watch how the emitted value changes.

  • checkbox_mode="independent" — 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 — 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 | cascade
el.cascadeSelectPolicy = "rolled-up"   // rolled-up | leaves | all

Selectable leaves only

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

Action buttons

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

TR03 · Search — Filter vs Navigate

search_mode="navigate" keeps the whole tree visible and jumps focus between matches; the default filter narrows to matches + ancestors. Add show_search_mode_toggle for a built-in switch.

Two search behaviors on one picker. Filter (the default) narrows the hierarchy to the matching nodes plus their ancestors, so the indentation of the results still makes sense. Navigate (search_mode="navigate") keeps the whole tree on screen and typing jumps focus between matching nodes instead of hiding anything — handy on a tree, where filtering can hide the structure you were reading. Matched rows keep a highlight; step through them with Ctrl+↑/↓ on desktop. In the fullscreen overlay (touch, or the preview toggle below) a N of M count with prev/next buttons appears under the search box.

This picker also sets show_search_mode_toggle, which adds a built-in mode switch to the fullscreen search bar: the leading icon (magnifier = navigate, funnel = filter) flips the mode in place on tap — no reopen. Enable fullscreen preview, then tap the icon to switch without touching the host-page radios.

Search mode:
<!-- default: filter — hierarchy narrows to matches + their ancestors -->
<web-multiselect path-member="path"></web-multiselect>

<!-- navigate — keep the whole tree on screen; jump focus between matches -->
<web-multiselect path-member="path" search-mode="navigate"></web-multiselect>

<!-- add a built-in filter<->navigate toggle to the fullscreen search bar -->
<web-multiselect path-member="path" search-mode="navigate" show-search-mode-toggle></web-multiselect>

/* Fullscreen (phones) adds a count + prev/next match navigator automatically;
   desktop steps with Ctrl+ArrowUp / Ctrl+ArrowDown. */

TR04 · Styling Rows

Tune --ms-tree-indent / --ms-tree-base-indent; set --ms-option-min-height and the --ms-option-title-* trio; replace inner content with renderOptionContentCallback(item, ctx).

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

Row height & long labels

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

Custom node rendering

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

TR05 · Breadcrumbs & Selection Display

full_title_member="full_title" + show_badge_full_title={true} shows breadcrumbs; badges_display_mode="count" + show_counter={true} moves the selection into a popover; dropdown_width / selected_popover_width size the panels.

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

No badges — selections in a 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.

Independent panel widths

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

TR06 · Real Data — ISCO-08 & Scroll-to API

Dense rows via option_height={32}; getBadgeDisplayCallback / getBadgeTooltipCallback / getSearchValueCallback wired in JS; scrollToValue reaches any node (scrollToGroup → false in tree mode).

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.

Scroll-to API in a tree

The tree is always fully expanded, so scrollToValue reaches any node by its value (here the ISCO code) — and it works with virtual scroll (the row need not be rendered). scrollToGroup does not apply in tree mode and returns false. If a search has filtered the tree, the node may be hidden — call clearSearch() first to reveal it, then scroll.

scrollTo* results log here…
el.open();
el.scrollToValue('2211');        // any node by value — tree is always expanded
el.scrollToIndex(el /* count-1 */); // last visible node
el.scrollToGroup('x');           // → false in tree mode

// If a search filtered the node out, reveal it first:
el.clearSearch();
el.scrollToValue('2211');

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)