Skip to main content

Forms

Mention Input code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/mention-input.blade.php

@props([
    'name' => null,
    'value' => null,
    'suggestions' => [],
    'trigger' => '@',
    'rows' => 3,
    'placeholder' => null,
    'invalid' => false,
    'disabled' => false,
])

@php
    /*
    | Textarea with @-mention (or any trigger) autocomplete. Typing the trigger
    | followed by text opens a filtered suggestion list; Enter/click inserts the
    | value at the caret. Arrow keys move the highlight, Escape closes.
    |
    | suggestions: [['value' => 'ada', 'label' => 'Ada Lovelace'], ...]
    |
    | Usage:
    |   <x-ui.mention-input name="body" :suggestions="$users" placeholder="Write a comment…" />
    |   <x-ui.mention-input trigger="#" :suggestions="$channels" />
    */
    $ring = $invalid
        ? 'border-red-400 dark:border-red-500 focus:border-red-500 focus:ring-red-500'
        : 'border-gray-300 dark:border-gray-700 focus:border-brand-500 focus:ring-brand-500';

    $classes = implode(' ', [
        'block w-full rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 shadow-sm transition focus:ring-1 disabled:opacity-50 disabled:cursor-not-allowed',
        'px-3.5 py-2.5 text-sm',
        $ring,
    ]);

    $normalized = collect($suggestions)->map(fn ($item, $key) => is_array($item)
        ? ['value' => (string) ($item['value'] ?? $key), 'label' => (string) ($item['label'] ?? $item['value'] ?? $key)]
        : ['value' => (string) $key, 'label' => (string) $item]
    )->values();

    $listId = 'mention-'.substr(md5($trigger.$name.$placeholder), 0, 10);
@endphp

<div
    x-data="{
        value: @js((string) ($value ?? '')),
        suggestions: @js($normalized),
        trigger: @js($trigger),
        open: false,
        query: '',
        start: null,
        highlighted: 0,
        get filtered() {
            const q = this.query.toLowerCase();
            return this.suggestions
                .filter((s) => s.value.toLowerCase().includes(q) || s.label.toLowerCase().includes(q))
                .slice(0, 6);
        },
        onInput() {
            const el = this.$refs.area;
            const caret = el.selectionStart;
            const upto = this.value.slice(0, caret);
            const idx = upto.lastIndexOf(this.trigger);

            if (idx === -1) { this.close(); return; }

            const token = upto.slice(idx + this.trigger.length);
            if (/\s/.test(token)) { this.close(); return; }

            this.start = idx;
            this.query = token;
            this.highlighted = 0;
            this.open = this.filtered.length > 0;
        },
        pick(item) {
            if (! item) { return; }
            const el = this.$refs.area;
            const caret = el.selectionStart;
            const before = this.value.slice(0, this.start);
            const after = this.value.slice(caret);
            const insert = this.trigger + item.value + ' ';

            this.value = before + insert + after;
            const pos = (before + insert).length;

            this.close();
            this.$nextTick(() => {
                el.focus();
                el.setSelectionRange(pos, pos);
            });
        },
        move(direction) {
            const count = this.filtered.length;
            if (count === 0) { return; }
            this.highlighted = (this.highlighted + direction + count) % count;
        },
        close() {
            this.open = false;
            this.query = '';
            this.start = null;
        },
    }"
    @click.outside="close()"
    {{ $attributes->except(['class', 'id', 'name', 'placeholder', 'rows'])->merge(['class' => 'relative '.$attributes->get('class', '')]) }}
>
    <textarea
        x-ref="area"
        x-model="value"
        @input="onInput()"
        @keydown.arrow-down.prevent="if (open) { move(1); }"
        @keydown.arrow-up.prevent="if (open) { move(-1); }"
        @keydown.enter="if (open) { $event.preventDefault(); pick(filtered[highlighted]); }"
        @keydown.escape="close()"
        @disabled($disabled)
        @if ($attributes->get('id')) id="{{ $attributes->get('id') }}" @endif
        @if ($name) name="{{ $name }}" @endif
        rows="{{ (int) $rows }}"
        @if ($placeholder) placeholder="{{ $placeholder }}" @endif
        @if ($invalid) aria-invalid="true" @endif
        role="combobox"
        aria-expanded="false"
        :aria-expanded="open.toString()"
        aria-controls="{{ $listId }}"
        class="{{ $classes }}"
    ></textarea>

    <div
        id="{{ $listId }}"
        x-show="open" x-cloak
        x-transition:enter="transition ease-out duration-150"
        x-transition:enter-start="opacity-0 scale-95"
        x-transition:enter-end="opacity-100 scale-100"
        class="absolute start-0 top-full z-50 mt-2 w-64 overflow-hidden rounded-xl border border-gray-200 bg-white p-1.5 shadow-lg ring-1 ring-black/5 dark:border-gray-800 dark:bg-gray-900"
        role="listbox"
    >
        <template x-for="(item, index) in filtered" :key="item.value">
            <button
                type="button"
                @mousedown.prevent="pick(item)"
                @mouseenter="highlighted = index"
                :class="index === highlighted ? 'bg-gray-100 dark:bg-gray-800' : ''"
                class="flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-start text-sm transition cursor-pointer"
                role="option"
                :aria-selected="(index === highlighted).toString()"
            >
                <span class="min-w-0 truncate font-medium text-gray-900 dark:text-white" x-text="item.label"></span>
                <span class="shrink-0 text-xs text-gray-400 dark:text-gray-500" x-text="trigger + item.value"></span>
            </button>
        </template>
    </div>
</div>