Skip to content
LarawellUi

<x-widget.textarea>

Textarea

Grows with its content, or resizes by dragging. Optional character counter. Works with Livewire wire:model.

php artisan larawell:add textarea

Usage

Livewire

In a Livewire component, bind with wire:model (deferred) or wire:model.live.debounce for live checks; no name is needed. The text shows the property after every render, the counter and height included, so clearing it in PHP after a save empties the box.

Blade
<form wire:submit="post" class="space-y-5">
    <x-widget.textarea label="Comment" counter maxlength="500" wire:model="comment" />

    <button type="submit">Post</button>
</form>

Examples

Textarea

By default it grows with its content between min-rows and max-rows (5), including when a script sets its value. resizable gives it a drag handle instead: the user sets the height, from min-rows down to max-rows if you set one. Each new height fires a textarea-resize event.

Show code
Blade
<div class="grid gap-6 sm:grid-cols-2">
    <x-widget.textarea name="notes" label="Notes" placeholder="Grows as you type..." />
    <x-widget.textarea name="message" label="Message" placeholder="Drag the corner to resize" resizable :min-rows="4" />
</div>

Counter

counter shows characters used against maxlength. It's opt-in, so a field with maxlength doesn't get one by default.

0 / 160

Show code
Blade
<x-widget.textarea name="bio" label="Bio" maxlength="160" counter placeholder="A line or two about you" />

Props

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

<x-widget.textarea>

Prop Default Description
name null What it submits as; its error and old input are found under it. Optional with wire:model, which then names it.
id null Defaults to one made from the name (or the wire:model property).
label null Shown above the field, and its name for screen readers.
value null The value to show. Old input wins after a failed submit; with wire:model and no value, the bound Livewire property.
placeholder null Shown while it's empty.
error null An error message of your own; otherwise the validation error for the name, from the session or Livewire.
info null A hint under the field.
bag 'default' Which error bag to read the error from.
disabled false Greyed out: it can't be changed.
readonly false Shown, and submitted, but it can't be edited.
min-rows 3 The height to start at, in lines.
max-rows null Grows up to this many lines, then scrolls (5 by default; no limit when resizable).
counter false With maxlength: a live count under the field (12 / 40).
resizable false Lets people drag it taller instead of sizing it to the text.

Source

What larawell:add textarea writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from Field, and the theme and base CSS.

resources/views/components/widget/textarea/index.blade.php Show
index.blade.php
@props([
    // What it submits as; its error and old input are found under it. Optional with wire:model, which then names it.
    'name' => null,
    // Defaults to one made from the name (or the wire:model property).
    'id' => null,
    // Shown above the field, and its name for screen readers.
    'label' => null,
    // The value to show. Old input wins after a failed submit; with wire:model and no value, the bound Livewire
    // property.
    'value' => null,
    // Shown while it's empty.
    'placeholder' => null,
    // An error message of your own; otherwise the validation error for the name, from the session or Livewire.
    'error' => null,
    // A hint under the field.
    'info' => null,
    // Which error bag to read the error from.
    'bag' => 'default',
    // Greyed out: it can't be changed.
    'disabled' => false,
    // Shown, and submitted, but it can't be edited.
    'readonly' => false,
    // The height to start at, in lines.
    'minRows' => 3,
    // Grows up to this many lines, then scrolls (5 by default; no limit when resizable).
    'maxRows' => null,
    // With maxlength: a live count under the field (12 / 40).
    'counter' => false,
    // Lets people drag it taller instead of sizing it to the text.
    'resizable' => false,
])

@php
    $field = \App\View\Widget\FormField::make($name, $id, $errors ?? null, $error, $bag, 'textarea', attributes: $attributes);
    $value = $field->old($value);
    // Up to 30: the heights are classes from the ranges at the end of base.css, not style="".
    $minRows = max(1, min(30, (int) $minRows));
    // Auto-grow stops at 5 rows unless told otherwise. A resizable textarea has no cap unless max-rows sets one:
    // the user decides its height, so the browser can't also size it to the content.
    $maxRows = match (true) {
        $maxRows !== null => max($minRows, min(30, (int) $maxRows)),
        $resizable => null,
        default => max($minRows, 5),
    };
@endphp

<x-widget.field :required="$attributes->has('required')" :id="$field->id" :label="$label" :error="$field->errors" :info="$info" :disabled="$disabled" :readonly="$readonly" :box="$resizable ? 'items-start pe-2 pb-2' : 'items-start'" :counter="$counter ? $attributes->get('maxlength') : null" :count="intdiv(strlen(mb_convert_encoding((string) $value, 'UTF-16LE', 'UTF-8')), 2)" :class="$attributes->get('class')">
    {{-- Grows with its content via CSS field-sizing (the JS only steps in for browsers without it), or, when
         resizable, keeps the height the user drags it to. 2rem = py-4. --}}
    <textarea
        id="{{ $field->id }}"
        @if ($name) name="{{ $name }}" @endif
        rows="{{ $minRows }}"
        placeholder="{{ $placeholder }}"
        @disabled($disabled)
        @readonly($readonly)
        @unless ($resizable) data-autosize @endunless
        {{ $field->controlAttributes($attributes, (bool) $info)->class([
            "min-h-[calc({$minRows}lh+2rem)]",
            "max-h-[calc({$maxRows}lh+2rem)]" => $maxRows !== null,
            'field-sizing-content resize-none' => ! $resizable,
            'resize-y' => $resizable,
            'w-full bg-transparent px-5 py-4 outline-none placeholder:text-muted disabled:cursor-not-allowed disabled:opacity-50 read-only:cursor-default',
            'group-data-invalid/field:placeholder:text-error group-data-invalid/field:focus:placeholder:text-muted',
        ]) }}
    >{{ $value }}</textarea>
</x-widget.field>
resources/js/widget/textarea/index.js Show
index.js
// Behaviour for <x-widget.textarea>: growing with its content. Delegated from `document`, so fields added later work without
// re-initialising. Client-side filtering is only for convenience; the Form Request must validate the same rules.
import { on } from '../field';

// --- Textarea: auto-grow -----------------------------------------------------------

// CSS field-sizing grows the textarea where the browser supports it; elsewhere fit() does, and also has to
// catch what the browser would: width changes (more wrapping), fonts arriving late, and values set by a
// script (Alpine, Livewire, "insert template"), which fire no input event. Either way, each new height is
// announced as a `textarea-resize` event (detail.height), e.g. to keep a chat scrolled to the bottom.
const nativeAutosize = CSS.supports('field-sizing', 'content');
const lastHeight = new WeakMap();
const lastWidth = new WeakMap();

function fit(textarea) {
    if (nativeAutosize) {
        return;
    }
    // Reset first so it can shrink as well as grow; min/max-height on the element still clamp the result.
    textarea.style.height = 'auto';
    textarea.style.height = `${textarea.scrollHeight}px`;
}

const textareaSizes = new ResizeObserver((entries) => {
    for (const { target } of entries) {
        if (lastWidth.get(target) !== target.offsetWidth) {
            lastWidth.set(target, target.offsetWidth);
            // Next frame: resizing the element being observed inside its own callback is a ResizeObserver loop.
            requestAnimationFrame(() => fit(target));
        }
        const height = target.offsetHeight;
        if (lastHeight.has(target) && lastHeight.get(target) !== height) {
            target.dispatchEvent(new CustomEvent('textarea-resize', { bubbles: true, detail: { height } }));
        }
        lastHeight.set(target, height);
    }
});

function watchTextarea(textarea) {
    if (textarea.dataset.autosizeReady) {
        return;
    }
    textarea.dataset.autosizeReady = 'true';
    if (!nativeAutosize) {
        // Refit whenever a script sets .value, on this element only.
        const { get, set } = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
        Object.defineProperty(textarea, 'value', {
            configurable: true,
            get() {
                return get.call(this);
            },
            set(value) {
                set.call(this, value);
                fit(this);
            },
        });
        fit(textarea);
    }
    textareaSizes.observe(textarea);
}

on('input', '[data-autosize]', (event, textarea) => fit(textarea));
document.querySelectorAll('[data-autosize]').forEach(watchTextarea);
// Textareas that arrive later: fetched HTML, modals, table page swaps.
new MutationObserver((mutations) => {
    for (const node of mutations.flatMap((mutation) => [...mutation.addedNodes])) {
        if (node instanceof Element) {
            (node.matches('[data-autosize]') ? [node] : node.querySelectorAll('[data-autosize]')).forEach(watchTextarea);
        }
    }
}).observe(document.documentElement, { childList: true, subtree: true });
document.fonts?.addEventListener('loadingdone', () => document.querySelectorAll('[data-autosize]').forEach(fit));
app/View/Widget/ElementIds.php Show
ElementIds.php
<?php

declare(strict_types=1);

namespace App\View\Widget;

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

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

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

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

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

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

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

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

declare(strict_types=1);

namespace App\View\Widget;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        return [];
    }

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

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

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

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

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

    /**
     * aria-invalid plus one aria-describedby that joins the error, the hint and the caller's own
     * ids. Two separate aria-describedby attributes would make the browser silently drop one.
     */
    public function aria(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        $describedBy = array_filter([
            $this->hasError() ? $this->errorId() : null,
            $hasInfo ? $this->infoId() : null,
            $attributes->get('aria-describedby'),
        ]);

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

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

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

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

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