Skip to content
Bladewell

<x-widget.tree>

Tree Blade component for Laravel and Livewire

A tree of nodes that open and close: compact rows like a file explorer, cards joined by lines like a referral network, a top-down org chart, or tick boxes where ticking a parent ticks everything under it. Children can load from your app the first time a node opens, for trees too big to send at once. The arrow keys move through it, as in any tree. Works inside Livewire components, with wire:model for the ticked values.

php artisan bladewell:add tree
Also adds
Icon

Usage

Children endpoint

The URL in children-url answers GET with the node's children as HTML: tree items, inside a tree of the same look (variant), so each renders that look's styles. The script takes the items out and leaves the tree behind. Give a child that has children of its own its own children-url; one with none left off is a leaf. Check the person may see this member, as for any page.

PHP
Route::get('/members/{member}/children', function (Member $member) {
    Gate::authorize('view', $member);

    return view('members.children', ['children' => $member->referrals()->withCount('referrals')->orderBy('joined_at')->get()]);
})->name('members.children');

// resources/views/members/children.blade.php
<x-widget.tree variant="cards">
    @foreach ($children as $child)
        <x-widget.tree.item
            :label="$child->username"
            :meta="'Level '.$child->level"
            :children-url="$child->referrals_count > 0 ? route('members.children', $child) : null"
        >
            <x-slot:details>Joined {{ $child->joined_at->isoFormat('D MMM YYYY') }} · Team of {{ $child->referrals_count }}</x-slot:details>
        </x-widget.tree.item>
    @endforeach
</x-widget.tree>

Livewire

Inside a Livewire component, bind the checkbox look with wire:model (or wire:model.live) to an array property: public array $permissions = []. Ticking updates it, and setting it in PHP ticks the boxes after the render. Which nodes are open stays as the person left it, so :open only sets how a node starts. Children fetched with children-url stay through renders too.

Blade
<div>
    <x-widget.tree label="Permissions" variant="checkbox" wire:model.live="permissions">
        @foreach ($groups as $group)
            <x-widget.tree.item wire:key="group-{{ $group->id }}" :label="$group->name" open>
                @foreach ($group->permissions as $permission)
                    <x-widget.tree.item wire:key="permission-{{ $permission->id }}" :label="$permission->label" :value="$permission->name" />
                @endforeach
            </x-widget.tree.item>
        @endforeach
    </x-widget.tree>

    <button type="button" wire:click="save">Save</button>
</div>

Examples

List

The default look: compact rows under indent guides, like a file explorer. open starts a node open, meta adds a count at the end of the row, and href makes a node a link. The arrow keys move through it: Right opens, Left closes or goes up a level.

Show code
Blade
<x-widget.tree label="Files" class="max-w-sm">
    <x-widget.tree.item label="Documents" icon="inbox" meta="3" open>
        <x-widget.tree.item label="Invoices" icon="inbox" meta="2">
            <x-widget.tree.item label="March.pdf" icon="file" href="#invoice-march" />
            <x-widget.tree.item label="April.pdf" icon="file" href="#invoice-april" />
        </x-widget.tree.item>
        <x-widget.tree.item label="Lease.pdf" icon="file" href="#lease" />
        <x-widget.tree.item label="Tax return.pdf" icon="file" href="#tax-return" />
    </x-widget.tree.item>
    <x-widget.tree.item label="Photos" icon="image" meta="2">
        <x-widget.tree.item label="Beach.jpg" icon="image" />
        <x-widget.tree.item label="Garden.jpg" icon="image" />
    </x-widget.tree.item>
    <x-widget.tree.item label="Notes.txt" icon="file" />
</x-widget.tree>

Cards

variant="cards": each node a card, joined to its children by lines, like a referral network. The details slot holds the card's figures and meta its level. Deep trees scroll sideways inside themselves rather than widen the page.

  • amelia.chen Joined 12 Jan 2026 · Team of 5 Level 0
    • ben.okafor Joined 3 Feb 2026 · Team of 2 Level 1
      • chloe.martin Joined 19 Mar 2026 · No team yet Level 2
      • daniel.ruiz Joined 2 Apr 2026 · No team yet Level 2
Show code
Blade
<x-widget.tree label="Referral network" variant="cards">
    <x-widget.tree.item label="amelia.chen" meta="Level 0" open>
        <x-slot:details>Joined 12 Jan 2026 · Team of 5</x-slot:details>
        <x-widget.tree.item label="ben.okafor" meta="Level 1" open>
            <x-slot:details>Joined 3 Feb 2026 · Team of 2</x-slot:details>
            <x-widget.tree.item label="chloe.martin" meta="Level 2">
                <x-slot:details>Joined 19 Mar 2026 · No team yet</x-slot:details>
            </x-widget.tree.item>
            <x-widget.tree.item label="daniel.ruiz" meta="Level 2">
                <x-slot:details>Joined 2 Apr 2026 · No team yet</x-slot:details>
            </x-widget.tree.item>
        </x-widget.tree.item>
        <x-widget.tree.item label="erin.walsh" meta="Level 1">
            <x-slot:details>Joined 28 Feb 2026 · Team of 1</x-slot:details>
            <x-widget.tree.item label="farid.haddad" meta="Level 2">
                <x-slot:details>Joined 7 May 2026 · No team yet</x-slot:details>
            </x-widget.tree.item>
        </x-widget.tree.item>
    </x-widget.tree.item>
</x-widget.tree>

Org

variant="org": top-down, each parent centred over its children, like an org chart. On a narrow screen it scrolls sideways inside itself.

  • Maya Patel Chief Executive
    • Leo Grant Engineering
      • Ivy Tran Platform
      • Omar Said Mobile
    • Nora Field Finance
    • Sam Ito Operations
      • Ana Costa Support
Show code
Blade
<x-widget.tree label="Company" variant="org">
    <x-widget.tree.item label="Maya Patel" open>
        <x-slot:details>Chief Executive</x-slot:details>
        <x-widget.tree.item label="Leo Grant" open>
            <x-slot:details>Engineering</x-slot:details>
            <x-widget.tree.item label="Ivy Tran">
                <x-slot:details>Platform</x-slot:details>
            </x-widget.tree.item>
            <x-widget.tree.item label="Omar Said">
                <x-slot:details>Mobile</x-slot:details>
            </x-widget.tree.item>
        </x-widget.tree.item>
        <x-widget.tree.item label="Nora Field">
            <x-slot:details>Finance</x-slot:details>
        </x-widget.tree.item>
        <x-widget.tree.item label="Sam Ito" open>
            <x-slot:details>Operations</x-slot:details>
            <x-widget.tree.item label="Ana Costa">
                <x-slot:details>Support</x-slot:details>
            </x-widget.tree.item>
        </x-widget.tree.item>
    </x-widget.tree.item>
</x-widget.tree>

Checkbox

variant="checkbox": a tick box on every node. Ticking a parent ticks everything under it, and a parent with only some ticked shows a dash. The values of ticked nodes submit as name[] (here permissions[]); a node without a value only groups. value sets what starts ticked; disabled keeps a node as it is. Space ticks, Enter too.

Permissions
  • Posts
    • View
    • Create
    • Edit
    • Delete
  • Users
    • View
    • Invite
  • Settings

Delete is managed by the owner.

Show code
Blade
<x-widget.tree label="Permissions" variant="checkbox" name="permissions" :value="['posts.view', 'posts.create', 'users.view']" info="Delete is managed by the owner." class="max-w-sm">
    <x-widget.tree.item label="Posts" open>
        <x-widget.tree.item label="View" value="posts.view" />
        <x-widget.tree.item label="Create" value="posts.create" />
        <x-widget.tree.item label="Edit" value="posts.edit" />
        <x-widget.tree.item label="Delete" value="posts.delete" disabled />
    </x-widget.tree.item>
    <x-widget.tree.item label="Users" open>
        <x-widget.tree.item label="View" value="users.view" />
        <x-widget.tree.item label="Invite" value="users.invite" />
    </x-widget.tree.item>
    <x-widget.tree.item label="Settings" value="settings" />
</x-widget.tree>

Load on open

children-url: the node's children come from your app the first time it opens, with a spinner meanwhile, so a network of thousands sends only what's looked at. The URL returns them as HTML, in a tree of the same look (see the endpoint under Usage); a node whose URL returns none becomes a leaf, and one that fails offers to try again.

Show code
Blade
<x-widget.tree label="Referral network" variant="cards">
    <x-widget.tree.item label="amelia.chen" meta="Level 0" :children-url="route('members.children', 1)">
        <x-slot:details>Joined 12 Jan 2026 · Team of 3</x-slot:details>
    </x-widget.tree.item>
</x-widget.tree>

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.tree>

Prop Default Description
label null Its name for screen readers. With the checkbox look it's also shown above the tree, as a field's label.
variant 'list' list: rows with indent guides, like a file explorer. cards: each node a card, joined to its children by lines. org: top-down, each parent centred over its children, like an org chart. checkbox: rows with a tick box each; ticking a parent ticks everything under it, and one with only some ticked shows a dash.
name null checkbox: what the ticked values submit as (name[]). Optional with wire:model, which then names it.
id null Defaults to one made from the name (or the wire:model property).
value null checkbox: the ticked values. Old input wins after a failed submit; with wire:model and no value, the bound property.
error null checkbox: an error message of your own; otherwise the validation error for the name, from the session or Livewire.
info null checkbox: a hint under the tree.
bag 'default' checkbox: which error bag to read the error from.

<x-widget.tree.item>

Prop Default Description
label Required The node's text, which also names it for screen readers.
meta null Short text at the end of the row, such as a count or a level. Read out with the label.
icon null An icon before the label, by name, as the icon widget takes it.
href null A URL: the node is a link, followed on click or Enter.
open false Starts open, showing what's under it.
value null checkbox look: what this node submits when ticked. A node without one only groups the ones under it.
disabled false checkbox look: can't be ticked or unticked, and ticking a parent leaves it as it is.
children-url null Where its children come from when it's first opened: a URL in your app that returns tree items as HTML. For trees too big to send at once, like a referral network.

Slots

<x-slot:details>
More about the node under its label, such as a card's figures. Read out as its description.

Accessibility

All 5 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

Source

What bladewell:add tree writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds and Field, and the theme and base CSS.

resources/views/components/widget/tree/index.blade.php Show
index.blade.php
@props([
    // Its name for screen readers. With the checkbox look it's also shown above the tree, as a field's label.
    'label' => null,
    // list: rows with indent guides, like a file explorer. cards: each node a card, joined to its children by lines.
    // org: top-down, each parent centred over its children, like an org chart. checkbox: rows with a tick box each;
    // ticking a parent ticks everything under it, and one with only some ticked shows a dash.
    'variant' => 'list',
    // checkbox: what the ticked values submit as (name[]). Optional with wire:model, which then names it.
    'name' => null,
    // Defaults to one made from the name (or the wire:model property).
    'id' => null,
    // checkbox: the ticked values. Old input wins after a failed submit; with wire:model and no value, the bound property.
    'value' => null,
    // checkbox: an error message of your own; otherwise the validation error for the name, from the session or Livewire.
    'error' => null,
    // checkbox: a hint under the tree.
    'info' => null,
    // checkbox: which error bag to read the error from.
    'bag' => 'default',
])

@php
    // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
    if (! in_array($variant, ['list', 'cards', 'org', 'checkbox'], true)) {
        throw new \InvalidArgumentException("Unknown variant [{$variant}] for <x-widget.tree>. Use one of: list, cards, org, checkbox.");
    }
    $checkbox = $variant === 'checkbox';
    // Only the checkbox look is a form control, with a field's label, error and hint.
    $field = $checkbox ? \App\View\Widget\FormField::make($name, $id, $errors ?? null, $error, $bag, 'tree', attributes: $attributes) : null;
    // The ticked values, as strings (backed enums as their value), for the hidden select below.
    $ticked = $checkbox
        ? collect(\Illuminate\Support\Arr::wrap($field->old($value)))->map(fn (mixed $v): string => (string) ($v instanceof \BackedEnum ? $v->value : $v))->unique()->values()
        : collect();
    // Cards and org charts grow sideways with depth, so they scroll inside themselves rather than widen the page.
    $scrolls = in_array($variant, ['cards', 'org'], true);
@endphp

@if ($checkbox)
    {{--
        What submits, and what wire:model binds, is a hidden multiple select of the ticked values: the script keeps it in
        step as boxes are ticked, and ticks the boxes from it as the page loads and after each Livewire render. Values
        under a branch that hasn't been opened yet stay in it untouched.
    --}}
    <x-widget.field :id="$field->id" :label="$label" :error="$field->errors" :info="$info" :labels-control="false" bare :class="$attributes->get('class')">
        <ul
            id="{{ $field->id }}"
            role="tree"
            data-tree
            data-variant="checkbox"
            @if ($label) aria-labelledby="{{ $field->id }}-label" @endif
            {{ $field->aria($attributes, (bool) $info) }}
            class="group/tree flex flex-col"
        >
            {{ $slot }}
        </ul>
        <select multiple hidden data-tree-value @if ($name) name="{{ $name }}[]" @endif {{ $field->bindings($attributes) }}>
            @foreach ($ticked as $tickedValue)
                <option value="{{ $tickedValue }}" selected>{{ $tickedValue }}</option>
            @endforeach
        </select>
    </x-widget.field>
@else
    <div {{ $attributes->class(['overflow-x-auto overscroll-x-contain' => $scrolls]) }}>
        <ul
            @if ($id) id="{{ $id }}" @endif
            role="tree"
            data-tree
            data-variant="{{ $variant }}"
            @if ($label) aria-label="{{ $label }}" @endif
            @class(['group/tree', 'flex flex-col' => $variant === 'list', 'w-max min-w-full p-1' => $scrolls])
        >
            {{ $slot }}
        </ul>
    </div>
@endif
resources/views/components/widget/tree/item.blade.php Show
item.blade.php
@props([
    // The node's text, which also names it for screen readers.
    'label',
    // Short text at the end of the row, such as a count or a level. Read out with the label.
    'meta' => null,
    // An icon before the label, by name, as the icon widget takes it.
    'icon' => null,
    // A URL: the node is a link, followed on click or Enter.
    'href' => null,
    // Starts open, showing what's under it.
    'open' => false,
    // checkbox look: what this node submits when ticked. A node without one only groups the ones under it.
    'value' => null,
    // checkbox look: can't be ticked or unticked, and ticking a parent leaves it as it is.
    'disabled' => false,
    // Where its children come from when it's first opened: a URL in your app that returns tree items as HTML.
    // For trees too big to send at once, like a referral network.
    'childrenUrl' => null,
])

{{-- Which look the tree has, from the tree around it: on the page, or the one children fetched later come back in. --}}
@aware(['variant' => 'list'])

@php
    $branch = $slot->isNotEmpty() || $childrenUrl !== null;
    $open = $open && $slot->isNotEmpty();
    // Named by its label (and meta), not by everything inside it, which would include every child's text too. Random
    // rather than counted: children fetched later are rendered in another request, whose counting starts over.
    $ids = 'tree-item-'.\Illuminate\Support\Str::random(8);
    $value = $value instanceof \BackedEnum ? $value->value : $value;
    $checkbox = $variant === 'checkbox';
    $card = in_array($variant, ['cards', 'org'], true);

    // Each look's row. Only the tree's own look is rendered, so a node carries nothing it doesn't use.
    $looks = [
        // A compact row, like a file explorer.
        'list' => 'items-center rounded-lg px-2 py-1.5 hover:bg-field',
        'checkbox' => 'items-center rounded-lg px-2 py-1.5 hover:bg-field cursor-pointer data-disabled:cursor-not-allowed',
        // A card, with the open arrow at its end.
        'cards' => 'border-line bg-surface w-72 items-start rounded-2xl border p-4 shadow-sm hover:border-line-strong',
        // A smaller card, centred over its children, with the open arrow under its text.
        'org' => 'border-line bg-surface min-w-36 max-w-56 flex-col items-center rounded-2xl border px-4 py-3 text-center shadow-sm hover:border-line-strong',
    ];
    $look = $looks[$variant] ?? $looks['list'];
@endphp

{{--
    The script mirrors each node's state onto its row (data-expanded, data-busy, data-checked, data-mixed), where only
    that row's own icons see it.
    wire:ignore.self: whether it's open or ticked, and the ids, are the person's once the page is up; a Livewire render
    would put back the server's. What's inside still updates.
--}}
<li
    role="treeitem"
    data-tree-item
    tabindex="-1"
    wire:ignore.self
    aria-labelledby="{{ $ids }}-label @if ($meta !== null) {{ $ids }}-meta @endif"
    @isset($details) aria-describedby="{{ $ids }}-details" @endisset
    @if ($branch) aria-expanded="{{ $open ? 'true' : 'false' }}" @endif
    @if ($childrenUrl) data-children-url="{{ $childrenUrl }}" @endif
    @if ($value !== null) data-value="{{ $value }}" @endif
    @if ($disabled) aria-disabled="true" @endif
    {{ $attributes->class(['outline-none [&:focus-visible>[data-tree-row]]:ring-2 [&:focus-visible>[data-tree-row]]:ring-primary']) }}
>
    <div
        data-tree-row
        wire:ignore.self
        @if ($open) data-expanded @endif
        @if ($disabled) data-disabled @endif
        @class(['group/row text-foreground relative flex gap-2 text-sm transition-colors data-disabled:opacity-60', $look, 'cursor-pointer' => $branch || $href])
    >
        {{-- The open arrow, or the spinner while children load. Empty on a list's leaf, as a spacer so labels line up. --}}
        @if ($branch || ! $card)
            <span data-tree-toggle aria-hidden="true" @class(['text-foreground/60 grid size-5 shrink-0 place-items-center', 'order-last' => $card])>
                @if ($branch)
                    <x-widget.icon name="chevron-down" class="size-4 -rotate-90 transition-transform duration-200 group-data-busy/row:hidden group-data-expanded/row:rotate-0 motion-reduce:transition-none rtl:rotate-90 rtl:group-data-expanded/row:rotate-0" />
                    <span class="border-line border-t-primary hidden size-4 animate-spin rounded-full border-2 group-data-busy/row:block"></span>
                @endif
            </span>
        @endif

        @if ($checkbox)
            {{-- The tick box. The node itself carries aria-checked; this is only its picture. --}}
            <span aria-hidden="true" class="border-muted bg-surface text-on-primary group-data-checked/row:border-primary-fill group-data-checked/row:bg-primary-fill group-data-mixed/row:border-primary-fill group-data-mixed/row:bg-primary-fill grid size-5 shrink-0 place-items-center rounded-md border">
                <x-widget.icon name="check" class="hidden size-3.5 stroke-3 group-data-checked/row:block" />
                <x-widget.icon name="minus" class="hidden size-3.5 stroke-3 group-data-mixed/row:block" />
            </span>
        @endif

        @if ($icon)
            <x-widget.icon :name="$icon" class="text-foreground/60 size-4 shrink-0 {{ $variant === 'cards' ? 'mt-0.5' : '' }}" />
        @endif

        <span class="min-w-0 flex-1">
            <span id="{{ $ids }}-label" wire:ignore.self @class(['block break-words', 'font-semibold' => $card])>
                {{-- tabindex -1: the node takes focus, not the link; Enter on the node follows it (see the script). --}}
                @if ($href)
                    <a href="{{ $href }}" tabindex="-1" class="hover:text-primary underline-offset-4 hover:underline">{{ $label }}</a>
                @else
                    {{ $label }}
                @endif
            </span>
            @isset($details)
                <span id="{{ $ids }}-details" wire:ignore.self class="text-foreground/70 mt-1 block text-xs">{{ $details }}</span>
            @endisset
        </span>

        @if ($meta !== null)
            <span id="{{ $ids }}-meta" wire:ignore.self @class(['text-muted shrink-0 text-xs tabular-nums', 'mt-0.5' => $variant === 'org'])>{{ $meta }}</span>
        @endif
    </div>

    @if ($branch)
        {{-- Children fetched on first open are the script's: wire:ignore keeps a Livewire render from emptying them. --}}
        <ul role="group" data-tree-group @if (! $open) hidden @endif @if ($childrenUrl) wire:ignore @endif>
            {{ $slot }}
        </ul>
    @endif
</li>
resources/js/widget/tree/index.js Show
index.js
// Drives <x-widget.tree>, an ARIA tree. A click on a node opens or closes it (in the checkbox look, ticks it; its arrow
// still opens it); a link node is followed. The keyboard: Up and Down move between the nodes you can see, Right opens
// a node or moves into it, Left closes it or moves to its parent, Home and End go to the first and last, Enter
// activates, Space ticks (checkbox) or opens, and a letter jumps to the next node starting with it. Only one node is
// in the Tab order at a time. A node with data-children-url fetches its children's HTML on first open.
// Events on the tree: tree:toggle (detail.item, detail.open) and tree:loaded (detail.item, detail.count).
// Delegated from `document`, so trees added later (a Livewire render, fetched HTML) work without setting up.

import { onLivewireMorph } from '../field';

const TREE = '[data-tree]';
const ITEM = '[data-tree-item]';
// The error row's look, written out here since the script builds it.
const ERROR_ROW = 'text-error flex flex-wrap items-center gap-x-2 px-2 py-1.5 text-sm';
const RETRY = 'text-foreground hover:text-primary focus-visible:ring-primary rounded underline underline-offset-4 outline-none focus-visible:ring-2';

const treeOf = (element) => element.closest(TREE);
const rowOf = (item) => item.querySelector(':scope > [data-tree-row]');
const groupOf = (item) => item.querySelector(':scope > [data-tree-group]');
const childItems = (item) => [...(groupOf(item)?.children ?? [])].filter((child) => child.matches(ITEM));
const isBranch = (item) => item.hasAttribute('aria-expanded');
const isOpen = (item) => item.getAttribute('aria-expanded') === 'true';
const isCheckbox = (tree) => tree.dataset.variant === 'checkbox';
const isDisabled = (item) => item.getAttribute('aria-disabled') === 'true';

// The node a node sits under, in the same tree; null at the top.
function parentItem(item) {
    const parent = item.parentElement?.closest(`${ITEM}, ${TREE}`);

    return parent?.matches(ITEM) ? parent : null;
}

// The nodes a person can see, in reading order: none inside a closed node.
const visibleItems = (tree) => [...tree.querySelectorAll(ITEM)].filter((item) => !item.parentElement.closest('[data-tree-group][hidden]'));

// --- Focus ------------------------------------------------------------------------------------------------------------

// One node in the Tab order (tabindex 0), the rest reached with the arrow keys: the one last focused, or the first.
function keepTabStop(tree) {
    const items = visibleItems(tree);
    const stop = items.find((item) => item.tabIndex === 0) ?? items[0];
    tree.querySelectorAll(ITEM).forEach((item) => {
        item.tabIndex = item === stop ? 0 : -1;
    });
}

function focusItem(item, { scroll = true } = {}) {
    if (!item) {
        return;
    }
    treeOf(item).querySelectorAll(`${ITEM}[tabindex="0"]`).forEach((other) => {
        other.tabIndex = -1;
    });
    item.tabIndex = 0;
    item.focus({ preventScroll: !scroll });
}

// --- Opening, and children fetched on first open ----------------------------------------------------------------------

const loading = new WeakMap();

async function setOpen(item, open) {
    if (!isBranch(item) || isOpen(item) === open) {
        return;
    }
    item.setAttribute('aria-expanded', String(open));
    rowOf(item).toggleAttribute('data-expanded', open);
    const group = groupOf(item);
    if (group) {
        group.hidden = !open;
    }
    item.dispatchEvent(new CustomEvent('tree:toggle', { bubbles: true, detail: { item, open } }));
    if (open && item.dataset.childrenUrl && !('loaded' in item.dataset)) {
        await load(item);
    }
}

// The URL answers with a tree of the same look holding the children, as HTML; its items go in as they are. A <style> in it is dropped, as the
// table does: inserted, it would break a Content Security Policy without 'unsafe-inline'.
function load(item) {
    if (loading.has(item)) {
        return loading.get(item);
    }
    const tree = treeOf(item);
    const row = rowOf(item);
    const group = groupOf(item);
    row.toggleAttribute('data-busy', true);
    item.setAttribute('aria-busy', 'true');
    group.querySelector(':scope > [data-tree-error]')?.remove();

    const done = (async () => {
        try {
            const response = await fetch(item.dataset.childrenUrl, { headers: { Accept: 'text/html' }, credentials: 'same-origin' });
            // A redirect (to a login page, say) brings back a page, not children.
            if (!response.ok || response.redirected) {
                throw new Error(`${response.status}`);
            }
            const html = (await response.text()).replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
            // They come back inside a tree of the same look, which tells each one which look to render. Its items are
            // what goes in; the tree around them is left behind.
            const answer = new DOMParser().parseFromString(html, 'text/html');
            const children = [...(answer.querySelector(TREE) ?? answer.body).children].filter((child) => child.matches(ITEM));
            group.replaceChildren(...children.map((child) => document.adoptNode(child)));
            item.dataset.loaded = '';
            if (children.length === 0) {
                // Nothing under it after all: a leaf from now on.
                item.removeAttribute('aria-expanded');
                row.removeAttribute('data-expanded');
                // A list keeps the empty box, so labels stay in line; a card has nothing there.
                const toggle = row.querySelector('[data-tree-toggle]');
                if (['cards', 'org'].includes(tree.dataset.variant)) {
                    toggle?.remove();
                } else {
                    toggle?.replaceChildren();
                }
                group.remove();
            } else if (isCheckbox(tree)) {
                tickLoaded(item, children);
            }
            item.dispatchEvent(new CustomEvent('tree:loaded', { bubbles: true, detail: { item, count: children.length } }));
        } catch {
            showError(item);
        } finally {
            row.removeAttribute('data-busy');
            item.removeAttribute('aria-busy');
            loading.delete(item);
        }
    })();
    loading.set(item, done);

    return done;
}

// Not a node: a row inside the open group saying so, with a way to try again.
function showError(item) {
    const row = Object.assign(document.createElement('li'), { className: ERROR_ROW });
    row.setAttribute('role', 'none');
    row.dataset.treeError = '';
    const retry = Object.assign(document.createElement('button'), { type: 'button', className: RETRY, textContent: 'Try again' });
    retry.dataset.treeRetry = '';
    row.append(Object.assign(document.createElement('span'), { textContent: "Couldn't load these." }), retry);
    groupOf(item).replaceChildren(row);
}

// --- Ticking (the checkbox look) ----------------------------------------------------------------------------------------

function setChecked(item, state) {
    item.setAttribute('aria-checked', state);
    const row = rowOf(item);
    row.toggleAttribute('data-checked', state === 'true');
    row.toggleAttribute('data-mixed', state === 'mixed');
}

const checked = (item) => item.getAttribute('aria-checked');

// A node, and everything under it that isn't disabled.
function tickDown(item, state) {
    if (!isDisabled(item)) {
        setChecked(item, state);
    }
    childItems(item).forEach((child) => tickDown(child, state));
}

// A parent shows what's under it: ticked when all of it is, a dash when some is. One not yet opened keeps its own.
function settleUp(item) {
    for (let node = item; node; node = parentItem(node)) {
        const states = childItems(node).map(checked);
        if (states.length > 0) {
            setChecked(node, states.every((state) => state === 'true') ? 'true' : states.every((state) => state === 'false') ? 'false' : 'mixed');
        }
    }
}

// Ticks it and everything under it, or unticks them when every leaf under it that can be ticked already is. Deciding by
// those leaves, not the parent's own state, means a disabled, unticked child can't leave it stuck half-ticked.
function toggleChecked(item) {
    if (isDisabled(item)) {
        return;
    }
    const tickable = [item, ...item.querySelectorAll(ITEM)].filter((node) => !isDisabled(node) && childItems(node).length === 0);
    tickDown(item, tickable.every((node) => checked(node) === 'true') ? 'false' : 'true');
    // From the node itself: a disabled child it couldn't tick leaves it half-ticked.
    settleUp(item);
    writeValue(treeOf(item));
}

const valueSelect = (tree) => tree.closest('[data-field]')?.querySelector('select[data-tree-value]');

// The hidden select is what submits and what wire:model binds. Nodes on the page update their option; values under
// nodes not yet opened keep theirs.
function writeValue(tree) {
    const select = valueSelect(tree);
    if (!select) {
        return;
    }
    const options = new Map([...select.options].map((option) => [option.value, option]));
    tree.querySelectorAll(`${ITEM}[data-value]`).forEach((item) => {
        const on = checked(item) === 'true';
        const option = options.get(item.dataset.value);
        if (option) {
            option.selected = on;
        } else if (on) {
            select.append(new Option(item.dataset.value, item.dataset.value, true, true));
        }
    });
    select.dispatchEvent(new Event('input', { bubbles: true }));
    select.dispatchEvent(new Event('change', { bubbles: true }));
}

// Ticks the boxes from the select: as the page loads, and after a Livewire render changed the bound property.
function readValue(tree) {
    const select = valueSelect(tree);
    const chosen = new Set(select ? [...select.selectedOptions].map((option) => option.value) : []);
    const items = [...tree.querySelectorAll(ITEM)];
    items.forEach((item) => setChecked(item, chosen.has(item.dataset.value) ? 'true' : 'false'));
    // Deepest first, so each parent counts children that are already settled.
    items.reverse().forEach((item) => {
        const states = childItems(item).map(checked);
        if (states.length > 0) {
            setChecked(item, states.every((state) => state === 'true') ? 'true' : states.every((state) => state === 'false') ? 'false' : 'mixed');
        }
    });
}

// Children just fetched: ticked if their value was, or if their parent was ticked whole; then the parents settle.
function tickLoaded(item, children) {
    const chosen = new Set([...(valueSelect(treeOf(item))?.selectedOptions ?? [])].map((option) => option.value));
    const inherit = checked(item) === 'true';
    children.forEach((child) => tickDown(child, inherit || chosen.has(child.dataset.value) ? 'true' : 'false'));
    settleUp(item);
    writeValue(treeOf(item));
}

// --- Setting up --------------------------------------------------------------------------------------------------------

function setUp(tree) {
    keepTabStop(tree);
    if (isCheckbox(tree)) {
        readValue(tree);
    }
}

const setUpAll = (scope = document) => {
    if (scope instanceof Element && scope.matches(TREE)) {
        setUp(scope);
    }
    scope.querySelectorAll?.(TREE).forEach(setUp);
};

// --- Pointer -----------------------------------------------------------------------------------------------------------

document.addEventListener('click', (event) => {
    const retry = event.target.closest?.('[data-tree-retry]');
    if (retry) {
        const item = retry.closest(ITEM);
        focusItem(item);
        load(item);

        return;
    }
    const row = event.target.closest?.('[data-tree-row]');
    const tree = row && treeOf(row);
    if (!tree) {
        return;
    }
    const item = row.parentElement;
    // Something of its own inside the node (a link in its details, say) does its own thing.
    const own = event.target.closest('a[href], button, input, select, textarea, summary');
    if (own && !own.closest('[data-tree-row] > span > span[id$="-label"]')) {
        return;
    }
    focusItem(item, { scroll: false });
    // The node's own link is followed as a link.
    if (own) {
        return;
    }
    if (isCheckbox(tree) && !event.target.closest('[data-tree-toggle]')) {
        toggleChecked(item);

        return;
    }
    setOpen(item, !isOpen(item));
});

// Focus moving onto a node by any means (a click on its padding, a script) makes it the Tab stop.
document.addEventListener('focusin', (event) => {
    if (!event.target.matches?.(ITEM)) {
        return;
    }
    treeOf(event.target).querySelectorAll(`${ITEM}[tabindex="0"]`).forEach((other) => {
        other.tabIndex = -1;
    });
    event.target.tabIndex = 0;
});

// --- Keyboard ----------------------------------------------------------------------------------------------------------

function activate(item) {
    const link = rowOf(item).querySelector('[id$="-label"] a[href]');
    if (link) {
        link.click();
    } else if (isCheckbox(treeOf(item))) {
        toggleChecked(item);
    } else {
        setOpen(item, !isOpen(item));
    }
}

const labelOf = (item) => rowOf(item).querySelector('[id$="-label"]')?.textContent.trim().toLowerCase() ?? '';

document.addEventListener('keydown', (event) => {
    const item = event.target.matches?.(ITEM) ? event.target : null;
    if (!item || event.altKey || event.ctrlKey || event.metaKey) {
        return;
    }
    const tree = treeOf(item);
    const items = visibleItems(tree);
    const at = items.indexOf(item);
    // Right and Left follow the reading direction: in a right-to-left page, Left goes in.
    const rtl = getComputedStyle(tree).direction === 'rtl';
    const key = rtl && event.key === 'ArrowLeft' ? 'ArrowRight' : rtl && event.key === 'ArrowRight' ? 'ArrowLeft' : event.key;

    switch (key) {
        case 'ArrowDown':
            focusItem(items[at + 1]);
            break;
        case 'ArrowUp':
            focusItem(items[at - 1]);
            break;
        case 'Home':
            focusItem(items[0]);
            break;
        case 'End':
            focusItem(items.at(-1));
            break;
        case 'ArrowRight':
            if (isBranch(item) && !isOpen(item)) {
                setOpen(item, true);
            } else if (isOpen(item)) {
                focusItem(childItems(item)[0]);
            }
            break;
        case 'ArrowLeft':
            if (isOpen(item)) {
                setOpen(item, false);
            } else {
                focusItem(parentItem(item));
            }
            break;
        case 'Enter':
            activate(item);
            break;
        case ' ':
            if (isCheckbox(tree)) {
                toggleChecked(item);
            } else {
                setOpen(item, !isOpen(item));
            }
            break;
        default: {
            // A letter or digit: the next node, after this one and round to the top, whose label starts with it.
            if (event.key.length !== 1 || !/\S/.test(event.key)) {
                return;
            }
            const letter = event.key.toLowerCase();
            const next = [...items.slice(at + 1), ...items.slice(0, at)].find((other) => labelOf(other).startsWith(letter));
            if (!next) {
                return;
            }
            focusItem(next);
        }
    }
    event.preventDefault();
});

// --- After a Livewire render, and for pages swapped in by wire:navigate -------------------------------------------------

// A frame later: Livewire 3 first reuses the hidden select's options in place, then marks the bound values selected,
// so ticking from it straight after the render would read the half-done select (checked in a browser against
// Livewire 3.8 and 4.4).
onLivewireMorph((element) => requestAnimationFrame(() => {
    const tree = element.closest?.(TREE);
    if (tree) {
        setUp(tree);
    } else {
        setUpAll(element);
    }
}));
document.addEventListener('livewire:navigated', () => setUpAll());

export const tree = {
    open: (item) => setOpen(item, true),
    close: (item) => setOpen(item, false),
};

setUpAll();
app/View/Widget/ElementIds.php Show
ElementIds.php
<?php

declare(strict_types=1);

namespace App\View\Widget;

use Illuminate\Container\Attributes\Scoped;
use LogicException;

/**
 * Keeps element ids unique within one response, so labels, aria-describedby and #fragments
 * always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
 */
#[Scoped]
final class ElementIds
{
    /** @var array<string, true> */
    private array $used = [];

    /**
     * Reserves an id for this response.
     *
     * A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
     * id is one the caller chose and may reference from JS or CSS, so silently renaming it would
     * break that reference; a duplicate throws instead, which surfaces in development and tests.
     */
    public function claim(string $id, bool $explicit = false): string
    {
        if (! isset($this->used[$id])) {
            return $this->reserve($id);
        }

        if ($explicit) {
            throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
        }

        $suffix = 2;
        while (isset($this->used["{$id}-{$suffix}"])) {
            $suffix++;
        }

        return $this->reserve("{$id}-{$suffix}");
    }

    private function reserve(string $id): string
    {
        $this->used[$id] = true;

        return $id;
    }
}
app/View/Widget/FormField.php Show
FormField.php
<?php

declare(strict_types=1);

namespace App\View\Widget;

use Illuminate\Contracts\Support\MessageBag;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\ViewErrorBag;
use Illuminate\View\ComponentAttributeBag;

/**
 * Server-side state of one form widget: its dot-notation key, a valid id, its validation
 * messages and its old input. Every <x-widget.input.*> and the date picker resolve through
 * here, so array names (items[0][date]) and named error bags behave the same everywhere.
 */
final class FormField
{
    /**
     * @param  list<string>  $errors
     */
    private function __construct(
        public readonly ?string $name,
        public readonly string $id,
        public readonly ?string $key,
        public readonly array $errors,
        // The property a wire:model or x-model attribute binds it to, if any.
        public readonly ?string $bound = null,
    ) {}

    /**
     * @param  mixed  $errorBag  the view's shared $errors (absent outside a web request)
     * @param  string|array<int, string>|null  $error  an explicit message from the caller; overrides the bag
     */
    public static function make(
        ?string $name,
        ?string $id,
        mixed $errorBag,
        string|array|null $error = null,
        string $bag = 'default',
        string $idPrefix = 'field',
        ?ComponentAttributeBag $attributes = null,
    ): self {
        // With no name, a Livewire or Alpine binding (wire:model="email") names the field. Its errors are filed under
        // that property, and its id stays the same on every render, which Livewire's morph needs to keep the element
        // (it matches elements by id: a random one makes it swap in a new field, dropping focus mid-typing).
        $bound = $attributes === null ? null : self::boundTo($attributes);
        $key = match (true) {
            $name !== null && $name !== '' => self::key($name),
            $bound !== null => self::key($bound),
            default => null,
        };

        $messages = match (true) {
            $error !== null => Arr::wrap($error),
            $key !== null && $errorBag instanceof ViewErrorBag => self::messagesFor($errorBag->getBag($bag), $key),
            default => [],
        };

        return new self(
            $name,
            app(ElementIds::class)->claim(
                $id ?? ($key !== null ? self::idFrom($key) : $idPrefix.'-'.Str::random(6)),
                explicit: $id !== null,
            ),
            $key,
            array_values(array_filter($messages, static fn (mixed $message): bool => is_string($message) && $message !== '')),
            $bound,
        );
    }

    /**
     * The field's own messages, plus those Laravel files per item for a list of values: a 'tags.*' rule
     * reports a bad second choice under tags.1, which a multiple select named tags must still show. Only
     * numbered children count, so a field named address doesn't take errors meant for address[city].
     *
     * @return list<string>
     */
    private static function messagesFor(MessageBag $bag, string $key): array
    {
        $items = array_filter(
            $bag->getMessages(),
            static fn (string $name): bool => preg_match('/^'.preg_quote($key, '/').'\.\d+$/', $name) === 1,
            ARRAY_FILTER_USE_KEY,
        );

        return array_values(array_unique([...$bag->get($key), ...array_merge(...array_values($items))]));
    }

    /**
     * items[0][date] → items.0.date and tags[] → tags: the key Laravel files errors and old input under.
     */
    public static function key(string $name): string
    {
        return trim((string) preg_replace('/\[([^\]]*)\]/', '.$1', $name), '.');
    }

    /**
     * The id a field named $name gets when it's the first of that name on the page, for links to it
     * (the error summary). A later duplicate gets a -2 suffix, which links can't know about.
     */
    public static function idFor(string $name): string
    {
        return self::idFrom(self::key($name));
    }

    /**
     * The property a wire:model or x-model attribute (any modifiers) binds the field to; null without one.
     */
    public static function boundTo(ComponentAttributeBag $attributes): ?string
    {
        return array_values(self::binding($attributes))[0] ?? null;
    }

    private static function idFrom(string $key): string
    {
        return trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $key), '-');
    }

    public function hasError(): bool
    {
        return $this->errors !== [];
    }

    public function errorId(): string
    {
        return $this->id.'-error';
    }

    public function infoId(): string
    {
        return $this->id.'-info';
    }

    /**
     * Old input after a failed validation, falling back to the widget's value prop, or with none, to the bound Livewire
     * property: a re-render then draws the field as it is, which Livewire morphs onto the page.
     */
    public function old(mixed $default = null): mixed
    {
        if ($default === null) {
            [$found, $live] = $this->fromLivewire();
            $default = $found ? $live : null;
        }

        return $this->key === null ? $default : old($this->key, $default);
    }

    /**
     * The bound property's value while Livewire renders the component that holds it: Livewire shares that component
     * with every view as $__livewire. Livewire isn't a dependency; it's only looked for. [false, null] otherwise.
     * $key reads inside it: a range bound to period reads period.start.
     *
     * @return array{0: bool, 1: mixed}
     */
    public function fromLivewire(?string $key = null): array
    {
        $component = $this->bound === null ? null : view()->shared('__livewire');

        return is_object($component) ? [true, data_get($component, $key === null ? $this->bound : "{$this->bound}.{$key}")] : [false, null];
    }

    /**
     * The binding attribute as written (wire:model.live => period), to put on the inputs that carry the value: a range
     * picker binds period.start and period.end with the same modifiers. Empty without one.
     *
     * @return array<string, string>
     */
    public static function binding(ComponentAttributeBag $attributes): array
    {
        foreach ($attributes->getAttributes() as $attribute => $value) {
            if (is_string($value) && $value !== '' && (str_starts_with($attribute, 'wire:model') || str_starts_with($attribute, 'x-model'))) {
                return [$attribute => $value];
            }
        }

        return [];
    }

    /**
     * Whether a checkbox or switch renders ticked. An unticked box isn't in the request at all, so after a
     * failed submit "no old value" means unticked, but only when that submit was this box's own form. A page
     * with a second form (or a disabled box, which is never sent) would otherwise lose every `checked`.
     *
     * @param  bool  $alwaysSent  it has an unchecked-value, so its form always sends something under its name
     * @param  mixed  $errorBag  the view's shared $errors
     */
    public function checked(mixed $value, bool $default, bool $disabled, bool $alwaysSent, mixed $errorBag, string $bag = 'default'): bool
    {
        // Bound to a Livewire property: that says, true/false, or for a list of boxes, whether it holds this value.
        [$found, $live] = $this->fromLivewire();
        if ($found) {
            return is_array($live) ? in_array(self::text($value), array_map(self::text(...), $live), true) : (bool) $live;
        }
        if ($this->key === null || $disabled || ! session()->hasOldInput()) {
            return $default;
        }

        $old = old($this->key);
        if ($old !== null) {
            return in_array(self::text($value), array_map(self::text(...), Arr::wrap($old)), true);
        }
        // Nothing under this name, though this box always sends something: its form wasn't the one submitted.
        if ($alwaysSent) {
            return $default;
        }
        // A form with its own error bag: no errors in that bag means the failed submit was a different form.
        if ($bag !== 'default') {
            return $errorBag instanceof ViewErrorBag && $errorBag->getBag($bag)->isNotEmpty() ? false : $default;
        }

        // One form, or forms sharing the default bag: there's no telling them apart, so trust the old input.
        return false;
    }

    /** Values cast to backed enums (Plan::Pro) compare as their backing value. */
    private static function text(mixed $value): string
    {
        return (string) ($value instanceof \BackedEnum ? $value->value : $value);
    }

    /**
     * What the caller passed, minus `class` (that styles the wrapper) and the aria attributes
     * this widget manages itself. Goes on the element that is actually submitted.
     */
    public function forwarded(ComponentAttributeBag $attributes): ComponentAttributeBag
    {
        return $attributes->except(['class', 'aria-invalid', 'aria-describedby']);
    }

    /**
     * aria-invalid plus one aria-describedby that joins the error or the hint, and the caller's own
     * ids. Two separate aria-describedby attributes would make the browser silently drop one.
     */
    public function aria(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        // Not both: the frame hides the hint while there's an error, and aria-describedby reads hidden text, so a
        // screen reader would hear two messages that often say the same thing. resources/js/field brings the hint
        // back when the error clears.
        $describedBy = array_filter([
            $this->hasError() ? $this->errorId() : null,
            $hasInfo && ! $this->hasError() ? $this->infoId() : null,
            $attributes->get('aria-describedby'),
        ]);

        return new ComponentAttributeBag([
            'aria-invalid' => $this->hasError() ? 'true' : null,
            'aria-describedby' => $describedBy === [] ? null : implode(' ', $describedBy),
        ]);
    }

    /**
     * For a widget whose visible control isn't the submitted input (grouped number, phone): what belongs on the
     * hidden input that carries the value. Which form it's in, and Livewire and Alpine bindings, go with the value.
     */
    public function bindings(ComponentAttributeBag $attributes): ComponentAttributeBag
    {
        return $attributes->filter(static fn (mixed $value, string $key): bool => self::isBinding($key));
    }

    /**
     * The other side of bindings(): everything else the caller passed (required, autofocus, aria-label,
     * placeholder…) goes on the visible control, where the browser validates it and screen readers hear it.
     */
    public function visibleAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        return $this->controlAttributes($attributes->filter(static fn (mixed $value, string $key): bool => ! self::isBinding($key)), $hasInfo);
    }

    private static function isBinding(string $key): bool
    {
        return $key === 'form' || str_starts_with($key, 'wire:model') || str_starts_with($key, 'x-model');
    }

    /**
     * forwarded() and aria() together, for widgets whose visible control is also the submitted one.
     */
    public function controlAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        return $this->forwarded($attributes)->merge($this->aria($attributes, $hasInfo)->getAttributes());
    }
}