Skip to content
Brok UI

Loading…

No results

Block Handle

Open source

Gutter control cluster rendered beside an editor block. Provides an insert-below shortcut and a drag handle that opens the block menu (turn-into-menu's block-type list plus move, duplicate and delete). Composed as <x-ui.editor.block-handle>; <x-ui.block-handle> stays as the compatibility tag.

Version
v1.4.2
Stability
stable
License
MIT
Related
Editor
Editor Toolbar
Drop Indicator

Preview

Adding page…

previews.components.block-handle.default.blade.php Blade
<x-ui.editor class="mx-auto max-w-xl" placeholder="Write something…">
    <x-slot:tools>
        <x-ui.editor.block-handle />
        <x-ui.editor.drop-indicator />
    </x-slot:tools>

    <h2>Hover a block</h2>
    <p>The handle appears in the gutter beside the block under the pointer: the plus adds a paragraph below it, the grip opens the block menu (turn into, move, duplicate, delete).</p>
    <p>Press and drag the grip to move a block; the drop indicator marks the gap it will land in.</p>
    <ul><li>First item</li><li>Second item</li></ul>
</x-ui.editor>

Installation

terminal
php artisan ui:add block-handle

Note

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

resources/js/ui/index.js JS
import './block-handle.js';

Registry contract

php artisan ui:add block-handle 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/editor/block-handle.blade.php
  • blade resources/views/components/ui/block-handle.blade.php
Registry dependencies
dropdown turn-into-menu editor
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.

block-handle.md
# Brok UI: Block Handle (`block-handle`)

Gutter control cluster rendered beside an editor block. Provides an insert-below shortcut and a drag handle that opens the block menu (turn-into-menu's block-type list plus move, duplicate and delete). Composed as <x-ui.editor.block-handle>; <x-ui.block-handle> stays as the compatibility tag.

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

## Install

```bash
php artisan ui:add block-handle
```

## Usage

```blade
<x-ui.editor class="mx-auto max-w-xl" placeholder="Write something…">
    <x-slot:tools>
        <x-ui.editor.block-handle />
        <x-ui.editor.drop-indicator />
    </x-slot:tools>

    <h2>Hover a block</h2>
    <p>The handle appears in the gutter beside the block under the pointer: the plus adds a paragraph below it, the grip opens the block menu (turn into, move, duplicate, delete).</p>
    <p>Press and drag the grip to move a block; the drop indicator marks the gap it will land in.</p>
    <ul><li>First item</li><li>Second item</li></ul>
</x-ui.editor>
```

## Use when

- Use when users need rich structured authoring, annotation, or collaborative editing controls.
- Giving each block in the editor a gutter control for inserting, reordering by drag, or opening the block menu (turn into, move, duplicate, delete).

## Avoid when

- Do not use rich editor affordances for simple one-line or plain-text tasks.
- The host is not a contenteditable editor surface; block-level drag-and-drop belongs to sortable instead.

## Anti-patterns

- Rich editors for one-line or easily validated input

## Rules

- Use the `<brok:block-handle>` tag (or `<x-ui.block-handle>`) 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/block-handle
- Registry JSON (files, props, contract): https://brokui.dev/r/open/block-handle.json

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

API

manifest knowledge + registry-derived coverage

Props

No documented props. Pass normal Blade attributes through the component root.

Slots

  • default —
  • x-ui.editor.block-handle — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

block-handle

Behavior

  • Stamps data-gutter on the nearest ancestor editor root, which opens a start gutter for it, and follows the hovered block through the editor's shared handle state.
  • The grip's click opens turn-into-menu's block menu; a press-and-drag instead reorders the block, with drop-indicator marking the target gap.
  • Standalone, outside an editor, it renders inline and its dispatched events bubble to nothing, so it stays harmless.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Rich authoring and editing

Create or revise structured rich content.

Use when

  • Use when users need rich structured authoring, annotation, or collaborative editing controls.
  • Giving each block in the editor a gutter control for inserting, reordering by drag, or opening the block menu (turn into, move, duplicate, delete).

Avoid when

  • Do not use rich editor affordances for simple one-line or plain-text tasks.
  • The host is not a contenteditable editor surface; block-level drag-and-drop belongs to sortable instead.

Use instead

  • Textarea or plain field for simple content

Anti-patterns

  • Rich editors for one-line or easily validated input
Anatomy
root
Theming hooks
block-handle

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
native
Focus
native
  • Every control uses @mousedown.prevent so clicking the handle never blurs the contenteditable region and drops the current selection.
  • 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="block-handle-{{ $record->id }}">
    <x-ui.editor class="mx-auto max-w-xl" placeholder="Write something…">
        <x-slot:tools>
            <x-ui.editor.block-handle />
            <x-ui.editor.drop-indicator />
        </x-slot:tools>
    
        <h2>Hover a block</h2>
        <p>The handle appears in the gutter beside the block under the pointer: the plus adds a paragraph below it, the grip opens the block menu (turn into, move, duplicate, delete).</p>
        <p>Press and drag the grip to move a block; the drop indicator marks the gap it will land in.</p>
        <ul><li>First item</li><li>Second item</li></ul>
    </x-ui.editor>
</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:block-handle
        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/editor/block-handle.blade.php Blade
@props([])

{{--
    Block Handle — the gutter control cluster beside a content block in an
    <x-ui.editor>. Two controls:

      1. "+" — inserts a fresh paragraph after the block.
      2. Grip — click opens the block menu (turn into, move, duplicate, delete);
         press and drag reorders the block, with <x-ui.editor.drop-indicator> marking
         the gap it will land in.

    Inside an editor it stamps `data-gutter` on the editor root (which opens a
    start gutter for it) and follows the hovered block: the editor's `handle`
    state carries the block's offset box. Standalone (no editor ancestor) it
    renders inline and its events bubble to nothing — harmless.

    All interactive controls use @mousedown.prevent so clicking them never blurs
    the contenteditable region and drops the current selection.
--}}
<div
    data-slot="block-handle"
    x-data="{
        inEditor: false,
        dragged: false,
        init() {
            const editor = this.$el.closest('[data-slot=&quot;editor&quot;]');
            if (editor) {
                editor.setAttribute('data-gutter', '');
                this.inEditor = true;
            }
        },
        onGripDown(event) {
            if (event.button !== 0) return;
            this.dragged = false;
            const onMove = () => { this.dragged = true; };
            window.addEventListener('pointermove', onMove, { once: true });
            window.addEventListener('pointerup', () => window.removeEventListener('pointermove', onMove), { once: true });
            this.$dispatch('editor-block-drag', { event });
        },
    }"
    x-bind:class="inEditor ? 'absolute start-2 z-10 transition-[top,opacity] duration-150 ease-out motion-reduce:transition-none' : 'relative'"
    x-bind:style="inEditor && typeof handle !== 'undefined' ? `top:${handle.top + Math.max(0, (Math.min(handle.height, 28) - 24) / 2)}px; opacity:${handle.visible ? 1 : 0}; pointer-events:${handle.visible ? 'auto' : 'none'}` : ''"
    {{ $attributes->merge(['class' => 'inline-flex items-center gap-0.5']) }}
>
    {{-- ── 1. Insert block button ─────────────────────────────────────────── --}}
    <button
        type="button"
        aria-label="{{ __('Insert block below') }}"
        @mousedown.prevent=""
        @click="$dispatch('editor-block-action', { action: 'insert-after' })"
        class="inline-flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
    >
        {{-- Plus icon --}}
        <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <line x1="12" y1="5" x2="12" y2="19"/>
            <line x1="5" y1="12" x2="19" y2="12"/>
        </svg>
    </button>

    {{-- ── 2. Grip: drag to reorder, click for the block menu ─────────────── --}}
    {{-- The menu is <x-ui.editor.turn-into-menu>, which owns the block-type list; the
         grip is its trigger and the block-level actions follow the list. --}}
    <x-ui.editor.turn-into-menu content-class="w-52">
        <x-slot:trigger>
            <x-ui.dropdown.trigger
                aria-label="{{ __('Block options (drag to move)') }}"
                @mousedown.prevent=""
                @pointerdown="onGripDown($event)"
                @click.capture="if (dragged) { $event.stopImmediatePropagation(); dragged = false }"
                class="inline-flex size-6 cursor-grab touch-none items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring active:cursor-grabbing"
            >
                {{-- Grip-vertical icon --}}
                <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                    <circle cx="9" cy="5" r="1" fill="currentColor" stroke="none"/>
                    <circle cx="9" cy="12" r="1" fill="currentColor" stroke="none"/>
                    <circle cx="9" cy="19" r="1" fill="currentColor" stroke="none"/>
                    <circle cx="15" cy="5" r="1" fill="currentColor" stroke="none"/>
                    <circle cx="15" cy="12" r="1" fill="currentColor" stroke="none"/>
                    <circle cx="15" cy="19" r="1" fill="currentColor" stroke="none"/>
                </svg>
            </x-ui.dropdown.trigger>
        </x-slot:trigger>

        <x-ui.dropdown.separator />

        {{-- ── Block-level actions ─────────────────────────────────────── --}}
        <x-ui.dropdown.item type="button" @mousedown.prevent="" @click="$dispatch('editor-block-action', { action: 'move-up' })">
            <svg class="size-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 7-7 7 7M12 19V5"/></svg>
            {{ __('Move up') }}
        </x-ui.dropdown.item>
        <x-ui.dropdown.item type="button" @mousedown.prevent="" @click="$dispatch('editor-block-action', { action: 'move-down' })">
            <svg class="size-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14m7-7-7 7-7-7"/></svg>
            {{ __('Move down') }}
        </x-ui.dropdown.item>
        <x-ui.dropdown.item type="button" @mousedown.prevent="" @click="$dispatch('editor-block-action', { action: 'duplicate' })">
            <svg class="size-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
                <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
            </svg>
            {{ __('Duplicate') }}
        </x-ui.dropdown.item>

        <x-ui.dropdown.separator />

        <x-ui.dropdown.item
            type="button"
            @mousedown.prevent=""
            @click="$dispatch('editor-block-action', { action: 'delete' })"
            class="text-destructive focus:bg-destructive/10 focus:text-destructive hover:bg-destructive/10 hover:text-destructive"
        >
            <svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                <polyline points="3 6 5 6 21 6"/>
                <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
                <path d="M10 11v6"/>
                <path d="M14 11v6"/>
                <path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
            </svg>
            {{ __('Delete') }}
        </x-ui.dropdown.item>
    </x-ui.editor.turn-into-menu>
</div>
resources/views/components/ui/block-handle.blade.php Blade
{{-- <x-ui.block-handle> is the install slug's compatibility tag: the part lives at
     <x-ui.editor.block-handle> (ui/editor/block-handle) with every other editor part.
     Compose the namespaced tag in new code; this file only forwards to it. --}}
@props([])

<x-ui.editor.block-handle {{ $attributes }} />

Ownership & lifecycle

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