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>