Inputs
Mention Textarea
Поле с упоминаниями по «@»: список коллег открывается, только когда символ стоит в начале слова, и выбор подставляет ник прямо в текст.
- textarea
- mention
- autocomplete
- comment
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/textarea-005
Символ @ открывает список коллег
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «textarea-005» (Mention Textarea) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/textarea-005.json
Registry item: https://vibeui.ru/r/textarea-005.json
Ставится в: components/vibeui/textarea-005.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Поле с упоминаниями по «@»: список коллег открывается, только когда символ стоит в начале слова, и выбор подставляет ник прямо в текст.
Поле комментария с подсказкой упоминаний по «@». Один файл, ноль зависимостей, клиентский компонент.
## 3. Как использовать
import { Textarea005 } from "@/components/vibeui/textarea-005"
<Textarea005
label="Комментарий к задаче"
placeholder="Наберите @, чтобы позвать коллегу"
/>
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- локальную палитру --vibeui-textarea-005-* — не заменять её токенами темы проекта
- условие «@ в начале слова»: без него подсказка выскакивает на каждом почтовом адресе
- preventDefault на mousedown у пунктов списка — иначе поле теряет фокус раньше, чем произойдёт клик
- setRangeText при вставке: он сохраняет историю отмены и ставит каретку за ником
- список под полем, а не над кареткой: точное позиционирование в textarea требует измерения текста и промахивается
- собственную светлую подложку — без неё тёмный текст пропадает на тёмной странице
## 6. Можно менять
- подпись через label и текст-заглушку через placeholder
- справочник коллег через people
- акцентный цвет через accent
- число видимых подсказок ограничением списка перед отрисовкой
## 7. Правила
- Клавиатурной навигации по списку здесь нет: стрелки двигают каретку в тексте, выбор делается мышью.
- Справочник статичен: для живого поиска people заменяется загруженным списком у владельца данных.
- Компонент не разбирает упоминания обратно — распознавание ников остаётся на стороне, которая сохраняет комментарий.
- Не выносить CSS-переменные в globals.css: компонент обязан оставаться одним самодостаточным файлом.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
npx shadcn@latest add https://vibeui.ru/r/textarea-005.jsonhttps://vibeui.ru/r/textarea-005.jsonКлиентский компонент: useRef на поле, useState на текст и на текущий запрос после «@». Запрос ищется регулярным выражением по тексту до каретки и только в начале слова — иначе подсказка вылезала бы на каждом почтовом адресе. Вставка идёт через setRangeText, поэтому история отмены сохраняется, а каретка встаёт за ником. Кнопки списка гасят mousedown, чтобы поле не теряло фокус до клика. Палитра в --vibeui-textarea-005-*.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"use client"
import { useId, useRef, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"
export type Textarea005Person = {
handle: string
name: string
role: string
}
export type Textarea005Props = Omit<
ComponentPropsWithoutRef<"div">,
"children" | "onChange"
> & {
label?: string
placeholder?: string
people?: Textarea005Person[]
accent?: string
}
// Идея компонента: упоминания по «@» прямо в обычном textarea. Список
// открывается только когда «@» стоит в начале слова и после него нет
// пробела — иначе подсказка выскакивала бы на каждом почтовом адресе.
// Выбор подставляет ник на место набранного куска и возвращает каретку
// за него, чтобы можно было продолжать печатать не глядя.
const STYLES = `
:where([data-vibeui-block="textarea-005"]){
--vibeui-textarea-005-bg:oklch(1 0 0);
--vibeui-textarea-005-fg:oklch(0.22 0.014 265);
--vibeui-textarea-005-muted:oklch(0.55 0.014 265);
--vibeui-textarea-005-border:oklch(0.9 0.006 265);
--vibeui-textarea-005-field:oklch(0.985 0.002 265);
--vibeui-textarea-005-accent:oklch(0.55 0.19 262);
--vibeui-textarea-005-tint:oklch(0.55 0.19 262 / 10%);
--vibeui-textarea-005-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="textarea-005"]{
display:flex;flex-direction:column;gap:0.375rem;
width:100%;max-width:23rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-textarea-005-bg);
border:1px solid var(--vibeui-textarea-005-border);border-radius:0.875rem;
font-family:var(--vibeui-textarea-005-font);color:var(--vibeui-textarea-005-fg);
}
[data-vibeui-block="textarea-005"] [data-part="label"]{font-size:0.8125rem;font-weight:600}
[data-vibeui-block="textarea-005"] [data-part="field"]{position:relative}
[data-vibeui-block="textarea-005"] textarea{
box-sizing:border-box;width:100%;min-height:5.5rem;resize:vertical;
margin:0;padding:0.625rem 0.75rem;
border:1px solid var(--vibeui-textarea-005-border);border-radius:0.625rem;
background:var(--vibeui-textarea-005-field);color:inherit;
font:inherit;font-size:0.875rem;line-height:1.55;
transition:border-color .16s ease,box-shadow .16s ease;
}
[data-vibeui-block="textarea-005"] textarea:focus{
outline:none;border-color:var(--vibeui-textarea-005-accent);
box-shadow:0 0 0 3px color-mix(in oklab,var(--vibeui-textarea-005-accent) 18%,transparent);
}
/* Список висит под полем, а не над кареткой: попасть точно под курсор в
textarea нельзя без измерения текста, а промах туда хуже, чем честная
привязка к нижнему краю поля. */
[data-vibeui-block="textarea-005"] [data-part="menu"]{
position:absolute;left:0;right:0;top:calc(100% + 0.25rem);z-index:2;
margin:0;padding:0.25rem;list-style:none;
border:1px solid var(--vibeui-textarea-005-border);border-radius:0.625rem;
background:var(--vibeui-textarea-005-bg);
box-shadow:0 8px 24px oklch(0.2 0.02 265 / 14%);
animation:vibeui-textarea-005-in .14s ease-out;
}
@keyframes vibeui-textarea-005-in{from{opacity:0;transform:translateY(-0.25rem)}to{opacity:1;transform:none}}
[data-vibeui-block="textarea-005"] [data-part="item"]{
display:flex;width:100%;align-items:center;gap:0.5rem;
padding:0.375rem 0.5rem;border:0;border-radius:0.375rem;
background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;
}
[data-vibeui-block="textarea-005"] [data-part="item"]:hover,
[data-vibeui-block="textarea-005"] [data-part="item"]:focus-visible{
background:var(--vibeui-textarea-005-tint);outline:none;
}
[data-vibeui-block="textarea-005"] [data-part="avatar"]{
display:grid;place-items:center;flex:none;
width:1.5rem;height:1.5rem;border-radius:9999px;
background:var(--vibeui-textarea-005-tint);color:var(--vibeui-textarea-005-accent);
font-size:0.6875rem;font-weight:700;
}
[data-vibeui-block="textarea-005"] [data-part="who"]{display:flex;flex-direction:column;min-width:0}
[data-vibeui-block="textarea-005"] [data-part="name"]{font-size:0.8125rem;font-weight:600;line-height:1.2}
[data-vibeui-block="textarea-005"] [data-part="role"]{font-size:0.6875rem;color:var(--vibeui-textarea-005-muted)}
[data-vibeui-block="textarea-005"] [data-part="hint"]{
margin:0;font-size:0.75rem;color:var(--vibeui-textarea-005-muted);
}
[data-vibeui-block="textarea-005"] [data-part="hint"] b{color:var(--vibeui-textarea-005-accent)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="textarea-005"] *{animation:none!important;transition:none!important}}
`
const DEFAULT_PEOPLE: Textarea005Person[] = [
{ handle: "anna", name: "Анна Летова", role: "дизайн" },
{ handle: "artem", name: "Артём Гуляев", role: "фронтенд" },
{ handle: "dasha", name: "Даша Ким", role: "аналитика" },
{ handle: "max", name: "Максим Орлов", role: "продукт" },
]
/**
* Поле с упоминаниями по «@»: список фильтруется по набранному нику.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Textarea005({
label = "Комментарий к задаче",
placeholder = "Наберите @, чтобы позвать коллегу",
people = DEFAULT_PEOPLE,
accent,
className,
style,
...props
}: Textarea005Props) {
const id = useId()
const field = useRef<HTMLTextAreaElement>(null)
const [value, setValue] = useState(
"Готово к ревью, посмотрите, пожалуйста, @",
)
const [query, setQuery] = useState<string | null>("")
const matches =
query === null
? []
: people.filter((person) => person.handle.startsWith(query.toLowerCase()))
const scan = (element: HTMLTextAreaElement) => {
const before = element.value.slice(0, element.selectionStart)
// «@» считается началом упоминания только в начале слова: иначе
// подсказка лезла бы в каждый почтовый адрес.
const found = before.match(/(?:^|\s)@([\p{L}\w-]*)$/u)
setQuery(found ? found[1] : null)
}
const insert = (handle: string) => {
const element = field.current
if (!element || query === null) {
return
}
const caret = element.selectionStart
element.setRangeText(handle + " ", caret - query.length, caret, "end")
element.focus()
setValue(element.value)
setQuery(null)
}
const palette = {
...(accent ? { "--vibeui-textarea-005-accent": accent } : null),
...style,
} as CSSProperties
return (
<>
<style href="vibeui-textarea-005" precedence="medium">
{STYLES}
</style>
<div
{...props}
data-vibeui-block="textarea-005"
className={className}
style={palette}
>
<label data-part="label" htmlFor={id}>
{label}
</label>
<div data-part="field">
<textarea
id={id}
ref={field}
value={value}
placeholder={placeholder}
onChange={(event) => {
setValue(event.target.value)
scan(event.target)
}}
onClick={(event) => scan(event.currentTarget)}
onKeyUp={(event) => scan(event.currentTarget)}
onBlur={() => setQuery(null)}
/>
{matches.length > 0 ? (
<ul data-part="menu">
{matches.map((person) => (
<li key={person.handle}>
<button
type="button"
data-part="item"
onMouseDown={(event) => event.preventDefault()}
onClick={() => insert(person.handle)}
>
<span data-part="avatar" aria-hidden="true">
{person.name.slice(0, 1)}
</span>
<span data-part="who">
<span data-part="name">{person.name}</span>
<span data-part="role">
@{person.handle} · {person.role}
</span>
</span>
</button>
</li>
))}
</ul>
) : null}
</div>
<p data-part="hint">
Символ <b>@</b> открывает список коллег
</p>
</div>
</>
)
}