Autocomplete

Mention Picker

Упоминания прямо в тексте: список коллег открывает собака перед словом, а не фокус в поле.

  • autocomplete
  • mention
  • textarea
  • avatar

Превью

1440pxHost theme

Использовать с AI

  1. 1. Скопируйте ссылку.
  2. 2. Напишите агенту своими словами и вставьте её в предложение.
  3. 3. Агент откроет ссылку и поставит компонент из registry.

размести это в шапке: https://vibeui.ru/c/autocomplete-011

  • Марк Ильинфронтенд@mark
  • Мария Гуровапродукт@masha
Список открывается после @ и закрывается на пробеле
Показать полную инструкцию

То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.

# Установка и размещение «autocomplete-011» (Mention Picker) из VibeUI

## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/autocomplete-011.json

Registry item: https://vibeui.ru/r/autocomplete-011.json
Ставится в: components/vibeui/autocomplete-011.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.

Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.

## 2. Что это
Упоминания прямо в тексте: список коллег открывает собака перед словом, а не фокус в поле.

Поле комментария с упоминаниями: список появляется после «@» перед словом, строка показывает имя, роль и логин, выбор подставляет упоминание в текст. Ноль зависимостей, один файл, своя палитра.

## 3. Как использовать
import { Autocomplete011 } from "@/components/vibeui/autocomplete-011"

<Autocomplete011
  people={[{ name: "Анна Петрова", handle: "anna", role: "дизайн" }]}
  onChange={(text) => console.log(text)}
/>

Полный список пропсов — в установленном файле.

## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.

Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)

## 5. Сохранить как установлено
- локальную палитру --vibeui-autocomplete-011-* и не подменять её токенами вашей темы
- открытие списка по собаке, а не по фокусу: упоминание живёт посреди фразы
- обрыв токена на пробеле — иначе список висит до конца абзаца
- отслеживание курсора на клике и стрелках, а не только при вводе
- подстановку с пробелом после логина: следующее слово иначе прилипнет к упоминанию
- инициалы вместо фотографий: сорок аватаров ради списка не грузят

## 6. Можно менять
- массив people: имя, логин и роль
- label, placeholder и стартовый текст
- обработчик onChange
- акцент через проп accent

## 7. Правила
- Список не позиционируется у курсора: он идёт под полем. Выпадание у каретки требует замера, а это уже другой компонент.
- Упоминание — просто текст: разбор «@логина» и отправку уведомления делает ваш бэкенд.
- Оттенок аватара считается из имени и стабилен, но не гарантирует уникальность: двум людям может достаться близкий цвет.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.

## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.

Для разработчика

npx shadcn@latest add https://vibeui.ru/r/autocomplete-011.json
https://vibeui.ru/r/autocomplete-011.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-autocomplete-011-*. Клиентский: "use client". Токен упоминания вычисляется от последней собаки до курсора и обрывается на пробеле, поэтому список гаснет сам. Позиция курсора отслеживается на onChange, onKeyUp и onClick. Аватар — инициалы на оттенке, посчитанном из имени: сумма кодов символов по модулю 360 отдаётся в локальную переменную оттенка.

Исходник компонента

Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.

"use client"

import { useId, useMemo, useState } from "react"
import type {
  ChangeEvent,
  ComponentPropsWithoutRef,
  CSSProperties,
} from "react"

export type Autocomplete011Person = {
  name: string
  handle: string
  role: string
}

export type Autocomplete011Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onChange" | "defaultValue"
> & {
  label?: string
  placeholder?: string
  people?: Autocomplete011Person[]
  defaultValue?: string
  onChange?: (value: string) => void
  accent?: string
}

// Идея компонента: подсказка внутри текста. Список появляется не от фокуса, а
// от собаки перед словом — упоминание живёт посреди фразы, и открывать его
// каждый раз при клике в поле значит мешать письму. Аватар — инициалы на
// оттенке из имени: сорок фотографий ради выпадающего списка не грузим.
const STYLES = `
:where([data-vibeui-block="autocomplete-011"]){
--vibeui-autocomplete-011-bg:oklch(1 0 0);
--vibeui-autocomplete-011-fg:oklch(0.22 0.014 265);
--vibeui-autocomplete-011-muted:oklch(0.52 0.014 265);
--vibeui-autocomplete-011-border:oklch(0.9 0.006 265);
--vibeui-autocomplete-011-field:oklch(0.985 0.002 265);
--vibeui-autocomplete-011-active:oklch(0.95 0.02 265);
--vibeui-autocomplete-011-accent:oklch(0.55 0.17 265);
--vibeui-autocomplete-011-radius:0.625rem;
--vibeui-autocomplete-011-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="autocomplete-011"]{
display:flex;flex-direction:column;gap:0.375rem;
width:100%;max-width:24rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-autocomplete-011-bg);
border:1px solid var(--vibeui-autocomplete-011-border);
border-radius:calc(var(--vibeui-autocomplete-011-radius) + 0.25rem);
color:var(--vibeui-autocomplete-011-fg);
font-family:var(--vibeui-autocomplete-011-font);
}
[data-vibeui-block="autocomplete-011"] label{font-size:0.8125rem;font-weight:600}
[data-vibeui-block="autocomplete-011"] textarea{
box-sizing:border-box;width:100%;min-height:4.5rem;resize:vertical;
padding:0.5rem 0.75rem;
border:1px solid var(--vibeui-autocomplete-011-border);
border-radius:var(--vibeui-autocomplete-011-radius);
background:var(--vibeui-autocomplete-011-field);
color:inherit;font:inherit;font-size:0.875rem;line-height:1.5;
}
[data-vibeui-block="autocomplete-011"] textarea::placeholder{color:var(--vibeui-autocomplete-011-muted)}
[data-vibeui-block="autocomplete-011"] textarea:focus-visible{
outline:2px solid var(--vibeui-autocomplete-011-accent);outline-offset:1px;border-color:transparent;
}
[data-vibeui-block="autocomplete-011"] [data-part="list"]{
margin:0;padding:0.25rem;list-style:none;max-height:11rem;overflow-y:auto;
border:1px solid var(--vibeui-autocomplete-011-border);
border-radius:var(--vibeui-autocomplete-011-radius);
background:var(--vibeui-autocomplete-011-bg);
}
[data-vibeui-block="autocomplete-011"] [data-part="option"]{
display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:0.25rem 0.625rem;
padding:0.375rem 0.5rem;border-radius:0.4375rem;cursor:pointer;
}
[data-vibeui-block="autocomplete-011"] [data-part="option"]:hover{background:var(--vibeui-autocomplete-011-active)}
[data-vibeui-block="autocomplete-011"] [data-part="avatar"]{
grid-row:span 2;display:flex;align-items:center;justify-content:center;
width:1.75rem;height:1.75rem;border-radius:9999px;
background:oklch(0.9 0.05 var(--vibeui-autocomplete-011-hue,265));
color:oklch(0.35 0.09 var(--vibeui-autocomplete-011-hue,265));
font-size:0.6875rem;font-weight:700;
}
[data-vibeui-block="autocomplete-011"] [data-part="name"]{font-size:0.875rem;line-height:1.2}
[data-vibeui-block="autocomplete-011"] [data-part="handle"]{font-size:0.75rem;color:var(--vibeui-autocomplete-011-muted)}
[data-vibeui-block="autocomplete-011"] [data-part="role"]{
grid-column:3;grid-row:span 2;justify-self:end;
font-size:0.6875rem;color:var(--vibeui-autocomplete-011-muted);
}
[data-vibeui-block="autocomplete-011"] [data-part="hint"]{font-size:0.75rem;color:var(--vibeui-autocomplete-011-muted)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="autocomplete-011"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_PEOPLE: Autocomplete011Person[] = [
  { name: "Анна Петрова", handle: "anna", role: "дизайн" },
  { name: "Марк Ильин", handle: "mark", role: "фронтенд" },
  { name: "Мария Гурова", handle: "masha", role: "продукт" },
  { name: "Олег Дроздов", handle: "oleg", role: "бэкенд" },
  { name: "Ирина Ким", handle: "irina", role: "поддержка" },
]

// Оттенок из имени: FNV-1a, разложенный по двенадцати ступеням круга.
// Сумма кодов символов не годится — кириллические имена ложатся в один
// розовый сектор; ступени в 30° дают заведомо различимые цвета.
function hue(name: string) {
  let hash = 2166136261

  for (const symbol of name) {
    hash ^= symbol.codePointAt(0)!
    hash = Math.imul(hash, 16777619)
  }

  return ((hash >>> 0) % 12) * 30
}

function initials(name: string) {
  return name
    .split(" ")
    .slice(0, 2)
    .map((part) => part[0])
    .join("")
}

/**
 * Упоминания по «@» прямо в тексте: список открывает собака, а не фокус.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Autocomplete011({
  label = "Комментарий",
  placeholder = "Напишите и позовите коллегу через @",
  people = DEFAULT_PEOPLE,
  defaultValue = "Проверьте макет, пожалуйста, @ma",
  onChange,
  accent,
  className,
  style,
  ...props
}: Autocomplete011Props) {
  const id = useId()
  const [value, setValue] = useState(defaultValue)
  const [caret, setCaret] = useState(defaultValue.length)

  // Токен упоминания — от последней собаки до курсора, без пробелов внутри.
  const token = useMemo(() => {
    const before = value.slice(0, caret)
    const at = before.lastIndexOf("@")
    if (at < 0) return null
    const word = before.slice(at + 1)
    if (/\s/.test(word)) return null
    return { at, word }
  }, [caret, value])

  const matches = useMemo(() => {
    if (!token) return []
    const needle = token.word.toLowerCase()
    return people.filter(
      (person) =>
        person.handle.startsWith(needle) ||
        person.name.toLowerCase().includes(needle),
    )
  }, [people, token])

  const palette = {
    ...(accent ? { "--vibeui-autocomplete-011-accent": accent } : null),
    ...style,
  } as CSSProperties

  const update = (event: ChangeEvent<HTMLTextAreaElement>) => {
    setValue(event.target.value)
    setCaret(event.target.selectionStart ?? event.target.value.length)
    onChange?.(event.target.value)
  }

  const mention = (handle: string) => {
    if (!token) return
    const next = `${value.slice(0, token.at)}@${handle} ${value.slice(caret)}`
    setValue(next)
    setCaret(token.at + handle.length + 2)
    onChange?.(next)
  }

  return (
    <>
      <style href="vibeui-autocomplete-011" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="autocomplete-011"
        className={className}
        style={palette}
      >
        <label htmlFor={id}>{label}</label>
        <textarea
          id={id}
          placeholder={placeholder}
          value={value}
          aria-describedby={`${id}-hint`}
          onChange={update}
          onKeyUp={(event) => setCaret(event.currentTarget.selectionStart ?? 0)}
          onClick={(event) => setCaret(event.currentTarget.selectionStart ?? 0)}
        />
        {matches.length ? (
          <ul role="listbox" aria-label="Коллеги" data-part="list">
            {matches.map((person) => (
              <li
                key={person.handle}
                role="option"
                aria-selected="false"
                data-part="option"
                style={
                  {
                    "--vibeui-autocomplete-011-hue": hue(person.name),
                  } as CSSProperties
                }
                onMouseDown={(event) => {
                  event.preventDefault()
                  mention(person.handle)
                }}
              >
                <span data-part="avatar" aria-hidden="true">
                  {initials(person.name)}
                </span>
                <span data-part="name">{person.name}</span>
                <span data-part="role">{person.role}</span>
                <span data-part="handle">@{person.handle}</span>
              </li>
            ))}
          </ul>
        ) : null}
        <span data-part="hint" id={`${id}-hint`}>
          Список открывается после @ и закрывается на пробеле
        </span>
      </div>
    </>
  )
}