Skip to content
Brok UI

Loading…

No results

Diagrams

Open source

Dependency-free, token-themed relationship diagrams — flow, network, org chart, indented tree, mind map and fishbone.

Version
v3.0.2
Stability
stable
License
MIT
Related
Organization Chart
Mind Map
4 of 6

Indented Tree — A nested outline rendering a tree of items as 32px rows — one guide line per level, chevron toggles that fold a branch, folder/file icons, page links or permission checkboxes, and right-aligned meta; an optional movable mode drags or Alt+Arrow-moves rows before, after or inside another row; logical padding mirrors under RTL.

Preview

  • resources 3 folders
    • views 12 files
      • components 8 files
      • layouts 2 files
      • welcome.blade.php 4 KB
    • js 6 files
      • app.js 1 KB
      • bootstrap.js 2 KB
    • css 2 files
      • app.css 3 KB
  • routes 4 files
    • web.php 2 KB
    • api.php 1 KB
  • composer.json 5 KB

A nested outline. Pass `:items` — an array of nodes with a `label`, an optional `meta` (e.g. a count or size) and an optional `children[]` array — and every branch renders as its own group with a guide line and a chevron that folds it.

Variant
Loading
previews.components.indented-tree.default.blade.php Blade
<div class="space-y-4">
    <x-ui.indented-tree class="max-w-md" />

    <p class="text-sm text-muted-foreground">
        {{ __('A nested outline. Pass `:items` — an array of nodes with a `label`, an optional `meta` (e.g. a count or size) and an optional `children[]` array — and every branch renders as its own group with a guide line and a chevron that folds it.') }}
    </p>
</div>
Default Current
Files Current
Permissions Current

Installation

terminal
php artisan ui:add indented-tree

Note

This component ships an Alpine behavior module at resources/js/ui/indented-tree.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './indented-tree.js';

Registry contract

php artisan ui:add indented-tree writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/indented-tree.blade.php
  • blade resources/views/components/ui/indented-tree/node.blade.php
  • js resources/js/ui/indented-tree.js
Registry dependencies
icon skeleton
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

indented-tree.md
# Brok UI: Indented Tree (`indented-tree`)

A nested outline rendering a tree of items as 32px rows — one guide line per level, chevron toggles that fold a branch, folder/file icons, page links or permission checkboxes, and right-aligned meta; an optional movable mode drags or Alt+Arrow-moves rows before, after or inside another row; logical padding mirrors under RTL.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add indented-tree
```

## Usage

```blade
<div class="space-y-4">
    <x-ui.indented-tree class="max-w-md" />

    <p class="text-sm text-muted-foreground">
        {{ __('A nested outline. Pass `:items` — an array of nodes with a `label`, an optional `meta` (e.g. a count or size) and an optional `children[]` array — and every branch renders as its own group with a guide line and a chevron that folds it.') }}
    </p>
</div>
```

## Props

- `items` (mixed|null, default `null`) — The tree: a list of nodes, each with a label and optional id (the move event and current use it), href (the row links there), icon (an icon name), meta, value (permissions variant), current (bool) and a children list of the same shape. Defaults to a demo file tree; an empty list shows the empty state.
- `label` (string, default `Indented tree`) — Accessible name for the tree region.
- `variant` (default|files|permissions, default `default`) — default shows chevrons and dots; files shows folder/file-type icons; permissions shows a checkbox per row.
- `name` (string, default `permissions`) — permissions variant only: the form field name each checkbox submits as name[].
- `collapsible` (bool, default `true`) — Lets a chevron button toggle each branch open or closed.
- `openDepth` (int, default `0`) — Depth below which branches start folded; 0 keeps everything open.
- `current` (string|int|null, default `null`) — The id of the node for the page on screen: its treeitem gets aria-current="page", a muted row, the first tab stop, and its ancestors open.
- `movable` (bool, default `false`) — Rows move by pointer drag (drop before, after or inside a row, with a drop line or a tinted row) and by Alt+Arrow keys, with a live announcement. Each move dispatches a cancelable, bubbling tree-move event with { id, key, parentId, index, fromParentId, fromIndex }. Not for the permissions variant.
- `persist` (string|null, default `null`) — A storage key: open and closed branches are remembered in localStorage per node id and restored on the next visit.
- `loading` (bool, default `false`) — Shows skeleton rows in a status region with aria-busy instead of the tree.
- `error` (string|bool|null, default `null`) — A message (or true for a default one) shown as an alert instead of the tree.
- `emptyText` (string|null, default `null`) — The message shown when items is an empty list; defaults to a translated "Nothing here yet.".
- `item` (array, default `[]`) — Declared by @props in the registry Blade source.
- `depth` (int, default `0`) — Declared by @props in the registry Blade source.
- `path` (string, default `0`) — Declared by @props in the registry Blade source.

## Use when

- Use when hierarchy, causality, network structure, or process flow is the information itself.
- Showing a nested hierarchy the user can expand and collapse, such as a file tree or a permissions outline.
- Needing a real APG treeview keyboard model (arrow-key navigation, type-ahead) rather than a plain nested list.
- Navigating nested documents (a wiki or docs page tree) with links as tree items, the open page marked and rows the user can move or nest by drag or keyboard.

## Avoid when

- Do not use specialized diagrams when a list, table, or short paragraph would be easier to interpret.
- The structure has no parent/child nesting; use a plain list instead, or sortable when the user only reorders a flat list.
- The hierarchy is a reporting structure of people, not files or settings; use org-chart instead.
- The links are the app's main navigation sections, not user documents; use sidebar with its menu parts instead.

## Anti-patterns

- Using a specialized diagram when linear content is clearer

## Rules

- Use the `<brok:indented-tree>` tag (or `<x-ui.indented-tree>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/indented-tree
- Registry JSON (files, props, contract): https://brokui.dev/r/open/indented-tree.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

empty.blade.php Blade
<div class="w-full max-w-xs">
    <x-ui.indented-tree :items="[]" :label="__('Pages')" :empty-text="__('No pages yet. Create a page to start the tree.')" />
</div>
error.blade.php Blade
<div class="w-full max-w-xs">
    <x-ui.indented-tree :items="[]" :label="__('Pages')" :error="__('The pages could not load. Reload to try again.')" />
</div>
files.blade.php Blade
<div class="w-full max-w-sm">
    <x-ui.indented-tree variant="files" />
</div>
loading.blade.php Blade
<div class="w-full max-w-xs">
    <x-ui.indented-tree loading :label="__('Pages')" />
</div>
long-content.blade.php Blade
{{-- Long titles truncate on one line; the full title stays in the title attribute and the accessible name. --}}
@php
    $pages = [
        ['id' => 'policy', 'label' => __('Security and privacy policy for contractors, freelancers and temporary staff working on client projects'), 'href' => '#policy', 'icon' => 'file-text', 'meta' => '12', 'children' => [
            ['id' => 'retention', 'label' => __('Data retention schedule for invoices, contracts, time sheets and client correspondence'), 'href' => '#retention', 'icon' => 'file-text'],
            ['id' => 'incident', 'label' => 'Incident-response-runbook-for-production-outages-and-customer-data-exposure', 'href' => '#incident', 'icon' => 'file-text'],
        ]],
        ['id' => 'short', 'label' => __('Notes'), 'href' => '#notes', 'icon' => 'file-text'],
    ];
@endphp

<div class="w-full max-w-60">
    <x-ui.indented-tree :items="$pages" :label="__('Pages')" current="retention" movable />
</div>
pages.blade.php Blade
{{-- A page tree for a wiki or docs sidebar: links as tree items, the open page
     marked, branches remembered, and rows moved by drag or Alt+Arrow keys. --}}
@php
    $pages = [
        ['id' => 'handbook', 'label' => __('Team handbook'), 'href' => '#handbook', 'icon' => 'book-open', 'children' => [
            ['id' => 'onboarding', 'label' => __('Onboarding'), 'href' => '#onboarding', 'icon' => 'file-text', 'children' => [
                ['id' => 'first-week', 'label' => __('Your first week'), 'href' => '#first-week', 'icon' => 'file-text'],
                ['id' => 'accounts', 'label' => __('Accounts and access'), 'href' => '#accounts', 'icon' => 'file-text'],
            ]],
            ['id' => 'time-off', 'label' => __('Time off'), 'href' => '#time-off', 'icon' => 'file-text'],
        ]],
        ['id' => 'meetings', 'label' => __('Meeting notes'), 'href' => '#meetings', 'icon' => 'calendar', 'children' => [
            ['id' => 'weekly-sync', 'label' => __('Weekly sync'), 'href' => '#weekly-sync', 'icon' => 'file-text'],
        ]],
        ['id' => 'roadmap', 'label' => __('Roadmap'), 'href' => '#roadmap', 'icon' => 'file-text'],
    ];
@endphp

<div class="w-full max-w-xs" x-data="{ moved: '' }">
    <x-ui.indented-tree
        :items="$pages"
        :label="__('Pages')"
        current="first-week"
        movable
        persist="docs-pages-example"
        x-on:tree-move="moved = `${$event.detail.id} → ${$event.detail.parentId ?? 'top'} #${$event.detail.index}`"
    />
    <p class="mt-2 text-xs text-muted-foreground" data-testid="tree-move-log" x-text="moved"></p>
</div>
permissions.blade.php Blade
@php
    $permissions = [
        ['label' => __('Billing'), 'value' => 'billing', 'children' => [
            ['label' => __('View invoices'), 'value' => 'billing.view'],
            ['label' => __('Edit payment methods'), 'value' => 'billing.edit'],
        ]],
        ['label' => __('Users'), 'value' => 'users', 'children' => [
            ['label' => __('Invite members'), 'value' => 'users.invite'],
            ['label' => __('Remove members'), 'value' => 'users.remove'],
        ]],
        ['label' => __('Reports'), 'value' => 'reports'],
    ];
@endphp

<form class="w-full max-w-sm">
    <x-ui.indented-tree variant="permissions" name="perms" :items="$permissions" label="Permissions" />
</form>
project.blade.php Blade
{{-- The default folder outline framed by chart-card with an item-count badge. --}}
<x-ui.chart-card heading="Project structure" subheading="Files & folders" trend="24 items" trend-variant="neutral">
    <x-ui.indented-tree label="{{ __('Project structure') }}" />
</x-ui.chart-card>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
items mixed | null null The tree: a list of nodes, each with a label and optional id (the move event and current use it), href (the row links there), icon (an icon name), meta, value (permissions variant), current (bool) and a children list of the same shape. Defaults to a demo file tree; an empty list shows the empty state.
label string Indented tree Accessible name for the tree region.
variant default | files | permissions default default shows chevrons and dots; files shows folder/file-type icons; permissions shows a checkbox per row.
name string permissions permissions variant only: the form field name each checkbox submits as name[].
collapsible bool true Lets a chevron button toggle each branch open or closed.
openDepth int 0 Depth below which branches start folded; 0 keeps everything open.
current string | int | null null The id of the node for the page on screen: its treeitem gets aria-current="page", a muted row, the first tab stop, and its ancestors open.
movable bool false Rows move by pointer drag (drop before, after or inside a row, with a drop line or a tinted row) and by Alt+Arrow keys, with a live announcement. Each move dispatches a cancelable, bubbling tree-move event with { id, key, parentId, index, fromParentId, fromIndex }. Not for the permissions variant.
persist string | null null A storage key: open and closed branches are remembered in localStorage per node id and restored on the next visit.
loading bool false Shows skeleton rows in a status region with aria-busy instead of the tree.
error string | bool | null null A message (or true for a default one) shown as an alert instead of the tree.
emptyText string | null null The message shown when items is an empty list; defaults to a translated "Nothing here yet.".
item array [] Declared by @props in the registry Blade source.
depth int 0 Declared by @props in the registry Blade source.
path string 0 Declared by @props in the registry Blade source.

Slots

  • default — Unused; rows render recursively from the items prop only.
  • x-ui.indented-tree.node — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

indented-tree indented-tree-drop-indicator indented-tree-empty indented-tree-error indented-tree-group indented-tree-item indented-tree-list indented-tree-loading indented-tree-meta indented-tree-row indented-tree-status

Behavior

  • Renders as a real nested tree (ul[role=group] per branch, li[role=treeitem] per row), not a flat indented list, so each level carries its own semantics.
  • role="tree" promises the APG treeview keyboard model: Up/Down walk the visible rows, Right/Left expand, enter, collapse and leave branches (swapped under RTL), Home/End jump to the ends, a typed letter jumps to the next matching row, * expands every sibling branch, and Enter opens a row's link or toggles the branch.
  • Each branch folds independently via a chevron toggle button; openDepth controls which levels start folded without disabling the toggle. The open state lives in the root component, so a Livewire re-render keeps it, and persist remembers it across visits.
  • A node with href renders a link stretched over its row, so a click anywhere on the row opens it; the chevron stays above it. Long labels truncate on one line and keep the full text in the title attribute and the accessible name.
  • movable: a drag starts after 4px of pointer movement (mouse or pen), marks a drop before or after a row with a line and inside a row with a tinted row, refuses a drop into the dragged row's own subtree, and Escape cancels. Alt+Up/Down move a row among its siblings, Alt+Right nests it in the previous sibling and Alt+Left moves it out of its parent. The tree moves the DOM itself unless the tree-move handler calls preventDefault(); a Livewire host can call a method from the event and re-render.
  • 3.0.0 migration: the root is a div wrapper that takes class, id, data-*, wire:* and x-* attributes; aria-label, aria-labelledby and aria-describedby go to ul[role=tree] ([data-slot=indented-tree-list]); under RTL, ArrowLeft expands and ArrowRight collapses, as the APG requires.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, responsive, rtl, darkMode, localized, interactive, behaviorTest, authoredStateFixtures.

Guidance

Diagram and relationship view

Explain hierarchy, causality, network structure, or flow.

Use when

  • Use when hierarchy, causality, network structure, or process flow is the information itself.
  • Showing a nested hierarchy the user can expand and collapse, such as a file tree or a permissions outline.
  • Needing a real APG treeview keyboard model (arrow-key navigation, type-ahead) rather than a plain nested list.
  • Navigating nested documents (a wiki or docs page tree) with links as tree items, the open page marked and rows the user can move or nest by drag or keyboard.

Avoid when

  • Do not use specialized diagrams when a list, table, or short paragraph would be easier to interpret.
  • The structure has no parent/child nesting; use a plain list instead, or sortable when the user only reorders a flat list.
  • The hierarchy is a reporting structure of people, not files or settings; use org-chart instead.
  • The links are the app's main navigation sections, not user documents; use sidebar with its menu parts instead.

Use instead

  • List, table, or prose explanation

Anti-patterns

  • Using a specialized diagram when linear content is clearer
Anatomy
root indented-tree-list indented-tree-item indented-tree-row indented-tree-group indented-tree-meta indented-tree-drop-indicator indented-tree-status indented-tree-loading indented-tree-empty indented-tree-error
Theming hooks
indented-tree indented-tree-drop-indicator indented-tree-empty indented-tree-error indented-tree-group indented-tree-item indented-tree-list indented-tree-loading indented-tree-meta indented-tree-row indented-tree-status

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Enter Escape ArrowUp ArrowDown Home End
Focus
managed
  • role="tree"/role="treeitem"/role="group" plus aria-level and aria-expanded give assistive technology the same structure the visual indentation shows; aria-level follows a row after a move.
  • The chevron toggle carries its own aria-label naming the row it expands or collapses, not a bare icon.
  • The page on screen carries aria-current="page" on its treeitem.
  • Moves are keyboard complete (Alt+Arrow keys) and every move is announced in a polite live region with the new parent and position.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="indented-tree-{{ $record->id }}">
    <div class="space-y-4">
        <x-ui.indented-tree class="max-w-md" />
    
        <p class="text-sm text-muted-foreground">
            {{ __('A nested outline. Pass `:items` — an array of nodes with a `label`, an optional `meta` (e.g. a count or size) and an optional `children[]` array — and every branch renders as its own group with a guide line and a chevron that folds it.') }}
        </p>
    </div>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:indented-tree
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/indented-tree.blade.php Blade
@props([
    'items' => null,
    'label' => 'Indented tree',
    // default (chevrons + dots) | files (folder / file-type icons) | permissions (checkboxes)
    'variant' => 'default',
    'name' => 'permissions',
    // Branches can fold: a chevron button toggles each group.
    'collapsible' => true,
    // Depth below which branches start folded (0 = everything open).
    'openDepth' => 0,
    // The id of the node for the page on screen: aria-current="page" and a muted row.
    'current' => null,
    // Drag a row (before, after or inside another row) or press Alt+Arrow keys to
    // move it; the tree dispatches a cancelable `tree-move` { id, parentId, index }.
    'movable' => false,
    // A storage key: open and closed branches are remembered in localStorage.
    'persist' => null,
    // Data states: skeleton rows while loading, a message on error or with no items.
    'loading' => false,
    'error' => null,
    'emptyText' => null,
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);
    $variant = in_array($variant, ['files', 'permissions'], true) ? $variant : 'default';
    $collapsible = filter_var($collapsible, FILTER_VALIDATE_BOOLEAN);
    $movable = filter_var($movable, FILTER_VALIDATE_BOOLEAN) && $variant !== 'permissions';
    $loading = filter_var($loading, FILTER_VALIDATE_BOOLEAN);
    $openDepth = max(0, (int) $openDepth);
    $current = $current === null || $current === '' ? null : (string) $current;
    $errorText = $error === true ? __('The tree could not load. Try again.') : (is_string($error) && $error !== '' ? $error : null);

    $items ??= [
        ['label' => 'resources', 'meta' => '3 folders', 'children' => [
            ['label' => 'views', 'meta' => '12 files', 'children' => [
                ['label' => 'components', 'meta' => '8 files'],
                ['label' => 'layouts', 'meta' => '2 files'],
                ['label' => 'welcome.blade.php', 'meta' => '4 KB'],
            ]],
            ['label' => 'js', 'meta' => '6 files', 'children' => [
                ['label' => 'app.js', 'meta' => '1 KB'],
                ['label' => 'bootstrap.js', 'meta' => '2 KB'],
            ]],
            ['label' => 'css', 'meta' => '2 files', 'children' => [
                ['label' => 'app.css', 'meta' => '3 KB'],
            ]],
        ]],
        ['label' => 'routes', 'meta' => '4 files', 'children' => [
            ['label' => 'web.php', 'meta' => '2 KB'],
            ['label' => 'api.php', 'meta' => '1 KB'],
        ]],
        ['label' => 'composer.json', 'meta' => '5 KB'],
    ];
    $items = is_iterable($items) ? collect($items)->values()->all() : [];

    // The accessible name belongs on the tree, not on the wrapper div: these
    // attributes go to ul[role=tree]; class, id, data-*, wire:* and x-* stay
    // on the wrapper.
    $nameAttributes = ['aria-label', 'aria-labelledby', 'aria-describedby'];
    $treeAttributes = $attributes->only($nameAttributes);
    $treeLabel = $treeAttributes->get('aria-label') ?? __($label);

    $strings = [
        'movedInto' => __(':label moved into :parent, position :position of :total.'),
        'movedTop' => __(':label moved to the top level, position :position of :total.'),
        'cannotMove' => __(':label cannot move further.'),
        'moveCancelled' => __('Move cancelled.'),
    ];
@endphp

{{-- A real nested tree (ul[role=group] per branch) so each level carries its
     own guide line and a branch folds as one unit. Rows are 32px, the chevron
     is the toggle, and the icon/label/meta share one baseline.

     `role="tree"` promises the APG treeview keyboard model, so `uiIndentedTree`
     delivers it: the tree is one tab stop, Arrow Up/Down walk the visible rows,
     Arrow Right expands (then enters) a branch, Arrow Left collapses (then
     leaves) it (mirrored in RTL), Home/End jump to the ends, a letter jumps to
     the next matching row, * expands the siblings and Enter opens a row's link
     (else it toggles). A movable tree adds Alt+Arrow moves and pointer drags. --}}
<div
    x-data="uiIndentedTree()"
    data-slot="indented-tree"
    data-variant="{{ $variant }}"
    data-collapsible="{{ $collapsible ? 'true' : 'false' }}"
    @if ($movable) data-movable="true" @endif
    @if (filled($persist)) data-persist="{{ $persist }}" @endif
    data-strings="{{ json_encode($strings, JSON_UNESCAPED_UNICODE) }}"
    {{ $attributes->except($nameAttributes)->merge(['class' => 'relative w-full text-sm text-foreground']) }}
>
    @if ($loading)
        <div data-slot="indented-tree-loading" role="status" aria-busy="true" class="flex flex-col gap-1">
            <span class="sr-only">{{ __('Loading…') }}</span>
            @foreach (['w-3/5', 'w-2/5', 'w-1/2', 'w-2/3'] as $index => $width)
                <div aria-hidden="true" class="flex h-8 items-center gap-2 pe-2 {{ $index > 0 && $index < 3 ? 'ps-6' : 'ps-1' }}">
                    <x-ui.skeleton class="size-4 shrink-0 rounded-sm" />
                    <x-ui.skeleton class="h-3 {{ $width }}" />
                </div>
            @endforeach
        </div>
    @elseif ($errorText !== null)
        <p data-slot="indented-tree-error" role="alert" class="flex items-start gap-2 rounded-md px-2 py-2 text-destructive">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="mt-0.5 size-4 shrink-0"><circle cx="12" cy="12" r="10" /><path d="M12 8v4M12 16h.01" /></svg>
            <span class="min-w-0">{{ $errorText }}</span>
        </p>
    @elseif ($items === [])
        <p data-slot="indented-tree-empty" class="px-2 py-2 text-muted-foreground">{{ $emptyText ?? __('Nothing here yet.') }}</p>
    @else
        <ul
            role="tree"
            aria-label="{{ $treeLabel }}"
            {{ $treeAttributes->except('aria-label') }}
            data-slot="indented-tree-list"
            @keydown="onKey($event)"
            @focusin="onFocusIn($event)"
            @if ($movable) @pointerdown="onPointerDown($event)" @click.capture="onClickCapture($event)" @endif
        >
            @foreach ($items as $index => $item)
                <x-ui.indented-tree.node
                    :item="$item"
                    :depth="0"
                    :path="(string) $index"
                    :variant="$variant"
                    :name="$name"
                    :collapsible="$collapsible"
                    :open-depth="$openDepth"
                    :current="$current"
                    :movable="$movable"
                />
            @endforeach

            {{ $slot }}
        </ul>

        @if ($movable)
            {{-- The drop line (before / after a row); a row a drop nests into is tinted instead.
                 Neutral foreground, never primary: a drag mark is not a call to action. --}}
            <div
                data-slot="indented-tree-drop-indicator"
                aria-hidden="true"
                x-show="drop && drop.zone !== 'inside'"
                x-cloak
                x-bind:style="{ 'inset-inline-start': (drop?.inset ?? 0) + 'px', top: (drop?.top ?? 0) + 'px' }"
                class="pointer-events-none absolute end-0 z-10 flex -translate-y-1/2 items-center"
            >
                <span class="-ms-1 size-2 shrink-0 rounded-full border-2 border-foreground bg-background"></span>
                <span class="h-0.5 w-full rounded-full bg-foreground"></span>
            </div>
            <p data-slot="indented-tree-status" role="status" aria-live="polite" class="sr-only" x-text="status"></p>
        @endif
    @endif
</div>
resources/views/components/ui/indented-tree/node.blade.php Blade
@props([
    'item' => [],
    'depth' => 0,
    'path' => '0',
    'variant' => 'default',
    'name' => 'permissions',
    'collapsible' => true,
    'openDepth' => 0,
    'current' => null,
    'movable' => false,
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);
    $children = array_values((array) ($item['children'] ?? []));
    $label = (string) ($item['label'] ?? '');
    $id = isset($item['id']) && $item['id'] !== '' ? (string) $item['id'] : null;
    $href = filled($item['href'] ?? null) ? (string) $item['href'] : null;
    $icon = filled($item['icon'] ?? null) ? (string) $item['icon'] : null;
    $isCurrent = (bool) ($item['current'] ?? false) || ($current !== null && $id === (string) $current);
    $hasChildren = count($children) > 0;
    $open = $openDepth === 0 || $depth < $openDepth;
    $ext = strtolower(pathinfo($label, PATHINFO_EXTENSION));
    $fileIcon = match ($ext) {
        'js', 'mjs', 'ts', 'php', 'json', 'css', 'scss', 'html', 'blade' => 'code',
        'md', 'mdx', 'txt' => 'doc',
        'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp' => 'image',
        default => 'file',
    };
    // A movable leaf can become a branch (a row dropped into it), so it
    // carries a hidden toggle and an empty group from the start.
    $branchable = $hasChildren || $movable;
    $toggle = $collapsible && $branchable;
    // aria-label: the row's own label names the treeitem; a name from content
    // would run on through every nested row.
    $itemExpr = "\$el.closest('[role=treeitem]')";
@endphp

<li
    role="treeitem"
    tabindex="-1"
    aria-level="{{ $depth + 1 }}"
    data-key="{{ $id ?? $path }}"
    @if ($id !== null) data-id="{{ $id }}" wire:key="indented-tree-node-{{ $id }}" @endif
    @if ($branchable) data-open="{{ $open ? 'true' : 'false' }}" x-bind:aria-expanded="expanded($el)" @endif
    @if ($isCurrent) aria-current="page" @endif
    aria-label="{{ $label }}"
    data-slot="indented-tree-item"
    {{ $attributes->merge(['class' => 'relative outline-none data-[dragging]:opacity-50 focus-visible:rounded-md focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background']) }}
>
    <div
        data-slot="indented-tree-row"
        @class([
            'group/row relative flex h-8 items-center gap-1 rounded-md pe-2 ps-1 transition-colors hover:bg-muted/60 data-[drop=inside]:bg-muted data-[drop=inside]:ring-1 data-[drop=inside]:ring-inset data-[drop=inside]:ring-foreground/60',
            'bg-muted font-medium' => $isCurrent,
        ])
    >
        {{-- Toggle / marker: a chevron for branches, a dot (or icon) for leaves. --}}
        @if ($toggle)
            <button
                type="button"
                data-tree-toggle
                tabindex="-1"
                x-on:click="toggleItem({{ $itemExpr }})"
                x-bind:aria-expanded="expanded({{ $itemExpr }})"
                @if ($movable) x-show="isBranch({{ $itemExpr }})" @unless ($hasChildren) x-cloak @endunless @endif
                aria-label="{{ __('Toggle :label', ['label' => $label]) }}"
                class="group/toggle relative z-10 flex size-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background"
            >
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-4 transition-transform duration-200 group-aria-expanded/toggle:rotate-90 motion-reduce:transition-none rtl:-scale-x-100">
                    <polyline points="9 6 15 12 9 18" />
                </svg>
            </button>
        @elseif ($hasChildren)
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="mx-1 size-4 shrink-0 rotate-90 text-muted-foreground rtl:-scale-x-100">
                <polyline points="9 6 15 12 9 18" />
            </svg>
        @endif
        @if (! $hasChildren)
            <span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true" @if ($toggle) x-show="!isBranch({{ $itemExpr }})" @endif>
                @if ($variant === 'default' && $icon === null)
                    <span class="size-1 rounded-full bg-muted-foreground/60"></span>
                @endif
            </span>
        @endif

        @if ($variant === 'permissions')
            <label class="flex min-w-0 flex-1 cursor-pointer items-center gap-2">
                <input
                    type="checkbox"
                    tabindex="-1"
                    name="{{ $name }}[]"
                    value="{{ $item['value'] ?? $label }}"
                    class="size-4 shrink-0 rounded border-input accent-primary"
                />
                <span data-tree-label class="truncate" title="{{ $label }}">{{ $label }}</span>
            </label>
        @else
            @if ($icon !== null)
                <x-ui.icon :name="$icon" size="sm" class="shrink-0 text-muted-foreground" />
            @elseif ($variant === 'files')
                @if ($hasChildren)
                    {{-- Folder, open or closed with the branch. --}}
                    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" class="size-4 shrink-0 text-chart-3" @if ($toggle) x-show="isOpen({{ $itemExpr }})" @endif>
                        <path d="M3 8a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v1H5.5a2 2 0 0 0-1.9 1.4L3 15.5z" opacity=".55" /><path d="M3 18.5 4.6 12.4A2 2 0 0 1 6.5 11H21a1 1 0 0 1 1 1.3l-1.6 5.7A2 2 0 0 1 18.5 19.5H5a2 2 0 0 1-2-1z" />
                    </svg>
                    @if ($toggle)
                        <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" class="size-4 shrink-0 text-chart-3" x-show="!isOpen({{ $itemExpr }})" x-cloak>
                            <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
                        </svg>
                    @endif
                @else
                    @switch($fileIcon)
                        @case('code')
                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-4 shrink-0 text-muted-foreground">
                                <polyline points="16 18 22 12 16 6" />
                                <polyline points="8 6 2 12 8 18" />
                            </svg>
                            @break
                        @case('doc')
                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-4 shrink-0 text-muted-foreground">
                                <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
                                <polyline points="14 2 14 8 20 8" />
                                <line x1="8" y1="13" x2="16" y2="13" />
                                <line x1="8" y1="17" x2="14" y2="17" />
                            </svg>
                            @break
                        @case('image')
                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-4 shrink-0 text-muted-foreground">
                                <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
                                <circle cx="9" cy="9" r="2" />
                                <path d="m21 15-3.6-3.6a2 2 0 0 0-2.8 0L6 21" />
                            </svg>
                            @break
                        @default
                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-4 shrink-0 text-muted-foreground">
                                <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
                                <polyline points="14 2 14 8 20 8" />
                            </svg>
                    @endswitch
                @endif
            @endif

            {{-- Long titles truncate; the title attribute and the accessible name keep the full text. --}}
            @if ($href !== null)
                {{-- The link stretches over the row, so the whole row opens it; the toggle sits above it. --}}
                <a
                    href="{{ $href }}"
                    tabindex="-1"
                    draggable="false"
                    data-tree-link
                    data-tree-label
                    title="{{ $label }}"
                    class="min-w-0 flex-1 truncate outline-none after:absolute after:inset-0 after:rounded-md after:content-[''] {{ $hasChildren ? 'font-medium' : '' }}"
                >{{ $label }}</a>
            @else
                <span data-tree-label title="{{ $label }}" class="min-w-0 flex-1 truncate {{ $hasChildren ? 'font-medium' : '' }}">{{ $label }}</span>
            @endif
        @endif

        @if (! is_null($item['meta'] ?? null))
            <span data-slot="indented-tree-meta" class="ms-auto shrink-0 text-xs tabular-nums text-muted-foreground">{{ $item['meta'] }}</span>
        @endif
    </div>

    @if ($branchable)
        {{-- The guide line sits under the parent's marker; children indent past it. --}}
        <ul
            role="group"
            data-slot="indented-tree-group"
            class="ms-4 border-s border-border ps-2"
            @if ($toggle || $movable) x-show="groupVisible({{ $itemExpr }})" @if (! $open || ! $hasChildren) x-cloak @endif @endif
        >
            @foreach ($children as $index => $child)
                <x-ui.indented-tree.node
                    :item="$child"
                    :depth="$depth + 1"
                    :path="$path.'.'.$index"
                    :current="$current"
                    :movable="$movable"
                    :variant="$variant"
                    :name="$name"
                    :collapsible="$collapsible"
                    :open-depth="$openDepth"
                />
            @endforeach
        </ul>
    @endif
</li>
resources/js/ui/indented-tree.js JS
/**
 * Indented Tree behaviour — the ARIA APG treeview keyboard model, plus moves.
 *
 *   ArrowDown / ArrowUp   move to the next / previous VISIBLE treeitem
 *   ArrowRight            expand a collapsed branch, else enter its first child
 *   ArrowLeft             collapse an expanded branch, else go to its parent
 *                         (Right and Left swap under dir="rtl")
 *   Home / End            first / last visible treeitem
 *   a letter              the next visible treeitem whose label starts with the
 *                         typed text (type-ahead, 500 ms buffer)
 *   *                     expand every sibling branch of the focused treeitem
 *   Enter                 open the row's link, else toggle the checkbox or branch
 *   Space                 toggle the checkbox or branch
 *
 * The tree is a single tab stop (roving tabindex across the treeitems). The
 * chevron button, the permission checkbox and a row's link stay clickable but
 * leave the tab order; the keyboard model reaches them.
 *
 * Open state lives here, in `open` (key -> bool), and the markup binds to it
 * (expanded(), isOpen(), groupVisible(), isBranch()). A row's key is its `id`,
 * else its position path. Keeping it in one reactive map means a Livewire
 * morph re-evaluates the bindings instead of resetting the branches, and a
 * `data-persist` key remembers them in localStorage.
 *
 * A `data-movable` tree also moves rows:
 *
 *   Alt+ArrowUp / Down    before the previous / after the next sibling
 *   Alt+ArrowRight        into the previous sibling, as its last child
 *   Alt+ArrowLeft         out of the parent, right after it
 *                         (Right and Left swap under dir="rtl")
 *   pointer drag          drop before, after or inside a row; Escape cancels
 *
 * Every move dispatches a bubbling, cancelable `tree-move` event with
 * { id, key, parentId, index, fromParentId, fromIndex } before the DOM
 * changes (preventDefault() keeps the row where it was), then announces the
 * new place through the live region. A row never moves into its own subtree.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
const DRAG_THRESHOLD = 4;
const TYPEAHEAD_MS = 500;
const STORAGE_PREFIX = 'brok:indented-tree:';

document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiIndentedTree', () => ({
        open: {},
        revision: 0,
        status: '',
        drop: null,
        _root: null,
        _focused: null,
        _observer: null,
        _strings: {},
        _typed: '',
        _typedAt: 0,
        _press: null,
        _drag: null,
        _suppressClick: false,
        _onPointerMove: null,
        _onPointerUp: null,
        _onDragKey: null,

        init() {
            // Handlers run with $el set to the element that fired them, so
            // every query starts from the root kept here.
            this._root = this.$el;
            try {
                this._strings = JSON.parse(this._root.dataset.strings || '{}');
            } catch {
                this._strings = {};
            }
            this.open = { ...this.readStored() };
            // The row for the page on screen is never hidden in a closed branch.
            const current = this._root.querySelector('[role="treeitem"][aria-current="page"]');
            for (let parent = this.parentItem(current); parent; parent = this.parentItem(parent)) {
                this.open[parent.dataset.key] = true;
            }
            this._onPointerMove = (event) => this.onPointerMove(event);
            this._onPointerUp = (event) => this.onPointerUp(event);
            this._onDragKey = (event) => {
                if (event.key === 'Escape' && this._drag) {
                    event.preventDefault();
                    this.endDrag(false);
                    this.announce(this._strings.moveCancelled || '');
                }
            };
            this.$nextTick(() => this.syncTabStop(current));
            // A server re-render (a Livewire morph after a move) can reset the
            // tab stop and move the focused row: repair both afterwards.
            this._observer = new MutationObserver((records) => this.repair(records));
            this._observer.observe(this._root, { childList: true, subtree: true, attributes: true, attributeFilter: ['tabindex'] });
        },

        destroy() {
            this.stopListening();
            this._observer?.disconnect();
        },

        repair(records) {
            const focused = this._focused;
            const lost = focused && document.activeElement === document.body
                && records.some((record) => [...record.removedNodes].some((node) => node === focused || node.contains?.(focused)));
            const rows = Array.from(this._root.querySelectorAll('[role="treeitem"]'));
            const stops = rows.filter((row) => row.tabIndex === 0);
            if (lost) {
                const again = rows.find((row) => row.dataset.key === focused.dataset.key);
                if (again) this.focusItem(again);
            } else if (stops.length !== 1 && rows.length > 0) {
                const active = this.itemFor(document.activeElement);
                this.syncTabStop(active && this._root.contains(active) ? active : this._root.querySelector('[role="treeitem"][aria-current="page"]'));
            }
        },

        /* ------------------------------------------------------------ state */

        tree() {
            return this._root.querySelector('[role="tree"]');
        },

        movable() {
            return this._root.dataset.movable === 'true';
        },

        collapsible() {
            return this._root.dataset.collapsible !== 'false';
        },

        storageKey() {
            return this._root.dataset.persist ? STORAGE_PREFIX + this._root.dataset.persist : null;
        },

        readStored() {
            const key = this.storageKey();
            if (!key) return {};
            try {
                const stored = JSON.parse(window.localStorage.getItem(key) || '{}');
                return stored && typeof stored === 'object' ? stored : {};
            } catch {
                return {};
            }
        },

        store() {
            const key = this.storageKey();
            if (!key) return;
            try {
                window.localStorage.setItem(key, JSON.stringify(this.open));
            } catch {
                /* Storage can be full or blocked; the tree still works. */
            }
        },

        /** A treeitem is a branch while its group holds treeitems. */
        isBranch(item) {
            void this.revision;
            return this.childItems(item).length > 0;
        },

        isOpen(item) {
            if (!item) return false;
            if (!this.collapsible()) return true;
            const key = item.dataset.key;
            return key in this.open ? !!this.open[key] : item.dataset.open !== 'false';
        },

        /** aria-expanded: only a branch has one. */
        expanded(item) {
            return this.isBranch(item) ? String(this.isOpen(item)) : null;
        },

        groupVisible(item) {
            return this.isBranch(item) && this.isOpen(item);
        },

        setOpen(item, open) {
            if (!item || !this.collapsible() || !this.isBranch(item)) return;
            this.open = { ...this.open, [item.dataset.key]: open };
            this.store();
        },

        toggleItem(item) {
            this.setOpen(item, !this.isOpen(item));
        },

        /* ---------------------------------------------------------- walking */

        /** Every treeitem that is not inside a collapsed branch. */
        items() {
            return Array.from(this._root.querySelectorAll('[role="treeitem"]')).filter(
                (item) => item.offsetParent !== null || item.getClientRects().length > 0
            );
        },

        /** The tree keeps exactly one tab stop. */
        syncTabStop(active = null) {
            const items = this.items();
            if (items.length === 0) {
                return;
            }
            const target = items.includes(active)
                ? active
                : items.find((item) => item.tabIndex === 0) ?? items[0];

            Array.from(this._root.querySelectorAll('[role="treeitem"]')).forEach((item) => {
                item.tabIndex = item === target ? 0 : -1;
            });
        },

        focusItem(item) {
            if (!item) {
                return;
            }
            this.syncTabStop(item);
            item.focus();
        },

        /** The treeitem the key press belongs to. */
        itemFor(target) {
            return target instanceof Element ? target.closest('[role="treeitem"]') : null;
        },

        childItems(item) {
            const group = item?.querySelector(':scope > [role="group"]');

            return group ? Array.from(group.querySelectorAll(':scope > [role="treeitem"]')) : [];
        },

        parentItem(item) {
            return item?.parentElement?.closest('[role="treeitem"]') ?? null;
        },

        siblings(item) {
            return item ? Array.from(item.parentElement.querySelectorAll(':scope > [role="treeitem"]')) : [];
        },

        labelOf(item) {
            return (item?.querySelector(':scope > [data-slot="indented-tree-row"] [data-tree-label]')?.textContent ?? '').trim();
        },

        rtl() {
            return getComputedStyle(this._root).direction === 'rtl';
        },

        move(item, delta) {
            const items = this.items();
            const index = items.indexOf(item);
            if (index === -1) {
                return;
            }
            const next = items[index + delta];
            if (next) {
                this.focusItem(next);
            }
        },

        /** Enter opens the row's link; Enter and Space toggle a checkbox or branch. */
        activate(item, key) {
            const link = item?.querySelector(':scope > [data-slot="indented-tree-row"] [data-tree-link]');
            if (key === 'Enter' && link) {
                link.click();

                return;
            }
            const checkbox = item?.querySelector(':scope > [data-slot="indented-tree-row"] input[type="checkbox"]');
            if (checkbox) {
                checkbox.click();

                return;
            }
            if (this.isBranch(item)) {
                this.toggleItem(item);
            }
        },

        typeAhead(item, char) {
            const now = Date.now();
            this._typed = now - this._typedAt > TYPEAHEAD_MS ? char : this._typed + char;
            this._typedAt = now;
            const items = this.items();
            const start = items.indexOf(item);
            // A repeated single letter cycles; a longer prefix keeps the current row if it matches.
            const offset = this._typed.length > 1 ? 0 : 1;
            const ordered = [...items.slice(start + offset), ...items.slice(0, start + offset)];
            const needle = this._typed.toLocaleLowerCase();
            const match = ordered.find((candidate) => this.labelOf(candidate).toLocaleLowerCase().startsWith(needle));
            if (match) {
                this.focusItem(match);
            }
        },

        onFocusIn(event) {
            const item = this.itemFor(event.target);
            if (item) {
                this._focused = item;
                this.syncTabStop(item);
            }
        },

        onKey(event) {
            const item = this.itemFor(event.target);
            if (!item || event.target.closest('[role="tree"]') !== this.tree()) {
                return;
            }
            const [forward, backward] = this.rtl() ? ['ArrowLeft', 'ArrowRight'] : ['ArrowRight', 'ArrowLeft'];

            if (event.altKey && !event.ctrlKey && !event.metaKey && this.movable()) {
                const moves = { ArrowUp: 'up', ArrowDown: 'down', [forward]: 'in', [backward]: 'out' };
                if (moves[event.key]) {
                    event.preventDefault();
                    this.keyboardMove(item, moves[event.key]);
                }

                return;
            }
            if (event.ctrlKey || event.metaKey || event.altKey) {
                return;
            }

            const items = this.items();
            if (event.key.length !== 1) {
                // Any other key ends a type-ahead word.
                this._typed = '';
            }

            switch (event.key) {
                case 'ArrowDown':
                    event.preventDefault();
                    this.move(item, 1);
                    break;
                case 'ArrowUp':
                    event.preventDefault();
                    this.move(item, -1);
                    break;
                case forward:
                    event.preventDefault();
                    if (this.isBranch(item) && !this.isOpen(item)) {
                        this.setOpen(item, true);
                        this.$nextTick(() => this.syncTabStop(item));
                    } else if (this.isBranch(item)) {
                        this.focusItem(this.childItems(item)[0]);
                    }
                    break;
                case backward:
                    event.preventDefault();
                    if (this.isBranch(item) && this.isOpen(item) && this.collapsible()) {
                        this.setOpen(item, false);
                    } else {
                        this.focusItem(this.parentItem(item));
                    }
                    break;
                case 'Home':
                    event.preventDefault();
                    this.focusItem(items[0]);
                    break;
                case 'End':
                    event.preventDefault();
                    this.focusItem(items[items.length - 1]);
                    break;
                case 'Enter':
                case ' ':
                    event.preventDefault();
                    this.activate(item, event.key);
                    break;
                case '*':
                    event.preventDefault();
                    this.siblings(item).forEach((sibling) => this.setOpen(sibling, true));
                    break;
                default:
                    if (event.key.length === 1 && /\S/u.test(event.key)) {
                        event.preventDefault();
                        this.typeAhead(item, event.key);
                    }
                    break;
            }
        },

        /* ------------------------------------------------------------ moves */

        /** The group a row moves into: its child group (a movable row always has one). */
        groupOf(item) {
            return item ? item.querySelector(':scope > [role="group"]') : this.tree();
        },

        keyboardMove(item, direction) {
            const siblings = this.siblings(item);
            const index = siblings.indexOf(item);
            const parent = this.parentItem(item);
            let target = null;
            if (direction === 'up' && index > 0) {
                target = { parent, index: index - 1 };
            } else if (direction === 'down' && index < siblings.length - 1) {
                target = { parent, index: index + 1 };
            } else if (direction === 'in' && index > 0 && this.groupOf(siblings[index - 1])) {
                const into = siblings[index - 1];
                target = { parent: into, index: this.childItems(into).length };
            } else if (direction === 'out' && parent) {
                target = { parent: this.parentItem(parent), index: this.siblings(parent).indexOf(parent) + 1 };
            }
            if (!target) {
                this.announce(this.format(this._strings.cannotMove, { label: this.labelOf(item) }));

                return;
            }
            if (this.moveTo(item, target.parent, target.index)) {
                this.focusItem(item);
            }
        },

        /**
         * Move a row to `index` among the children of `parent` (null = top level),
         * counted after the row has left its old place. Returns true when it moved.
         */
        moveTo(item, parent, index) {
            if (!item || (parent && (parent === item || item.contains(parent)))) {
                return false;
            }
            const group = this.groupOf(parent);
            if (!group) {
                return false;
            }
            const fromParent = this.parentItem(item);
            const fromIndex = this.siblings(item).indexOf(item);
            if (fromParent === parent && fromIndex === index) {
                return false;
            }
            const detail = {
                id: item.dataset.id ?? null,
                key: item.dataset.key,
                parentId: parent ? (parent.dataset.id ?? null) : null,
                index,
                fromParentId: fromParent ? (fromParent.dataset.id ?? null) : null,
                fromIndex,
            };
            const allowed = this._root.dispatchEvent(new CustomEvent('tree-move', { detail, bubbles: true, cancelable: true }));
            if (!allowed) {
                return false;
            }
            const hadFocus = item.contains(document.activeElement);
            const rest = Array.from(group.querySelectorAll(':scope > [role="treeitem"]')).filter((row) => row !== item);
            group.insertBefore(item, rest[index] ?? null);
            if (parent) {
                this.open = { ...this.open, [parent.dataset.key]: true };
                this.store();
            }
            this.relevel();
            this.revision++;
            if (hadFocus) {
                this.focusItem(item);
            }
            const total = this.siblings(item).length;
            const position = this.siblings(item).indexOf(item) + 1;
            this.announce(parent
                ? this.format(this._strings.movedInto, { label: this.labelOf(item), parent: this.labelOf(parent), position, total })
                : this.format(this._strings.movedTop, { label: this.labelOf(item), position, total }));

            return true;
        },

        /** aria-level follows the nesting after a move. */
        relevel() {
            this._root.querySelectorAll('[role="treeitem"]').forEach((row) => {
                let level = 1;
                for (let parent = this.parentItem(row); parent; parent = this.parentItem(parent)) level++;
                row.setAttribute('aria-level', String(level));
            });
        },

        format(template, values) {
            return String(template || '').replace(/:(\w+)/g, (match, name) => (name in values ? String(values[name]) : match));
        },

        announce(message) {
            // Clear first so the same sentence twice is still read out.
            this.status = '';
            this.$nextTick(() => {
                this.status = message;
            });
        },

        /* ------------------------------------------------------------- drag */

        onPointerDown(event) {
            if (!this.movable() || event.button !== 0 || event.pointerType === 'touch') return;
            if (event.target.closest('[data-tree-toggle], input, button:not([data-tree-link])')) return;
            const row = event.target.closest('[data-slot="indented-tree-row"]');
            const item = this.itemFor(row);
            if (!row || !item) return;
            this._press = { item, x: event.clientX, y: event.clientY };
            window.addEventListener('pointermove', this._onPointerMove);
            window.addEventListener('pointerup', this._onPointerUp);
            window.addEventListener('pointercancel', this._onPointerUp);
            window.addEventListener('keydown', this._onDragKey, true);
        },

        onPointerMove(event) {
            if (!this._press) return;
            if (!this._drag) {
                if (Math.hypot(event.clientX - this._press.x, event.clientY - this._press.y) < DRAG_THRESHOLD) return;
                this._drag = { item: this._press.item, target: null };
                this._press.item.setAttribute('data-dragging', 'true');
            }
            event.preventDefault();
            window.getSelection()?.removeAllRanges();
            this.trackDrop(event.clientX, event.clientY);
        },

        onPointerUp(event) {
            const dragged = !!this._drag;
            if (dragged) {
                event.preventDefault();
                this._suppressClick = true;
                setTimeout(() => {
                    this._suppressClick = false;
                }, 0);
            }
            this.endDrag(dragged && event.type === 'pointerup');
        },

        /** A click that ends a drag never follows the row's link. */
        onClickCapture(event) {
            if (this._suppressClick) {
                event.preventDefault();
                event.stopPropagation();
                this._suppressClick = false;
            }
        },

        /** The drop target under the pointer: a row and before | after | inside. */
        trackDrop(x, y) {
            const drag = this._drag;
            const under = document.elementFromPoint(x, y);
            const row = under?.closest('[data-slot="indented-tree-row"]');
            const item = this.itemFor(row);
            this.clearDropMark();
            if (!row || !item || !this.tree()?.contains(row) || item === drag.item || drag.item.contains(item)) {
                drag.target = null;
                this.drop = null;

                return;
            }
            const box = row.getBoundingClientRect();
            const ratio = (y - box.top) / box.height;
            let zone = ratio < 0.25 ? 'before' : ratio > 0.75 ? 'after' : 'inside';
            let anchor = item;
            // Below an open branch, "after" reads as the first child.
            if (zone === 'after' && this.groupVisible(item)) {
                anchor = this.childItems(item)[0];
                zone = 'before';
            }
            if (anchor === drag.item) {
                drag.target = null;
                this.drop = null;

                return;
            }
            drag.target = { item: anchor, zone };
            if (zone === 'inside') {
                row.setAttribute('data-drop', 'inside');
                this.drop = { zone, top: 0, inset: 0 };

                return;
            }
            const anchorRow = anchor.querySelector(':scope > [data-slot="indented-tree-row"]');
            const anchorBox = anchorRow.getBoundingClientRect();
            const host = this._root.getBoundingClientRect();
            const inset = this.rtl() ? host.right - anchorBox.right : anchorBox.left - host.left;
            this.drop = {
                zone,
                top: (zone === 'before' ? anchorBox.top : anchorBox.bottom) - host.top,
                inset: Math.max(0, inset),
            };
        },

        clearDropMark() {
            this._root.querySelectorAll('[data-drop]').forEach((row) => row.removeAttribute('data-drop'));
        },

        endDrag(commit) {
            const drag = this._drag;
            this.stopListening();
            this._press = null;
            this._drag = null;
            this.clearDropMark();
            this.drop = null;
            if (!drag) return;
            drag.item.removeAttribute('data-dragging');
            if (!commit || !drag.target) return;
            const { item: anchor, zone } = drag.target;
            if (zone === 'inside') {
                this.moveTo(drag.item, anchor, this.childItems(anchor).filter((row) => row !== drag.item).length);
            } else {
                const parent = this.parentItem(anchor);
                const rest = this.siblings(anchor).filter((row) => row !== drag.item);
                const at = rest.indexOf(anchor);
                this.moveTo(drag.item, parent, zone === 'before' ? at : at + 1);
            }
            this.focusItem(drag.item);
        },

        stopListening() {
            window.removeEventListener('pointermove', this._onPointerMove);
            window.removeEventListener('pointerup', this._onPointerUp);
            window.removeEventListener('pointercancel', this._onPointerUp);
            window.removeEventListener('keydown', this._onDragKey, true);
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
3.0.2
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
2026-09-30
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0

Migration history

  • 2.1.1 → 3.0.0 breaking — The root is now a div wrapper around ul[role=tree]; the accessible-name attributes go to the tree, and Right/Left follow the APG under RTL.