Combobox

Loading Empty

Combobox с двумя забытыми состояниями: полосы-заглушки, пока список едет, и содержательная пустота с готовыми запросами вместо «ничего не найдено».

  • combobox
  • loading
  • empty state
  • skeleton

Превью

1440pxHost theme

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

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

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

    Показать полную инструкцию

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

    # Установка и размещение «combobox-008» (Loading Empty) из VibeUI
    
    ## 1. Сначала установка — не пропускай, не пересоздавай
    Выполни эту команду до того, как писать код:
    npx shadcn@latest add https://vibeui.ru/r/combobox-008.json
    
    Registry item: https://vibeui.ru/r/combobox-008.json
    Ставится в: components/vibeui/combobox-008.tsx (точный путь следует алиасам components.json этого проекта).
    npm-зависимости: нет.
    Registry-зависимости: нет.
    
    Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
    компонентом из другой библиотеки и не переписывай под стиль проекта.
    
    ## 2. Что это
    Combobox с двумя забытыми состояниями: полосы-заглушки, пока список едет, и содержательная пустота с готовыми запросами вместо «ничего не найдено».
    
    Combobox с честными состояниями ожидания и пустоты: скелетон вместо прыжка панели и готовые запросы вместо тупика. Один файл, ноль зависимостей, таймер чистится при каждом вводе.
    
    ## 3. Как использовать
    import { Combobox008 } from "@/components/vibeui/combobox-008"
    
    <Combobox008
      label="Город"
      options={["Прага", "Прато"]}
      suggestions={["Прага", "Пусан"]}
      delay={600}
      onSelect={(value) => console.log(value)}
    />
    
    Полный список пропсов — в установленном файле.
    
    ## 4. Куда поставить
    Это мелкий inline-компонент. Поставь его ровно туда, куда просил
    пользователь, внутрь существующей разметки. Не создавай ради него
    новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
    контрол — замени его, а не добавляй второй.
    
    Куда поставить: ___
    (Эту строку заполняет пользователь. Если она осталась пустой — спроси,
    а не угадывай.)
    
    ## 5. Сохранить как установлено
    - локальную палитру --vibeui-combobox-008-* и не подменять её токенами вашей темы
    - очистку таймера в useEffect и вывод ожидания из состояний: без первого старый ответ перетирает новый, без второго setState в теле эффекта устраивает каскад рендеров
    - min-height у панели и высоту заглушек — иначе панель прыгает и человек промахивается мимо строки
    - aria-busy на поле и role="status" у заглушек: ожидание обязано звучать, а не только мигать
    - кнопки-подсказки в пустой выдаче — тупик без выхода читается как сломанный поиск
    - собственную светлую подложку блока: без неё тёмный текст пропадает на тёмной карточке каталога
    
    ## 6. Можно менять
    - источник строк через options и готовые запросы через suggestions
    - паузу ожидания через delay и стартовый запрос через defaultQuery
    - тексты состояний через loadingLabel и emptyLabel
    - подпись поля через label, заглушку через placeholder и цвет через accent
    
    ## 7. Правила
    - Загрузка здесь имитируется таймером: подставляя fetch, оставьте отмену — иначе гонка ответов вернётся.
    - Три полосы-заглушки — компромисс: больше выглядит честнее, но дольше держит пустую панель на экране.
    - Подсказки в пустой выдаче не проверяются на результативность: следите, чтобы они действительно что-то находили.
    - Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.
    
    ## 8. Проверка
    - рендерится без ошибок в консоли;
    - выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
    - если нет — ты изменил что-то из раздела 5, верни как было.

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

    npx shadcn@latest add https://vibeui.ru/r/combobox-008.json
    https://vibeui.ru/r/combobox-008.json

    Компонент самодостаточен: один файл, без зависимостей, собственная палитра --vibeui-combobox-008-*. Клиентский: состояний два — набранный запрос и показанный, — а ожидание из них выводится: пока показанный не догнал набранный, список считается едущим, и отдельного флага загрузки нет. useEffect только ставит таймер на delay и чистит его при следующем нажатии, поэтому устаревший ответ не перетирает свежий, а синхронного setState в теле эффекта нет. Панель держит min-height, заглушки повторяют высоту строк — панель не прыгает при смене состояния. Поле объявляет aria-busy на время ожидания, блок заглушек — role="status" с подписью, поэтому загрузка проговаривается. Пустая выдача не заканчивается сообщением: под ним лежат кнопки с заведомо результативными запросами. Список скрывается атрибутом hidden, а не удаляется, — aria-controls обязан указывать на существующий узел.

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

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

    "use client"
    
    import { useEffect, useId, useMemo, useRef, useState } from "react"
    import type {
      ComponentPropsWithoutRef,
      CSSProperties,
      KeyboardEvent,
    } from "react"
    
    export type Combobox008Props = Omit<
      ComponentPropsWithoutRef<"div">,
      "children" | "onSelect"
    > & {
      label?: string
      placeholder?: string
      options?: string[]
      suggestions?: string[]
      loadingLabel?: string
      emptyLabel?: string
      delay?: number
      defaultQuery?: string
      onSelect?: (value: string) => void
      accent?: string
    }
    
    // Идея компонента: два состояния, которые обычно забывают. Пока список едет,
    // на его месте стоят полосы-заглушки той же высоты — панель не прыгает.
    // Когда не нашлось ничего, пусто не остаётся пустым: под сообщением лежат
    // готовые запросы, по которым точно что-то есть.
    const STYLES = `
    :where([data-vibeui-block="combobox-008"]){
    --vibeui-combobox-008-bg:oklch(1 0 0);
    --vibeui-combobox-008-fg:oklch(0.23 0.02 350);
    --vibeui-combobox-008-muted:oklch(0.55 0.02 350);
    --vibeui-combobox-008-border:oklch(0.9 0.01 350);
    --vibeui-combobox-008-field:oklch(0.985 0.005 350);
    --vibeui-combobox-008-active:oklch(0.95 0.035 350);
    --vibeui-combobox-008-accent:oklch(0.55 0.17 350);
    --vibeui-combobox-008-radius:0.625rem;
    --vibeui-combobox-008-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
    }
    [data-vibeui-block="combobox-008"]{
    display:flex;flex-direction:column;gap:0.375rem;
    width:100%;max-width:21rem;box-sizing:border-box;padding:0.875rem;
    background:var(--vibeui-combobox-008-bg);
    border:1px solid var(--vibeui-combobox-008-border);
    border-radius:calc(var(--vibeui-combobox-008-radius) + 0.25rem);
    color:var(--vibeui-combobox-008-fg);
    font-family:var(--vibeui-combobox-008-font);
    }
    [data-vibeui-block="combobox-008"] label{font-size:0.8125rem;font-weight:600}
    [data-vibeui-block="combobox-008"] [data-part="field"]{position:relative;display:flex;align-items:center}
    [data-vibeui-block="combobox-008"] input{
    box-sizing:border-box;width:100%;height:2.5rem;padding:0 2.25rem 0 0.75rem;
    border:1px solid var(--vibeui-combobox-008-border);
    border-radius:var(--vibeui-combobox-008-radius);
    background:var(--vibeui-combobox-008-field);
    color:inherit;font:inherit;font-size:0.875rem;
    }
    [data-vibeui-block="combobox-008"] input::placeholder{color:var(--vibeui-combobox-008-muted)}
    [data-vibeui-block="combobox-008"] input:focus-visible{outline:2px solid var(--vibeui-combobox-008-accent);outline-offset:1px;border-color:transparent}
    [data-vibeui-block="combobox-008"] [data-part="spinner"]{
    position:absolute;right:0.75rem;width:0.9rem;height:0.9rem;border-radius:999px;
    border:2px solid var(--vibeui-combobox-008-active);
    border-top-color:var(--vibeui-combobox-008-accent);
    animation:vibeui-combobox-008-spin .7s linear infinite;
    }
    @keyframes vibeui-combobox-008-spin{to{transform:rotate(360deg)}}
    [data-vibeui-block="combobox-008"] [data-part="panel"]{
    padding:0.25rem;min-height:6.5rem;
    border:1px solid var(--vibeui-combobox-008-border);
    border-radius:var(--vibeui-combobox-008-radius);
    }
    [data-vibeui-block="combobox-008"] [data-part="list"]{margin:0;padding:0;list-style:none;max-height:9rem;overflow-y:auto}
    [data-vibeui-block="combobox-008"] [data-part="option"]{
    display:flex;align-items:center;min-height:2rem;padding:0 0.5rem;
    border-radius:0.375rem;font-size:0.875rem;cursor:pointer;
    }
    [data-vibeui-block="combobox-008"] [data-part="option"][data-active="true"]{background:var(--vibeui-combobox-008-active)}
    [data-vibeui-block="combobox-008"] [data-part="skeleton"]{display:flex;flex-direction:column;gap:0.4rem;padding:0.35rem 0.5rem}
    [data-vibeui-block="combobox-008"] [data-part="skeleton"] span{
    height:0.8rem;border-radius:999px;
    background:linear-gradient(90deg,var(--vibeui-combobox-008-active),var(--vibeui-combobox-008-field),var(--vibeui-combobox-008-active));
    background-size:200% 100%;
    animation:vibeui-combobox-008-shine 1.2s ease-in-out infinite;
    }
    [data-vibeui-block="combobox-008"] [data-part="skeleton"] span:nth-child(2){width:78%}
    [data-vibeui-block="combobox-008"] [data-part="skeleton"] span:nth-child(3){width:56%}
    @keyframes vibeui-combobox-008-shine{to{background-position:-200% 0}}
    [data-vibeui-block="combobox-008"] [data-part="empty"]{display:flex;flex-direction:column;gap:0.45rem;padding:0.5rem}
    [data-vibeui-block="combobox-008"] [data-part="emptytitle"]{font-size:0.8125rem;font-weight:600}
    [data-vibeui-block="combobox-008"] [data-part="emptyhint"]{margin:0;font-size:0.75rem;color:var(--vibeui-combobox-008-muted)}
    [data-vibeui-block="combobox-008"] [data-part="chips"]{display:flex;flex-wrap:wrap;gap:0.3rem}
    [data-vibeui-block="combobox-008"] [data-part="chip"]{
    appearance:none;cursor:pointer;height:1.6rem;padding:0 0.6rem;border-radius:999px;
    border:1px solid var(--vibeui-combobox-008-border);
    background:var(--vibeui-combobox-008-field);
    color:inherit;font:inherit;font-size:0.75rem;font-weight:600;
    }
    [data-vibeui-block="combobox-008"] [data-part="chip"]:hover{background:var(--vibeui-combobox-008-active)}
    [data-vibeui-block="combobox-008"] [data-part="chip"]:focus-visible{outline:2px solid var(--vibeui-combobox-008-accent);outline-offset:1px}
    @media (prefers-reduced-motion:reduce){[data-vibeui-block="combobox-008"] *{animation:none!important;transition:none!important}}
    `
    
    const DEFAULT_OPTIONS = [
      "Прага",
      "Прато",
      "Провиденс",
      "Пуэбла",
      "Портленд",
      "Пусан",
      "Познань",
    ]
    
    /**
     * Combobox с состоянием загрузки и содержательной пустотой: полосы-заглушки
     * вместо прыгающей панели, готовые запросы вместо «ничего не найдено».
     */
    export function Combobox008({
      label = "Город",
      placeholder = "Начните вводить",
      options = DEFAULT_OPTIONS,
      suggestions = ["Прага", "Пусан", "Портленд"],
      loadingLabel = "Ищем совпадения",
      emptyLabel = "Ничего не нашлось",
      delay = 600,
      defaultQuery = "",
      onSelect,
      accent,
      className,
      style,
      ...props
    }: Combobox008Props) {
      const id = useId()
      const [query, setQuery] = useState(defaultQuery)
      const [ready, setReady] = useState("")
      const [value, setValue] = useState("")
      const [active, setActive] = useState(0)
      const listRef = useRef<HTMLUListElement>(null)
    
      // Ожидание не хранится состоянием, а выводится: пока показанный запрос
      // не догнал набранный, список считается едущим. Эффект только ставит
      // таймер, а setState живёт в его колбэке — синхронный setState в теле
      // эффекта устроил бы каскад рендеров.
      const loading = query.trim() !== "" && query !== ready
    
      useEffect(() => {
        if (query === ready) return
    
        const timer = setTimeout(() => setReady(query), query.trim() ? delay : 0)
    
        return () => clearTimeout(timer)
      }, [delay, query, ready])
    
      const rows = useMemo(() => {
        const needle = ready.trim().toLowerCase()
        if (!needle) return []
        return options.filter((option) => option.toLowerCase().includes(needle))
      }, [options, ready])
    
      const palette = {
        ...(accent ? { "--vibeui-combobox-008-accent": accent } : null),
        ...style,
      } as CSSProperties
    
      const commit = (option: string) => {
        setValue(option)
        setQuery(option)
        setReady(option)
        onSelect?.(option)
      }
    
      const move = (delta: number) => {
        if (!rows.length) return
        const next = (active + delta + rows.length) % rows.length
        setActive(next)
        listRef.current?.children[next]?.scrollIntoView({ block: "nearest" })
      }
    
      const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
        if (event.key === "ArrowDown") {
          event.preventDefault()
          move(1)
        } else if (event.key === "ArrowUp") {
          event.preventDefault()
          move(-1)
        } else if (event.key === "Enter") {
          event.preventDefault()
          if (rows[active]) commit(rows[active])
        } else if (event.key === "Escape") {
          event.preventDefault()
          setQuery("")
        }
      }
    
      const showEmpty = !loading && query.trim() !== "" && rows.length === 0
    
      return (
        <>
          <style href="vibeui-combobox-008" precedence="medium">
            {STYLES}
          </style>
          <div
            {...props}
            data-vibeui-block="combobox-008"
            className={className}
            style={palette}
          >
            <label htmlFor={`${id}-input`}>{label}</label>
            <div data-part="field">
              <input
                id={`${id}-input`}
                type="text"
                role="combobox"
                autoComplete="off"
                placeholder={placeholder}
                aria-expanded="true"
                aria-controls={`${id}-list`}
                aria-autocomplete="list"
                aria-busy={loading}
                aria-activedescendant={
                  rows[active] ? `${id}-option-${active}` : undefined
                }
                value={query}
                onChange={(event) => {
                  setQuery(event.target.value)
                  setActive(0)
                }}
                onKeyDown={onKeyDown}
              />
              {loading ? <span data-part="spinner" aria-hidden="true" /> : null}
            </div>
            <div data-part="panel">
              {loading ? (
                <div data-part="skeleton" role="status" aria-label={loadingLabel}>
                  <span />
                  <span />
                  <span />
                </div>
              ) : null}
              {showEmpty ? (
                <div data-part="empty">
                  <strong data-part="emptytitle">{emptyLabel}</strong>
                  <p data-part="emptyhint">
                    Проверьте раскладку или попробуйте один из запросов ниже.
                  </p>
                  <div data-part="chips">
                    {suggestions.map((suggestion) => (
                      <button
                        key={suggestion}
                        type="button"
                        data-part="chip"
                        onClick={() => setQuery(suggestion)}
                      >
                        {suggestion}
                      </button>
                    ))}
                  </div>
                </div>
              ) : null}
              <ul
                ref={listRef}
                id={`${id}-list`}
                role="listbox"
                aria-label={label}
                data-part="list"
                hidden={loading || showEmpty}
              >
                {rows.map((option, index) => (
                  <li
                    key={option}
                    id={`${id}-option-${index}`}
                    role="option"
                    data-part="option"
                    data-active={index === active}
                    aria-selected={option === value}
                    onMouseEnter={() => setActive(index)}
                    onMouseDown={(event) => {
                      event.preventDefault()
                      commit(option)
                    }}
                  >
                    {option}
                  </li>
                ))}
              </ul>
            </div>
          </div>
        </>
      )
    }