Tree Select
v0.0.10
mix mishka.ui.gen.headless tree_select
Overview of the Phoenix and LiveView Headless Tree Select Component
The Mishka Chelekom Headless Tree Select is a field that shows the chosen value and opens a tree in a panel, for Phoenix LiveView. It is a thin shell: a trigger button that shows the label or the placeholder, and a panel that holds whatever you put in the default slot, normally the headless Tree. The trigger toggles the panel with Phoenix.LiveView.JS commands, so it needs no hook of its own. aria-expanded follows the panel, and a click outside or the Escape key closes it.
The tree owns selection and keyboard navigation. You wire its on_select event to your LiveView and pass the chosen name back as label. The component ships no styling: every part takes a *_class attribute.
Base UI Tree Select example - choose a category (Phoenix headless)
The panel holds a headless Tree whose categories, Design and Engineering, have selectable: false: a click on one opens or closes it, and only the items under it can be chosen. This example is drawn with open, so the tree shows at once. Press the trigger to close and open it, or click outside it or press Escape to close it.
The trigger shows label, which your LiveView sets from the tree's on_select event, as the next example does. Nothing on this page handles the event, so the trigger keeps showing "Mockups" when you pick another item.
Base UI Tree Select example - choose a category (Phoenix headless)
-
Design
-
Wireframes
-
Mockups
-
-
Engineering
-
Frontend
-
Backend
-
<.tree_select
id="baseui-tree-select"
label="Mockups"
placeholder="Choose a category…"
class="relative inline-block w-64 max-w-full"
trigger_class="flex w-full items-center justify-between gap-2 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm text-neutral-900 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
value_class="truncate data-[placeholder]:text-neutral-400"
panel_class="absolute left-0 z-20 mt-2 max-h-64 w-64 max-w-full overflow-auto rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<.tree
id="baseui-tree-select-tree"
aria_label="Categories"
select_on_click
expanded={["design", "engineering"]}
selected={["mockups"]}
nodes={[
%{
label: "Design",
value: "design",
selectable: false,
children: [
%{label: "Wireframes", value: "wireframes"},
%{label: "Mockups", value: "mockups"}
]
},
%{
label: "Engineering",
value: "engineering",
selectable: false,
children: [%{label: "Frontend", value: "frontend"}, %{label: "Backend", value: "backend"}]
}
]}
class="text-sm select-none"
label_class="flex items-center gap-1 rounded px-2 py-1 cursor-pointer [padding-left:calc(var(--label-offset)+0.5rem)] hover:bg-neutral-100 data-[selected]:bg-neutral-900/5 data-[selected]:font-medium dark:hover:bg-neutral-700 dark:data-[selected]:bg-white/10"
label_text_class="truncate"
expand_icon_class="inline-block w-3 text-center text-neutral-400 transition-transform [[data-expanded=true]>[data-part=label]>&]:rotate-90"
>
<:expand_icon>▸</:expand_icon>
</.tree>
</.tree_select>Tree Select example - keeping the label and a form field in sync (Phoenix headless)
The tree pushes on_select with the selected values. The handler stores the value and its label, and the template passes the label back to label and the value to a hidden input, which is what the form submits. Nodes with selectable: false act as category headers: clicking them only toggles the branch. Inside a LiveComponent, add on_target={@myself} to the tree so the event reaches the component.
<.form for={to_form(%{}, as: :category)} id="category-form" phx-submit="save">
<input type="hidden" name="category[node]" value={@value || ""} />
<.tree_select
id="category-select"
label={@label}
placeholder="Choose a category…"
class="relative inline-block w-64"
trigger_class="flex w-full items-center justify-between gap-2 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm text-neutral-900 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
value_class="truncate data-[placeholder]:text-neutral-400"
panel_class="absolute left-0 z-20 mt-2 max-h-64 w-64 overflow-auto rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<.tree
id="category-tree"
nodes={@nodes}
selected={(@value && [@value]) || []}
expanded={:all}
select_on_click
multiple={false}
on_select="select"
aria_label="Categories"
class="text-sm select-none"
label_class="flex items-center gap-1 rounded px-2 py-1 cursor-pointer [padding-left:calc(var(--label-offset)+0.5rem)] hover:bg-neutral-100 data-[selected]:bg-neutral-900/5 data-[selected]:font-medium dark:hover:bg-neutral-700 dark:data-[selected]:bg-white/10"
label_text_class="truncate"
expand_icon_class="inline-block w-3 text-center text-neutral-400"
>
<:expand_icon>▸</:expand_icon>
</.tree>
</.tree_select>
<button type="submit">Save</button>
</.form>@nodes [
%{
label: "Design",
value: "design",
selectable: false,
children: [%{label: "Wireframes", value: "wireframes"}, %{label: "Mockups", value: "mockups"}]
},
%{
label: "Engineering",
value: "engineering",
selectable: false,
children: [%{label: "Frontend", value: "frontend"}, %{label: "Backend", value: "backend"}]
}
]
@labels %{
"wireframes" => "Wireframes",
"mockups" => "Mockups",
"frontend" => "Frontend",
"backend" => "Backend"
}
def mount(_params, _session, socket) do
{:ok, assign(socket, nodes: @nodes, value: nil, label: nil)}
end
def handle_event("select", %{"values" => values}, socket) do
value = List.first(values)
{:noreply, assign(socket, value: value, label: Map.get(@labels, value))}
end
def handle_event("save", %{"category" => %{"node" => node}}, socket) do
{:noreply, put_flash(socket, :info, "Saved " <> node)}
endAttributes and parts
id is required, because the panel id is built from it. label is the text of the current selection. When it is empty, the value shows placeholder (default "Select…") and carries data-placeholder, so you can dim it. open renders the panel expanded on first render.
The parts are the root (data-part="root"), trigger, value, caret and panel. They take class, trigger_class, value_class, caret_class and panel_class. The panel is hidden with an inline display:none until the trigger opens it, so position it yourself, for example with absolute inside a relative root. The caret is the character ▾ in an aria-hidden span, and there is no slot to replace it. The component is meant to hold the headless Tree, which it lists as a dependency.
Accessibility
The trigger is a button with aria-haspopup="tree", aria-expanded and aria-controls pointing at the panel. Enter and Space toggle the panel, Escape closes it, and the tree inside handles its own arrow-key navigation and selection.
Attributes
| Name | Type | Default | Values | Required | Description |
|---|---|---|---|---|---|
id
|
string
| — | — | Yes | Unique id |
label
|
string
| — | — | No | Currently selected label |
placeholder
|
string
|
Select…
| — | No | Shown when nothing is selected |
open
|
boolean
|
false
| — | No | Render the panel expanded |
class
|
any
| — | — | No | Extra classes for the root |
trigger_class
|
any
| — | — | No | Extra classes for the trigger |
value_class
|
any
| — | — | No | Extra classes for the value label |
panel_class
|
any
| — | — | No | Extra classes for the panel |
caret_class
|
any
| — | — | No | — |
rest
|
global
| — | — | No | — |
Slots
| Name | Required | Description |
|---|---|---|
inner_block
| Yes | The tree (and anything else) to show in the panel |
Options
Each option takes one or more of its values, separated by commas.
| Option | Values |
|---|---|
--type
|
tree_select
|
--module
| A module name of your own |
What it generates
mix mishka.ui.gen.headless tree_select