Combobox

Section Then Value

A “section then value” picker: sections on the left, their contents on the right, and the moment you type, both panes collapse into one cross-section list of matches.

  • combobox
  • hierarchy
  • two-pane
  • search

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/combobox-013?lang=en

Выбрано: Акт сверки · Бухгалтерия

Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "combobox-013" (Section Then Value) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/combobox-013.json

Registry item: https://vibeui.ru/r/combobox-013.json
Installs to: components/vibeui/combobox-013.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
A “section then value” picker: sections on the left, their contents on the right, and the moment you type, both panes collapse into one cross-section list of matches.

A picker for a catalogue split into sections: the section list with counts on the left, the values of the active section on the right. While the field is empty you navigate the hierarchy; the moment someone types, the search runs across every section and each match is labelled with the section it came from. Zero dependencies, one file, its own palette.

## 3. How to use it
import { Combobox013 } from "@/components/vibeui/combobox-013"

<Combobox013 label="Document type" placeholder="Search" />

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-combobox-013-* palette — do not swap it for your theme tokens (bg-popover, text-muted-foreground and the like)
- the two modes switched by a non-empty query: hierarchy for browsing, a flat list for searching — that is the core of this variant
- the section caption on a match: without it two identical names from different sections are indistinguishable
- hiding the left pane with hidden instead of unmounting it — otherwise the panel width jumps while typing
- role="listbox" on the right pane and role="option" on the buttons inside li role="none": otherwise the list is not announced as one
- the per-section counts: they show where there is anything to pick at all
- the summary line with aria-live="polite" naming both the value and its section

## 6. You may change
- label — the field caption above the list
- placeholder — the hint text in the search field
- sections — the catalogue itself: an array of sections with their items
- defaultValue — the value selected at first render
- onSelect — the handler; it receives the value and the section it came from
- accent — the colour of the active section and the selected value

## 7. Rules
- Two panes do not cover three levels of nesting — that case needs a cascader, not this variant.
- Do not alphabetise sections that have a meaningful order: people memorise where a section sits.
- Matching uses a plain includes: fuzzy search for typos has to be added deliberately.
- Do not drop the counts: an empty section without a number looks broken rather than empty.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

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

Компонент самодостаточен: один файл, ноль зависимостей, собственная палитра в локальных переменных --vibeui-combobox-013-*. Клиентский: запрос, активный раздел и значение живут в useState. Раскладка — grid из двух колонок, левая прокручивается отдельно от правой. Ключевая механика в useMemo: пока запрос пуст, правая колонка показывает элементы активного раздела, а при непустом запросе список собирается flatMap по всем разделам и у каждой строки появляется подпись раздела [data-part="where"] — так поиск не ломает иерархию, а временно её выпрямляет. Левая колонка скрывается атрибутом hidden, а не размонтированием, поэтому не дёргается ширина. ARIA: input с role="combobox", правая колонка — role="listbox", строки — кнопки с role="option" внутри li role="none", поэтому доступна клавиатура без ручного управления activedescendant.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

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

export type Combobox013Section = { title: string; items: string[] }

export type Combobox013Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onSelect"
> & {
  label?: string
  placeholder?: string
  sections?: Combobox013Section[]
  defaultValue?: string
  onSelect?: (value: string, section: string) => void
  accent?: string
}

// Идея компонента: в длинном справочнике человек чаще помнит раздел, а не
// точное название. Поэтому список разложен на две колонки: слева разделы,
// справа значения выбранного раздела. Поиск при этом остаётся сквозным —
// как только в поле появляется текст, колонки схлопываются в один список
// совпадений с подписью раздела у каждой строки.
const STYLES = `
:where([data-vibeui-block="combobox-013"]){
--vibeui-combobox-013-bg:oklch(1 0 0);
--vibeui-combobox-013-fg:oklch(0.22 0.014 250);
--vibeui-combobox-013-muted:oklch(0.55 0.014 250);
--vibeui-combobox-013-border:oklch(0.9 0.008 250);
--vibeui-combobox-013-field:oklch(0.985 0.004 250);
--vibeui-combobox-013-soft:oklch(0.96 0.008 250);
--vibeui-combobox-013-accent:oklch(0.5 0.13 250);
--vibeui-combobox-013-accentsoft:oklch(0.94 0.04 250);
--vibeui-combobox-013-radius:0.625rem;
--vibeui-combobox-013-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="combobox-013"]{
display:flex;flex-direction:column;gap:0.4rem;
width:100%;max-width:24rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-combobox-013-bg);
border:1px solid var(--vibeui-combobox-013-border);
border-radius:calc(var(--vibeui-combobox-013-radius) + 0.25rem);
color:var(--vibeui-combobox-013-fg);
font-family:var(--vibeui-combobox-013-font);
}
[data-vibeui-block="combobox-013"] label{font-size:0.8125rem;font-weight:600}
[data-vibeui-block="combobox-013"] input{
box-sizing:border-box;width:100%;height:2.4rem;padding:0 0.6rem;
border:1px solid var(--vibeui-combobox-013-border);
border-radius:var(--vibeui-combobox-013-radius);
background:var(--vibeui-combobox-013-field);
color:inherit;font:inherit;font-size:0.875rem;
}
[data-vibeui-block="combobox-013"] input::placeholder{color:var(--vibeui-combobox-013-muted)}
[data-vibeui-block="combobox-013"] input:focus-visible{
outline:2px solid var(--vibeui-combobox-013-accent);outline-offset:1px;border-color:transparent;
}
[data-vibeui-block="combobox-013"] [data-part="panes"]{
display:grid;grid-template-columns:8.5rem 1fr;gap:0.4rem;
border:1px solid var(--vibeui-combobox-013-border);
border-radius:var(--vibeui-combobox-013-radius);
padding:0.3rem;
}
[data-vibeui-block="combobox-013"] [data-part="sections"],
[data-vibeui-block="combobox-013"] [data-part="values"]{
margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:0.1rem;
max-height:11rem;overflow:auto;
}
[data-vibeui-block="combobox-013"] [data-part="sections"]{
border-right:1px solid var(--vibeui-combobox-013-border);padding-right:0.3rem;
}
[data-vibeui-block="combobox-013"] [data-part="section"],
[data-vibeui-block="combobox-013"] [data-part="value"]{
appearance:none;cursor:pointer;font:inherit;width:100%;
display:flex;align-items:center;justify-content:space-between;gap:0.35rem;
box-sizing:border-box;padding:0.4rem 0.5rem;
border:0;border-radius:0.45rem;background:transparent;color:inherit;
font-size:0.8125rem;text-align:left;
transition:background-color .16s ease;
}
[data-vibeui-block="combobox-013"] [data-part="section"]:hover,
[data-vibeui-block="combobox-013"] [data-part="value"]:hover{background:var(--vibeui-combobox-013-soft)}
[data-vibeui-block="combobox-013"] [data-part="section"]:focus-visible,
[data-vibeui-block="combobox-013"] [data-part="value"]:focus-visible{
outline:2px solid var(--vibeui-combobox-013-accent);outline-offset:-2px;
}
[data-vibeui-block="combobox-013"] [data-part="section"][aria-pressed="true"]{
background:var(--vibeui-combobox-013-accentsoft);font-weight:600;
}
[data-vibeui-block="combobox-013"] [data-part="value"][aria-selected="true"]{
background:var(--vibeui-combobox-013-accent);color:var(--vibeui-combobox-013-bg);font-weight:600;
}
[data-vibeui-block="combobox-013"] [data-part="count"]{
flex:none;font-size:0.7rem;color:var(--vibeui-combobox-013-muted);font-variant-numeric:tabular-nums;
}
[data-vibeui-block="combobox-013"] [data-part="section"][aria-pressed="true"] [data-part="count"]{color:inherit}
[data-vibeui-block="combobox-013"] [data-part="where"]{
flex:none;font-size:0.68rem;color:var(--vibeui-combobox-013-muted);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:7rem;
}
[data-vibeui-block="combobox-013"] [data-part="value"][aria-selected="true"] [data-part="where"]{color:inherit}
[data-vibeui-block="combobox-013"] [data-part="empty"]{
margin:0;padding:0.6rem 0.5rem;font-size:0.8125rem;color:var(--vibeui-combobox-013-muted);
}
[data-vibeui-block="combobox-013"] [data-part="foot"]{
margin:0;font-size:0.78rem;color:var(--vibeui-combobox-013-muted);
}
[data-vibeui-block="combobox-013"] [data-part="foot"] b{color:var(--vibeui-combobox-013-fg)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="combobox-013"] *{animation:none!important;transition:none!important}}
`

const CATALOG: Combobox013Section[] = [
  {
    title: "Бухгалтерия",
    items: [
      "Акт сверки",
      "Счёт на оплату",
      "Товарная накладная",
      "Авансовый отчёт",
    ],
  },
  {
    title: "Кадры",
    items: ["Заявление на отпуск", "Трудовой договор", "Приказ о переводе"],
  },
  {
    title: "Закупки",
    items: ["Заявка на закупку", "Спецификация", "Протокол выбора поставщика"],
  },
  {
    title: "Юристы",
    items: ["Доверенность", "Претензия", "Соглашение о неразглашении"],
  },
]

/**
 * Выбор по иерархии «раздел → значение» с двумя колонками и сквозным поиском.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Combobox013({
  label = "Тип документа",
  placeholder = "Поиск по всем разделам",
  sections = CATALOG,
  defaultValue = "Акт сверки",
  onSelect,
  accent,
  className,
  style,
  ...props
}: Combobox013Props) {
  const id = useId()
  const [query, setQuery] = useState("")
  const [section, setSection] = useState(sections[0]?.title ?? "")
  const [value, setValue] = useState(defaultValue)

  const searching = query.trim().length > 0

  const matches = useMemo(() => {
    const needle = query.trim().toLowerCase()

    if (!needle) {
      const current = sections.find((entry) => entry.title === section)

      return (current?.items ?? []).map((item) => ({
        item,
        section: current?.title ?? "",
      }))
    }

    return sections.flatMap((entry) =>
      entry.items
        .filter((item) => item.toLowerCase().includes(needle))
        .map((item) => ({ item, section: entry.title })),
    )
  }, [query, section, sections])

  const commit = (item: string, from: string) => {
    setValue(item)
    setSection(from)
    setQuery("")
    onSelect?.(item, from)
  }

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

  return (
    <>
      <style href="vibeui-combobox-013" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="combobox-013"
        className={className}
        style={palette}
      >
        <label htmlFor={`${id}-input`}>{label}</label>
        <input
          id={`${id}-input`}
          type="text"
          role="combobox"
          autoComplete="off"
          placeholder={placeholder}
          aria-expanded="true"
          aria-controls={`${id}-values`}
          aria-autocomplete="list"
          value={query}
          onChange={(event) => setQuery(event.target.value)}
        />
        <div data-part="panes">
          <ul data-part="sections" aria-label="Разделы" hidden={searching}>
            {sections.map((entry) => (
              <li key={entry.title}>
                <button
                  type="button"
                  data-part="section"
                  aria-pressed={entry.title === section}
                  onClick={() => setSection(entry.title)}
                >
                  <span>{entry.title}</span>
                  <span data-part="count">{entry.items.length}</span>
                </button>
              </li>
            ))}
          </ul>
          <ul
            id={`${id}-values`}
            role="listbox"
            aria-label={searching ? "Совпадения" : section}
            data-part="values"
          >
            {matches.length === 0 ? (
              <li role="none">
                <p data-part="empty">Ничего не нашлось</p>
              </li>
            ) : (
              matches.map((match) => (
                <li key={`${match.section}-${match.item}`} role="none">
                  <button
                    type="button"
                    role="option"
                    data-part="value"
                    aria-selected={match.item === value}
                    onClick={() => commit(match.item, match.section)}
                  >
                    <span>{match.item}</span>
                    {searching ? (
                      <span data-part="where">{match.section}</span>
                    ) : null}
                  </button>
                </li>
              ))
            )}
          </ul>
        </div>
        <p data-part="foot" aria-live="polite">
          Выбрано: <b>{value || "ничего"}</b>
          {value ? ` · ${section}` : ""}
        </p>
      </div>
    </>
  )
}