Tree Select

v0.0.10

v0.0.10StableHeadlessStatelessAccessibleJS-moduleApache-2.0
Installation mix mishka.ui.gen.headless tree_select
Source View Source

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
heex
<.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.

heex
<.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>
elixir
@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)}
end

Attributes 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.