Skip to content
Brok UI

Loading…

No results

Inline Edit

Open source

A value shown as text in the host typography that edits in place: a record title, a client name or a description. Click, Enter, Space or F2 opens a matching input (or an auto-growing textarea), Enter or blur saves, Escape cancels, with required and maxlength checks, a flush variant for record headers that keeps its shape while editing, edit on focus, x-model and wire:model on commit, a Livewire save method, a hidden input for plain forms and error-bag validation.

Version
v1.1.1
Stability
stable
License
MIT
Related
Editable Card
Input
Textarea
Field

Preview

Client
Reference
Size
Variant
previews.components.inline-edit.default.blade.php Blade
{{-- A record header: the title is a heading that edits in place, and the
     fields below edit the same way. `-mx-2` lines the text up with the
     content around it while the hover tint reaches into the gutter. --}}
<div class="w-full max-w-2xl space-y-4">
    <x-ui.inline-edit
        as="h2"
        size="2xl"
        name="title"
        :label="__('Title')"
        :value="__('Website relaunch')"
        required
        maxlength="80"
        class="-mx-2 font-semibold"
    />

    <dl class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-6 gap-y-2 text-sm">
        <dt class="text-muted-foreground">{{ __('Client') }}</dt>
        <dd class="min-w-0">
            <x-ui.inline-edit name="client" :label="__('Client')" :value="__('Northwind Traders')" class="-mx-2" />
        </dd>
        <dt class="text-muted-foreground">{{ __('Reference') }}</dt>
        <dd class="min-w-0">
            <x-ui.inline-edit name="reference" :label="__('Reference')" class="-mx-2" />
        </dd>
    </dl>
</div>

Size options

Inherit Current
Sm Current
Base Current
Lg Current
Xl Current
2xl Current
3xl Current

Variant options

Default Current
Flush Current

Installation

terminal
php artisan ui:add inline-edit

Note

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

resources/js/ui/index.js JS
import './inline-edit.js';

Registry contract

php artisan ui:add inline-edit 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/inline-edit.blade.php
  • js resources/js/ui/inline-edit.js
Registry dependencies
None — installs on its own.
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.

inline-edit.md
# Brok UI: Inline Edit (`inline-edit`)

A value shown as text in the host typography that edits in place: a record title, a client name or a description. Click, Enter, Space or F2 opens a matching input (or an auto-growing textarea), Enter or blur saves, Escape cancels, with required and maxlength checks, a flush variant for record headers that keeps its shape while editing, edit on focus, x-model and wire:model on commit, a Livewire save method, a hidden input for plain forms and error-bag validation.

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

## Install

```bash
php artisan ui:add inline-edit
```

## Usage

```blade
{{-- A record header: the title is a heading that edits in place, and the
     fields below edit the same way. `-mx-2` lines the text up with the
     content around it while the hover tint reaches into the gutter. --}}
<div class="w-full max-w-2xl space-y-4">
    <x-ui.inline-edit
        as="h2"
        size="2xl"
        name="title"
        :label="__('Title')"
        :value="__('Website relaunch')"
        required
        maxlength="80"
        class="-mx-2 font-semibold"
    />

    <dl class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-6 gap-y-2 text-sm">
        <dt class="text-muted-foreground">{{ __('Client') }}</dt>
        <dd class="min-w-0">
            <x-ui.inline-edit name="client" :label="__('Client')" :value="__('Northwind Traders')" class="-mx-2" />
        </dd>
        <dt class="text-muted-foreground">{{ __('Reference') }}</dt>
        <dd class="min-w-0">
            <x-ui.inline-edit name="reference" :label="__('Reference')" class="-mx-2" />
        </dd>
    </dl>
</div>
```

## Props

- `value` (string|null, default `null`) — The stored value. With a name, old() replaces it after a redirect-back; with a validation error for the name, old() becomes the draft and the field opens in edit mode.
- `name` (string|null, default `null`) — Renders a hidden input with this name that carries the committed value for a plain form post, and is the error-bag key and the event name. Without it, the wire:model target is the error-bag key.
- `label` (string|null, default `null`) — What the value is ("Title", "Client"). Names the input and the display button ("Edit Title: Website relaunch"). Defaults to a translated "Value".
- `as` (span|p|div|h1|h2|h3|h4|h5|h6, default `span`) — The element that holds the text in both modes, so a heading stays a heading. Always a block box.
- `size` (inherit|sm|base|lg|xl|2xl|3xl, default `inherit`) — Font size from the type scale. inherit keeps the host size; set weight and colour with a class on the component.
- `variant` (default|flush, default `default`) — default keeps the box inside the host's text column. flush is for a record header: the text lines up with the text around it (the box reaches 8px into the gutter), the display fills the row and the field keeps the pencil's end gutter, so a wrapped title keeps its line breaks and height while editing. Accepts a string, a backed enum or a Stringable.
- `multiline` (bool, default `false`) — Edits in a textarea that grows with its content. Enter saves, Shift+Enter inserts a new line, and the display keeps line breaks.
- `placeholder` (string|null, default `null`) — Muted text shown while the value is empty. Defaults to a translated "Untitled" for a heading and "Add a value" otherwise.
- `required` (bool, default `false`) — An empty value does not save: the field stays in edit mode with the required message.
- `maxlength` (int|null, default `null`) — A longer value does not save: the field stays in edit mode with the maxlength message. Typing is not cut off.
- `trim` (bool, default `true`) — Trims leading and trailing whitespace before the value is checked and saved. Set false to keep it.
- `selectOnEdit` (bool, default `true`) — Selects the text when edit mode opens. Set false to put the caret at the end instead.
- `saveMethod` (string|null, default `null`) — Inside Livewire, calls $wire[saveMethod](value) on save and waits for it, with the field readonly and aria-busy. A rejection, a false result or a validation error for the name keeps the field open with the message; a string result becomes the committed value.
- `editable` (bool, default `true`) — false renders the value as plain text in the same box, with no button, hover tint or pencil.
- `editOnFocus` (bool, default `false`) — Opens edit mode when the display button gets focus (Tab or a click), as a field would. Focus that the control moves back after Enter, Escape or a save does not reopen it.
- `icon` (bool, default `true`) — Shows a pencil after the text on hover and keyboard focus, and always on touch screens.
- `messages` (array, default `[]`) — Overrides the translated copy: required, maxlength, saved (announced after a save) and failed (shown when a save method rejects or returns false).

## Use when

- Use when users must enter freeform information that cannot be reliably selected from a list.
- A record detail page shows a title or a field (an issue title, a project name, a client name, a description) that people rename where they read it, Linear or Notion style.
- You want the value to look like text until someone edits it, and to save on Enter or blur without a separate form and Save button.
- A record header title that wraps to several lines and must line up with the page text: use variant="flush" with multiline.

## Avoid when

- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- The value is one field of a larger form that is submitted together: use <x-ui.field> with <x-ui.input> or <x-ui.textarea>.
- The text is a card of notes with a label and explicit Save and Cancel buttons: use <x-ui.editable-card>.
- The content needs formatting (bold, links, lists): use <x-ui.editor>.

## Anti-patterns

- Using freeform entry for a bounded answer set

## Rules

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

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

Examples

binding.blade.php Blade
{{-- x-model binds the committed text: the readouts change on Enter or blur,
     not on every key. Inside a Livewire component, wire:model on the same
     tag binds a string property the same way; add save-method="rename" to
     call a component method instead. --}}
<div class="w-full max-w-xl space-y-4" x-data="{ title: @js(__('Website relaunch')), value: @js(__('Northwind Traders')) }">
    <x-ui.inline-edit as="h2" size="xl" :label="__('Title')" x-model="title" class="-mx-2 font-semibold" />
    <x-ui.inline-edit :label="__('Client')" x-model="value" class="-mx-2 text-sm" />

    <p class="text-sm text-muted-foreground">
        {{ __('Title:') }} <span data-testid="bound-title" x-text="title"></span>
    </p>
    <p class="text-sm text-muted-foreground">
        {{ __('Client:') }} <span data-testid="bound-value" x-text="value"></span>
    </p>
</div>
disabled.blade.php Blade
{{-- `:editable="false"` renders the value as plain text in the same box,
     with no button, hover tint or pencil. --}}
<div class="w-full max-w-2xl space-y-4">
    <x-ui.inline-edit as="h2" size="xl" :label="__('Title')" :value="__('Website relaunch')" :editable="false" class="-mx-2 font-semibold" />
    <x-ui.inline-edit :label="__('Client')" :value="__('Northwind Traders')" :editable="false" class="-mx-2 text-sm" />
</div>
empty.blade.php Blade
{{-- Empty values show a muted placeholder: "Untitled" for a heading,
     "Add a value" otherwise, or your own text. --}}
<div class="w-full max-w-2xl space-y-4">
    <x-ui.inline-edit as="h2" size="xl" name="title" :label="__('Title')" class="-mx-2 font-semibold" />
    <x-ui.inline-edit name="client" :label="__('Client')" class="-mx-2 text-sm" />
    <x-ui.inline-edit name="notes" :label="__('Notes')" :placeholder="__('Add a note for the team')" multiline class="-mx-2 text-sm" />
</div>
invalid.blade.php Blade
@php
    // Simulate a rejected post: the redirect-back flashes the error and the
    // submitted text. The field re-opens in edit mode with that text and
    // the message; Escape restores the stored title.
    $previewErrors = new \Illuminate\Support\ViewErrorBag;
    $previewErrors->put('default', new \Illuminate\Support\MessageBag([
        'title' => [__('The title must not be greater than 40 characters.')],
    ]));
    view()->share('errors', $previewErrors);
    if (request()->hasSession()) {
        request()->session()->now('_old_input', [
            'title' => __('Website relaunch for every regional market this spring'),
        ]);
    }
@endphp

<form class="w-full max-w-2xl" method="POST" action="#" x-on:submit.prevent>
    <x-ui.inline-edit
        as="h2"
        size="2xl"
        name="title"
        :label="__('Title')"
        :value="__('Website relaunch')"
        maxlength="40"
        class="-mx-2 font-semibold"
    />
</form>
long-content.blade.php Blade
{{-- A narrow column with a long title and an unbroken reference: the text
     wraps inside the field instead of widening the page. --}}
<div class="w-full max-w-xs space-y-4">
    <x-ui.inline-edit
        as="h2"
        size="xl"
        name="title"
        :label="__('Title')"
        :value="__('Quarterly compliance review for the regional distribution centres and their subcontractors')"
        class="-mx-2 font-semibold"
    />
    <x-ui.inline-edit
        name="reference"
        :label="__('Reference')"
        value="PO-2026-NORTHWIND-TRADERS-EUROPE-DISTRIBUTION-000123456789"
        class="-mx-2 text-sm"
    />
</div>
multiline.blade.php Blade
{{-- `multiline` edits in an auto-growing textarea: Enter saves and
     Shift+Enter starts a new line. The display keeps the line breaks. --}}
@php
    $description = __('Rebuild the marketing site on the new design system.')
        .PHP_EOL.__('Move the blog and the docs to one domain before the spring campaign.');
@endphp

<div class="w-full max-w-xl">
    <x-ui.inline-edit
        as="p"
        multiline
        name="description"
        :label="__('Description')"
        :placeholder="__('Add a description')"
        :value="$description"
        class="-mx-2 text-sm"
    />
</div>
record-header.blade.php Blade
{{-- A record header: variant="flush" lines the title text up with the page
     text below it and keeps the same line breaks while editing, so nothing
     moves when the title opens. `multiline` lets a long title wrap, and
     `edit-on-focus` opens it when the keyboard reaches it. --}}
<article class="w-full max-w-2xl space-y-2">
    <a href="#" data-testid="record-back" class="text-sm text-muted-foreground underline-offset-4 hover:underline">{{ __('Website relaunch') }}</a>
    <x-ui.inline-edit
        as="h1"
        size="2xl"
        variant="flush"
        multiline
        edit-on-focus
        name="title"
        :label="__('Title')"
        :value="__('Prepare the quarterly compliance review for the regional distribution centres and their subcontractors')"
        required
        maxlength="160"
        class="font-semibold"
    />
    <p data-testid="record-meta" class="text-sm text-muted-foreground">{{ __('Task in Website relaunch, due on Friday.') }}</p>
</article>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
value string | null null The stored value. With a name, old() replaces it after a redirect-back; with a validation error for the name, old() becomes the draft and the field opens in edit mode.
name string | null null Renders a hidden input with this name that carries the committed value for a plain form post, and is the error-bag key and the event name. Without it, the wire:model target is the error-bag key.
label string | null null What the value is ("Title", "Client"). Names the input and the display button ("Edit Title: Website relaunch"). Defaults to a translated "Value".
as span | p | div | h1 | h2 | h3 | h4 | h5 | h6 span The element that holds the text in both modes, so a heading stays a heading. Always a block box.
size inherit | sm | base | lg | xl | 2xl | 3xl inherit Font size from the type scale. inherit keeps the host size; set weight and colour with a class on the component.
variant default | flush default default keeps the box inside the host's text column. flush is for a record header: the text lines up with the text around it (the box reaches 8px into the gutter), the display fills the row and the field keeps the pencil's end gutter, so a wrapped title keeps its line breaks and height while editing. Accepts a string, a backed enum or a Stringable.
multiline bool false Edits in a textarea that grows with its content. Enter saves, Shift+Enter inserts a new line, and the display keeps line breaks.
placeholder string | null null Muted text shown while the value is empty. Defaults to a translated "Untitled" for a heading and "Add a value" otherwise.
required bool false An empty value does not save: the field stays in edit mode with the required message.
maxlength int | null null A longer value does not save: the field stays in edit mode with the maxlength message. Typing is not cut off.
trim bool true Trims leading and trailing whitespace before the value is checked and saved. Set false to keep it.
selectOnEdit bool true Selects the text when edit mode opens. Set false to put the caret at the end instead.
saveMethod string | null null Inside Livewire, calls $wire[saveMethod](value) on save and waits for it, with the field readonly and aria-busy. A rejection, a false result or a validation error for the name keeps the field open with the message; a string result becomes the committed value.
editable bool true false renders the value as plain text in the same box, with no button, hover tint or pencil.
editOnFocus bool false Opens edit mode when the display button gets focus (Tab or a click), as a field would. Focus that the control moves back after Enter, Escape or a save does not reopen it.
icon bool true Shows a pencil after the text on hover and keyboard focus, and always on touch screens.
messages array [] Overrides the translated copy: required, maxlength, saved (announced after a save) and failed (shown when a save method rejects or returns false).

Slots

  • default — Not used by this component; the value comes from the value prop, x-model or wire:model.

Renders as span, p, div, h1, h2, h3, h4, h5, h6.

Data slots

Stable hooks for CSS overrides and browser tests.

inline-edit inline-edit-display inline-edit-field inline-edit-hidden inline-edit-icon inline-edit-message inline-edit-server inline-edit-status inline-edit-text

Behavior

  • Display mode shows the value (or the muted placeholder) in a button inside the `as` element. Click, Enter, Space or F2 opens edit mode: the input (or textarea) takes the same box and font, gets focus and selects the text (or puts the caret at the end with select-on-edit false).
  • variant="flush" lines the text up with the surrounding text and makes the display fill the row; the field keeps the same end gutter as the pencil, so a multiline title wraps at the same words and keeps its height when it opens. No consumer margin or width class is needed.
  • Enter and blur save. Escape cancels: the draft is dropped, edit mode closes and focus returns to the display button. A save trims the draft, checks required and maxlength (an error keeps edit mode, sets aria-invalid and a linked message), and does nothing but close when the value did not change.
  • A commit updates the x-modelable value (x-model and wire:model change only on commit, never per keystroke), writes the hidden input and fires input and change on it (or change on the root without a name), dispatches a bubbling `inline-edit-save` CustomEvent with `detail: { value, previous, name }`, announces "Saved" in a polite status region and returns focus to the display button when focus was in the field.
  • With save-method inside Livewire the control calls the method first and commits only when it resolves; a rejection, a false result or $wire.$errors for the name keeps edit mode with the message. Alpine owns the control's DOM (wire:ignore), and a Livewire re-render updates a hidden server carrier instead: a new stored value shows in display mode and a validation error re-opens the field. Without Livewire the save commits locally.
  • A validation error in the shared $errors bag for the name (or the wire:model target) renders the field in edit mode with aria-invalid, the message below it and the submitted text from old(); Escape then restores the stored value.
  • With edit-on-focus, Tab to the title opens edit mode; focus that returns to the display after Enter, Escape or a save leaves it in display mode. When focus leaves the control, a blur event is dispatched on the root, so wire:model.live.blur sends the committed value then.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, responsive, rtl, darkMode, localized.

Guidance

Freeform text input

Collect unpredictable freeform information.

Use when

  • Use when users must enter freeform information that cannot be reliably selected from a list.
  • A record detail page shows a title or a field (an issue title, a project name, a client name, a description) that people rename where they read it, Linear or Notion style.
  • You want the value to look like text until someone edits it, and to save on Enter or blur without a separate form and Save button.
  • A record header title that wraps to several lines and must line up with the page text: use variant="flush" with multiline.

Avoid when

  • Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
  • The value is one field of a larger form that is submitted together: use <x-ui.field> with <x-ui.input> or <x-ui.textarea>.
  • The text is a card of notes with a label and explicit Save and Cancel buttons: use <x-ui.editable-card>.
  • The content needs formatting (bold, links, lists): use <x-ui.editor>.

Use instead

  • Radio or checkbox for bounded choices
  • Select or combobox for known options

Anti-patterns

  • Using freeform entry for a bounded answer set
Anatomy
inline-edit inline-edit-hidden inline-edit-field inline-edit-display inline-edit-text inline-edit-icon inline-edit-input inline-edit-message inline-edit-status
Theming hooks
input

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Enter Escape
Focus
managed
  • The `as` element stays in the page in both modes, so a heading keeps its level; the display is a real button named "Edit <label>: <value>" (or "Edit <label> (empty)"), which contains the visible text.
  • Focus moves into the field on open and back to the display button after Enter, Escape or a save, never to the page body. Errors set aria-invalid and aria-describedby on the field; saves and errors are announced once through a role="status" region.
  • The hover tint and the pencil fade are colour and opacity changes only and are removed with reduced motion; the focus ring uses the themeable ring tokens.
  • 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="inline-edit-{{ $record->id }}">
    {{-- A record header: the title is a heading that edits in place, and the
         fields below edit the same way. `-mx-2` lines the text up with the
         content around it while the hover tint reaches into the gutter. --}}
    <div class="w-full max-w-2xl space-y-4">
        <x-ui.inline-edit
            as="h2"
            size="2xl"
            name="title"
            :label="__('Title')"
            :value="__('Website relaunch')"
            required
            maxlength="80"
            class="-mx-2 font-semibold"
        />
    
        <dl class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-6 gap-y-2 text-sm">
            <dt class="text-muted-foreground">{{ __('Client') }}</dt>
            <dd class="min-w-0">
                <x-ui.inline-edit name="client" :label="__('Client')" :value="__('Northwind Traders')" class="-mx-2" />
            </dd>
            <dt class="text-muted-foreground">{{ __('Reference') }}</dt>
            <dd class="min-w-0">
                <x-ui.inline-edit name="reference" :label="__('Reference')" class="-mx-2" />
            </dd>
        </dl>
    </div>
</div>

Validation

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

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:inline-edit
        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/inline-edit.blade.php Blade
{{--
    Inline Edit: a value shown as text in the host's typography (a record
    title, a client name, a description) that turns into a text field in
    place. Click, Enter, Space or F2 opens the field; Enter or blur saves;
    Escape cancels and returns focus to the text.

    The `as` element (span, p, div or h1-h6) stays in the document in both
    modes, so a heading stays a heading: in display mode it holds a button
    that names the action ("Edit title: Website relaunch"), in edit mode the
    input or textarea. Both inherit the host font, so the text does not move.

    variant="flush" is for a record header: the text lines up with the page
    text (the box reaches into the gutter), the display fills the row, and
    the pencil keeps an end gutter the field keeps too, so a wrapped title
    breaks at the same words in both modes.
--}}
@props([
    'value' => null,
    'name' => null,
    'label' => null,
    'as' => 'span',
    'size' => 'inherit',
    // default: the box sits inside the host's text column. flush: the text
    // lines up with the text around it and the display fills the row.
    'variant' => 'default',
    'multiline' => false,
    'placeholder' => null,
    'required' => false,
    'maxlength' => null,
    'trim' => true,
    'selectOnEdit' => true,
    'saveMethod' => null,
    'editable' => true,
    // Opens edit mode when the display button gets focus (Tab), as a field
    // would. Focus that returns after a save or Escape does not reopen it.
    'editOnFocus' => false,
    'icon' => true,
    'messages' => [],
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $input = $styles['input'];

    $tag = in_array($as, ['span', 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'], true) ? $as : 'span';
    $isHeading = $tag[0] === 'h';

    // Type scale presets. `inherit` keeps the host's size, so a class on the
    // component (or on an ancestor) sets the typography of both modes.
    $sizes = [
        'inherit' => '',
        'sm' => 'text-sm',
        'base' => 'text-base',
        'lg' => 'text-lg',
        'xl' => 'text-xl',
        '2xl' => 'text-2xl',
        '3xl' => 'text-3xl',
    ];
    $size = array_key_exists((string) $size, $sizes) ? (string) $size : 'inherit';

    $variant = $styles['normalizeVariant']($variant);
    $variant = in_array($variant, ['default', 'flush'], true) ? $variant : 'default';
    $flush = $variant === 'flush';

    $multiline = filter_var($multiline, FILTER_VALIDATE_BOOLEAN);
    $required = filter_var($required, FILTER_VALIDATE_BOOLEAN);
    $trim = filter_var($trim, FILTER_VALIDATE_BOOLEAN);
    $selectOnEdit = filter_var($selectOnEdit, FILTER_VALIDATE_BOOLEAN);
    $editable = filter_var($editable, FILTER_VALIDATE_BOOLEAN);
    $editOnFocus = filter_var($editOnFocus, FILTER_VALIDATE_BOOLEAN);
    $icon = filter_var($icon, FILTER_VALIDATE_BOOLEAN);
    $maxlength = is_numeric($maxlength) && (int) $maxlength > 0 ? (int) $maxlength : null;
    $saveMethod = filled($saveMethod) ? (string) $saveMethod : null;

    $label = filled($label) ? (string) $label : __('Value');
    $placeholder = filled($placeholder) ? (string) $placeholder : ($isHeading ? __('Untitled') : __('Add a value'));

    // The error-bag key and the event `name`: the `name` prop, else the
    // wire:model target, so a Livewire property validates without a name.
    $modelKey = null;
    foreach ($attributes->getAttributes() as $attributeName => $attributeValue) {
        if (str_starts_with((string) $attributeName, 'wire:model') && is_string($attributeValue)) {
            $modelKey = $attributeValue;
            break;
        }
    }
    $fieldName = filled($name) ? (string) $name : null;
    $errorKey = $fieldName ?? $modelKey;

    $bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
    $serverError = $bag && filled($errorKey) && $bag->has($errorKey) ? (string) $bag->first($errorKey) : '';
    $startEditing = $editable && $serverError !== '';

    // A rejected post re-opens the field with the submitted text (old()),
    // while Escape still restores the stored value.
    $stored = $value === null ? '' : (string) $value;
    $old = $fieldName !== null ? old($fieldName) : null;
    $old = is_scalar($old) ? (string) $old : null;
    $committed = $startEditing ? $stored : ($old ?? $stored);
    $draft = $startEditing ? ($old ?? $stored) : $committed;

    $messages = array_merge([
        'required' => __(':Label cannot be empty.', ['label' => $label]),
        'maxlength' => $maxlength !== null ? __('Use :max characters or fewer.', ['max' => $maxlength]) : '',
        'saved' => __('Saved'),
        'failed' => __('Could not save. Try again.'),
    ], array_filter((array) $messages, 'is_string'));

    // ":value" stays in the pattern; the script fills in the current value.
    $editPattern = __('Edit :label: :value', ['label' => $label]);
    $editEmpty = __('Edit :label (empty)', ['label' => $label]);
    $displayLabel = $committed === '' ? $editEmpty : str_replace(':value', $committed, $editPattern);

    $baseId = 'inline-edit-'.substr(md5((string) ($attributes->get('id') ?? $errorKey ?? $label)), 0, 10);
    $inputId = (string) ($attributes->get('id') ?? $baseId.'-input');
    $messageId = $baseId.'-message';

    // Flush: the pencil (16px after an 8px gap) sits in an end gutter of
    // the display; the field keeps the same gutter (pe-8 = 8 + 16 + 8), so
    // the text wraps at the same width in both modes.
    $boxClasses = $flush ? 'rounded-md border ps-2 pe-2 py-1' : 'rounded-md border px-2 py-1';
    $fieldBoxClasses = $flush ? 'rounded-md border ps-2 '.($icon && $editable ? 'pe-8' : 'pe-2').' py-1' : $boxClasses;
    $fieldClasses = trim($input['base'].' h-auto '.$fieldBoxClasses.' '.($multiline ? 'resize-none overflow-hidden' : ''));
    $textClasses = 'min-w-0 break-words'.($multiline ? ' whitespace-pre-wrap' : '');
    $displayLayout = $flush ? 'flex w-full items-baseline' : 'inline-flex max-w-full items-center';

    // The modes swap with the hidden attribute, not x-show: x-show reveals
    // on the next animation frame, too late to move focus in $nextTick.
    // One attribute set for the input and the textarea. The border swaps
    // through a class object: Alpine removes the classes of a false key,
    // the static default included.
    $defaultState = $input['states']['default'];
    $invalidState = $input['states']['invalid'];
    $fieldAttributes = new \Illuminate\View\ComponentAttributeBag(array_filter([
        'id' => $inputId,
        'data-slot' => 'inline-edit-input',
        'x-ref' => 'inlineInput',
        'hidden' => $startEditing ? null : true,
        'x-bind:hidden' => '!inlineEditing',
        'x-model' => 'inlineDraft',
        'aria-label' => $label,
        'autocomplete' => 'off',
        'placeholder' => $placeholder,
        'aria-required' => $required ? 'true' : null,
        'aria-invalid' => $serverError !== '' ? 'true' : null,
        'aria-describedby' => $serverError !== '' ? $messageId : null,
        'x-bind:aria-invalid' => "inlineError !== '' ? 'true' : null",
        'x-bind:aria-describedby' => "inlineError !== '' ? ".\Illuminate\Support\Js::from($messageId)." : null",
        'x-bind:readonly' => 'inlineSaving',
        'x-on:keydown.enter' => 'inlineEnter($event)',
        'x-on:keydown.escape.stop.prevent' => 'inlineCancel()',
        'x-on:blur' => 'inlineSave()',
        'x-on:input' => 'inlineGrow()',
        'class' => $fieldClasses.' '.($serverError !== '' ? $invalidState : $defaultState),
        'x-bind:class' => '{ '.\Illuminate\Support\Js::from($defaultState).": inlineError === '', "
            .\Illuminate\Support\Js::from($invalidState).": inlineError !== '', 'cursor-progress opacity-60': inlineSaving }",
    ], fn ($attributeValue) => $attributeValue !== null));
@endphp

<div
    x-data="uiInlineEdit({
        value: @js($committed),
        draft: @js($draft),
        error: @js($serverError),
        name: @js($errorKey),
        placeholder: @js($placeholder),
        editable: @js($editable),
        editOnFocus: @js($editOnFocus),
        multiline: @js($multiline),
        trim: @js($trim),
        required: @js($required),
        maxlength: @js($maxlength),
        selectOnEdit: @js($selectOnEdit),
        saveMethod: @js($saveMethod),
        editPattern: @js($editPattern),
        editEmpty: @js($editEmpty),
        messages: @js($messages),
    })"
    {{-- Modelable: x-model and wire:model bind the committed string. It
         changes only when a save commits, never per keystroke. --}}
    x-modelable="inlineModel"
    data-slot="inline-edit"
    data-variant="{{ $variant }}"
    data-state="{{ $startEditing ? 'editing' : 'display' }}"
    x-bind:data-state="inlineState"
    @if ($serverError !== '') data-invalid="true" @endif
    x-bind:data-invalid="inlineError !== '' ? 'true' : null"
    x-bind:aria-busy="inlineSaving ? 'true' : null"
    x-on:focusout="inlineFocusOut()"
    {{-- Alpine owns this element's state attributes and every child but the
         server carrier: a Livewire morph would otherwise reset them to the
         server's display-mode markup in the middle of an edit. --}}
    wire:ignore.self
    {{ $attributes->except(['id'])->merge(['class' => $flush ? 'min-w-0 -mx-2' : 'min-w-0']) }}
>
    {{-- Server carrier: the value and the error of the latest render. A morph
         updates these two attributes and the script observes them. --}}
    <span hidden data-slot="inline-edit-server" data-value="{{ $committed }}" data-error="{{ $serverError }}"></span>

    @if ($fieldName !== null)
        <input type="hidden" name="{{ $fieldName }}" value="{{ $committed }}" wire:ignore x-ref="inlineHidden" x-bind:value="inlineCommitted" data-slot="inline-edit-hidden" />
    @endif

    <{{ $tag }} data-slot="inline-edit-field" wire:ignore class="{{ trim('m-0 block min-w-0 '.$sizes[$size]) }}">
        @if ($editable)
            <button
                type="button"
                data-slot="inline-edit-display"
                x-ref="inlineDisplay"
                @if ($startEditing) hidden @endif
                x-bind:hidden="inlineEditing"
                aria-label="{{ $displayLabel }}"
                x-bind:aria-label="inlineDisplayLabel"
                x-on:click="inlineEdit()"
                x-on:keydown.f2.prevent="inlineEdit()"
                @if ($editOnFocus) x-on:focus="inlineFocusEdit()" @endif
                class="group {{ $displayLayout }} gap-2 border-transparent text-start transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none {{ $boxClasses }}"
            >
                <span
                    data-slot="inline-edit-text"
                    class="{{ $textClasses }}{{ $committed === '' ? ' text-muted-foreground' : '' }}"
                    x-bind:class="{ 'text-muted-foreground': inlineCommitted === '' }"
                    x-text="inlineCommitted === '' ? inlinePlaceholder : inlineCommitted"
                >{{ $committed === '' ? $placeholder : $committed }}</span>
                @if ($icon)
                    <svg data-slot="inline-edit-icon" class="size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 pointer-coarse:opacity-100 motion-reduce:transition-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
                @endif
            </button>

            @if ($multiline)
                <textarea {{ $fieldAttributes }} rows="1">{{ $draft }}</textarea>
            @else
                <input {{ $fieldAttributes }} type="text" value="{{ $draft }}" />
            @endif
        @else
            <span
                data-slot="inline-edit-text"
                class="block border-transparent {{ $boxClasses }} {{ $textClasses }}{{ $committed === '' ? ' text-muted-foreground' : '' }}"
                x-bind:class="{ 'text-muted-foreground': inlineCommitted === '' }"
                x-text="inlineCommitted === '' ? inlinePlaceholder : inlineCommitted"
            >{{ $committed === '' ? $placeholder : $committed }}</span>
        @endif
    </{{ $tag }}>

    <p
        id="{{ $messageId }}"
        data-slot="inline-edit-message"
        wire:ignore
        @if ($serverError === '') hidden @endif
        x-bind:hidden="inlineError === ''"
        x-text="inlineError"
        class="mt-1 px-2 text-sm font-normal tracking-normal text-destructive"
    >{{ $serverError }}</p>

    <span data-slot="inline-edit-status" role="status" wire:ignore class="sr-only" x-text="inlineAnnouncement"></span>
</div>
resources/js/ui/inline-edit.js JS
/**
 * Inline Edit behaviour.
 *
 * Display mode shows the committed value in a button; edit mode shows an
 * input (or an auto-growing textarea with `multiline`) bound to a draft.
 * Enter and blur save, Escape cancels. A save trims the draft (unless
 * `trim` is false), checks `required` and `maxlength`, and does nothing when
 * the value did not change.
 *
 * A commit updates the modelable value (`x-modelable="inlineModel"`, so
 * x-model and wire:model change only on commit), the hidden input for plain
 * form posts, and dispatches `inline-edit-save` with
 * `{ value, previous, name }`. With `saveMethod` inside Livewire the
 * control first calls `$wire[saveMethod](value)` and stays in edit mode,
 * readonly and aria-busy, until it resolves. A rejection, a `false` result
 * or a Livewire validation error for `name` keeps the field open with the
 * message; a string result becomes the committed value.
 *
 * With `editOnFocus` the display button opens edit mode when it gets focus
 * (Tab); focus that the control itself moves back after a save or Escape
 * does not reopen it. When focus leaves the control, a `blur` event is
 * dispatched on the root, so wire:model.live.blur sends the committed value.
 *
 * Inside Livewire, Alpine owns the root's state attributes
 * (`wire:ignore.self`) and every child (`wire:ignore`) except a hidden
 * server carrier. A morph updates the carrier's `data-value` and
 * `data-error`; they are observed, so a new server value shows in display
 * mode and a server error re-opens the field.
 *
 * Internal state uses prefixed names (inlineCommitted, inlineDraft, ...):
 * an x-model on the root resolves in this component's scope first, so a
 * plain name such as `value` would shadow the consumer's property.
 * Self-registers on `alpine:init` so import order does not matter.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiInlineEdit', (config = {}) => {
        const messages = config.messages || {};
        const multiline = Boolean(config.multiline);
        let observer = null;
        let focusOutTimer = null;
        // True while the control itself moves focus back to the display, so
        // edit-on-focus does not reopen what a save or Escape just closed.
        let returningFocus = false;
        // True while the control moves focus between its button and its
        // field: hiding the focused one fires a focusout that is not a leave.
        let swapping = false;

        return {
            inlineCommitted: config.value ?? '',
            inlineDraft: config.draft ?? config.value ?? '',
            inlineEditing: config.editable !== false && Boolean(config.error),
            inlineSaving: false,
            inlineError: config.error || '',
            inlineAnnouncement: '',
            inlinePlaceholder: config.placeholder || '',
            inlineEditable: config.editable !== false,
            inlineSaveMethod: config.saveMethod || null,

            init() {
                const carrier = this.$root.querySelector('[data-slot="inline-edit-server"]');
                if (carrier) {
                    observer = new MutationObserver((records) => {
                        const changed = new Set(records.map((record) => record.attributeName));
                        if (changed.has('data-value')) this.inlineServerValue(carrier.dataset.value ?? '');
                        if (changed.has('data-error')) this.inlineServerError(carrier.dataset.error ?? '');
                    });
                    observer.observe(carrier, { attributes: true, attributeFilter: ['data-value', 'data-error'] });
                }
                if (this.inlineEditing) this.$nextTick(() => this.inlineGrow());
            },

            destroy() {
                observer?.disconnect();
                observer = null;
                clearTimeout(focusOutTimer);
            },

            /** Modelable value (`x-modelable="inlineModel"`): the committed string. */
            get inlineModel() {
                return this.inlineCommitted;
            },
            set inlineModel(next) {
                const value = next === null || next === undefined ? '' : String(next);
                if (value === this.inlineCommitted) return;
                this.inlineCommitted = value;
                if (!this.inlineEditing) this.inlineDraft = value;
            },

            get inlineState() {
                if (this.inlineSaving) return 'saving';
                return this.inlineEditing ? 'editing' : 'display';
            },

            get inlineDisplayLabel() {
                if (this.inlineCommitted === '') return config.editEmpty || '';
                return (config.editPattern || ':value').replace(':value', () => this.inlineCommitted);
            },

            inlineEdit() {
                if (!this.inlineEditable || this.inlineEditing) return;
                this.inlineDraft = this.inlineCommitted;
                this.inlineError = '';
                this.inlineEditing = true;
                swapping = true;
                this.$nextTick(() => {
                    const field = this.$refs.inlineInput;
                    if (!field) {
                        swapping = false;
                        return;
                    }
                    this.inlineGrow();
                    field.focus();
                    swapping = false;
                    if (config.selectOnEdit === false) {
                        const end = field.value.length;
                        field.setSelectionRange(end, end);
                    } else {
                        field.select();
                    }
                });
            },

            inlineEnter(event) {
                // Leave Enter to an input method editor that is composing text.
                if (event.isComposing || event.keyCode === 229) return;
                if (multiline && event.shiftKey) return;
                event.preventDefault();
                this.inlineSave();
            },

            inlineCancel() {
                if (!this.inlineEditing || this.inlineSaving) return;
                this.inlineDraft = this.inlineCommitted;
                this.inlineError = '';
                this.inlineEditing = false;
                this.inlineFocusDisplay(true);
            },

            /** Enter or blur. Cancel and a finished save leave edit mode first, so their blur is a no-op. */
            async inlineSave() {
                if (!this.inlineEditing || this.inlineSaving) return;
                const raw = multiline ? this.inlineDraft : this.inlineDraft.replace(/[\r\n]+/g, ' ');
                const value = config.trim === false ? raw : raw.trim();

                const invalid = this.inlineValidate(value);
                if (invalid) {
                    this.inlineFail(invalid);
                    return;
                }

                const previous = this.inlineCommitted;
                if (value === previous) {
                    this.inlineClose();
                    return;
                }

                const method = this.inlineSaveMethod;
                const wire = method ? this.inlineWire() : null;
                if (!wire) {
                    this.inlineCommit(value, previous);
                    return;
                }

                this.inlineSaving = true;
                let result;
                try {
                    result = await (typeof wire[method] === 'function' ? wire[method](value) : wire.call(method, value));
                } catch {
                    this.inlineSaving = false;
                    this.inlineFail(messages.failed || '');
                    return;
                }
                this.inlineSaving = false;

                const serverError = this.inlineWireError(wire);
                if (serverError) {
                    // A morph may already have shown (and announced) the same message.
                    this.inlineFail(serverError, serverError !== this.inlineError);
                    return;
                }
                if (result === false) {
                    this.inlineFail(messages.failed || '');
                    return;
                }
                this.inlineCommit(typeof result === 'string' ? result : value, previous);
            },

            inlineValidate(value) {
                if (config.required && value.trim() === '') return messages.required || '';
                if (config.maxlength && Array.from(value).length > config.maxlength) return messages.maxlength || '';
                return '';
            },

            /** Keeps edit mode with a message; focus stays where it is. */
            inlineFail(message, announce = true) {
                this.inlineError = message;
                this.inlineEditing = true;
                if (announce) this.inlineAnnounce(message);
            },

            /** Leaves edit mode without a save: the value did not change. */
            inlineClose() {
                const hadFocus = this.$root.contains(document.activeElement);
                this.inlineDraft = this.inlineCommitted;
                this.inlineError = '';
                this.inlineEditing = false;
                this.inlineFocusDisplay(hadFocus);
            },

            inlineCommit(value, previous) {
                const hadFocus = this.$root.contains(document.activeElement);
                this.inlineCommitted = value;
                this.inlineDraft = value;
                this.inlineError = '';
                this.inlineEditing = false;

                // Plain forms read the hidden input; its bubbling change also
                // reaches a wire:model.change on the root.
                const hidden = this.$refs.inlineHidden;
                if (hidden) {
                    hidden.value = value;
                    hidden.dispatchEvent(new Event('input', { bubbles: true }));
                    hidden.dispatchEvent(new Event('change', { bubbles: true }));
                } else {
                    this.$root.dispatchEvent(new Event('change', { bubbles: true }));
                }

                this.$root.dispatchEvent(
                    new CustomEvent('inline-edit-save', {
                        detail: { value, previous, name: config.name ?? null },
                        bubbles: true,
                        composed: true,
                    }),
                );
                this.inlineAnnounce(messages.saved || '');
                this.inlineFocusDisplay(hadFocus);
            },

            /** Keeps focus on the control when edit mode closes under it. */
            inlineFocusDisplay(hadFocus) {
                if (!hadFocus) return;
                swapping = true;
                this.$nextTick(() => {
                    returningFocus = true;
                    this.$refs.inlineDisplay?.focus();
                    returningFocus = false;
                    swapping = false;
                });
            },

            /** edit-on-focus: the display got focus from the user (Tab, click). */
            inlineFocusEdit() {
                if (!config.editOnFocus || returningFocus) return;
                this.inlineEdit();
            },

            /**
             * Focus left an element inside the control. A swap between the
             * button and the field is not a leave; otherwise check once focus
             * settled, and only focus that went elsewhere dispatches `blur`.
             */
            inlineFocusOut() {
                if (swapping) return;
                clearTimeout(focusOutTimer);
                focusOutTimer = setTimeout(() => {
                    if (this.$root.contains(document.activeElement)) return;
                    this.$root.dispatchEvent(new FocusEvent('blur'));
                }, 0);
            },

            /** A re-render brought a new stored value; a running save sets its own. */
            inlineServerValue(value) {
                if (this.inlineSaving || value === this.inlineCommitted) return;
                this.inlineCommitted = value;
                if (!this.inlineEditing) this.inlineDraft = value;
            },

            /** A re-render brought a validation error for this field. */
            inlineServerError(message) {
                if (!this.inlineEditable || message === '' || message === this.inlineError) return;
                const hadFocus = this.$root.contains(document.activeElement);
                if (!this.inlineEditing) this.inlineDraft = this.inlineCommitted;
                this.inlineError = message;
                this.inlineEditing = true;
                this.inlineAnnounce(message);
                if (hadFocus) this.$nextTick(() => this.$refs.inlineInput?.focus());
            },

            /** Grows the multiline textarea to its content. */
            inlineGrow() {
                const field = this.$refs.inlineInput;
                if (!multiline || !field) return;
                field.style.height = 'auto';
                const borders = field.offsetHeight - field.clientHeight;
                field.style.height = `${field.scrollHeight + borders}px`;
            },

            /** Announces once through the polite status region. */
            inlineAnnounce(message) {
                this.inlineAnnouncement = '';
                if (!message) return;
                this.$nextTick(() => {
                    this.inlineAnnouncement = message;
                });
            },

            /** The nearest Livewire component, or null outside Livewire. */
            inlineWire() {
                try {
                    return window.Livewire && this.$wire ? this.$wire : null;
                } catch {
                    return null;
                }
            },

            /** Livewire's validation message for `name` after a call, if any. */
            inlineWireError(wire) {
                if (!config.name) return '';
                try {
                    const message = wire.$errors?.first?.(config.name);
                    return typeof message === 'string' ? message : '';
                } catch {
                    return '';
                }
            },
        };
    });
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.1.1
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