Inputs
Tree Search
Каскад с поиском по всему дереву сразу: результат показывает полный путь до узла и подсвечивает совпавшую часть названия.
- cascader
- search
- tree
- filter
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/cascader-006
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «cascader-006» (Tree Search) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/cascader-006.json
Registry item: https://vibeui.ru/r/cascader-006.json
Ставится в: components/vibeui/cascader-006.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Каскад с поиском по всему дереву сразу: результат показывает полный путь до узла и подсвечивает совпавшую часть названия.
Поиск по всему дереву каскада: плоский список результатов, полный путь под каждым именем и подсветка совпадения. Один файл, ноль зависимостей.
## 3. Как использовать
import { Cascader006 } from "@/components/vibeui/cascader-006"
<Cascader006 label="Раздел настроек" placeholder="Искать по всем уровням" />
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- локальную палитру --vibeui-cascader-006-* и не подменять её токенами вашей темы
- полный путь под именем результата: одинаковые листья из разных веток иначе неразличимы
- поиск и по пути тоже — иначе запрос по названию родителя не находит ничего
- подсветку совпадения через <mark>: без неё непонятно, почему строка попала в выдачу
- фиксированную высоту списка результатов — панель иначе прыгает на каждый символ запроса
- собственную светлую подложку панели: на тёмной карточке каталога тёмный текст пропадает
## 6. Можно менять
- подпись поля через label
- подсказку внутри поля через placeholder
- дерево через tree и акцентный цвет через accent
- высоту списка результатов в CSS
## 7. Правила
- Поиск линейный по всем листьям: на дереве в десятки тысяч узлов его надо выносить на сервер.
- Подсветка ищет совпадение только в имени листа — совпадение в пути видно по строке пути, но не выделено.
- Пустой запрос показывает всё дерево целиком: это осознанный выбор вместо пустой панели.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
npx shadcn@latest add https://vibeui.ru/r/cascader-006.jsonhttps://vibeui.ru/r/cascader-006.jsonКомпонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-cascader-006-*. Клиентский: запрос и выбранный результат живут в useState, плоский список листьев считается один раз через useMemo. Поиск смотрит и на путь, и на имя листа, поэтому «инфра» находит узлы внутри ветки «Инфраструктура». Совпадение подсвечивается через <mark>, а под именем идёт путь: без него два одинаковых «Общие настройки» из разных веток неразличимы. Пустой запрос показывает все листья, а не пустоту.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"use client"
import { useId, useMemo, useState } from "react"
import type { CSSProperties } from "react"
export type Cascader006Node = {
label: string
children?: Cascader006Node[]
}
export type Cascader006Props = {
label?: string
placeholder?: string
tree?: Cascader006Node[]
accent?: string
className?: string
style?: CSSProperties
}
// Идея компонента: в глубоком дереве быстрее найти, чем пройти. Поиск идёт
// сразу по всем уровням, а в результате показывается полный путь до узла —
// без него два одинаковых «Общие» из разных веток неразличимы. Совпавшая
// часть подсвечивается, чтобы было видно, почему строка попала в выдачу.
// Пустой запрос показывает верхний уровень: пустая панель выглядит поломкой.
const STYLES = `
:where([data-vibeui-block="cascader-006"]){
--vibeui-cascader-006-bg:oklch(1 0 0);
--vibeui-cascader-006-field:oklch(0.975 0.003 265);
--vibeui-cascader-006-fg:oklch(0.23 0.014 265);
--vibeui-cascader-006-muted:oklch(0.55 0.012 265);
--vibeui-cascader-006-border:oklch(0.9 0.006 265);
--vibeui-cascader-006-accent:oklch(0.55 0.19 150);
--vibeui-cascader-006-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="cascader-006"]{
display:flex;flex-direction:column;gap:0.625rem;
width:100%;max-width:24rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-cascader-006-bg);color:var(--vibeui-cascader-006-fg);
border:1px solid var(--vibeui-cascader-006-border);border-radius:1rem;
font-family:var(--vibeui-cascader-006-font);
box-shadow:0 18px 40px -32px oklch(0.2 0.03 265 / 55%);
}
[data-vibeui-block="cascader-006"] *{box-sizing:border-box}
[data-vibeui-block="cascader-006"] [data-part="label"]{
font-size:0.75rem;font-weight:650;letter-spacing:-0.01em;
}
[data-vibeui-block="cascader-006"] [data-part="field"]{
display:flex;align-items:center;gap:0.5rem;
padding:0.4375rem 0.625rem;border-radius:0.75rem;
border:1px solid var(--vibeui-cascader-006-border);
background:var(--vibeui-cascader-006-field);
transition:border-color .14s ease;
}
[data-vibeui-block="cascader-006"] [data-part="field"]:focus-within{
border-color:var(--vibeui-cascader-006-accent);
}
[data-vibeui-block="cascader-006"] [data-part="glass"]{
flex:0 0 auto;width:0.75rem;height:0.75rem;border-radius:50%;
border:1.5px solid var(--vibeui-cascader-006-muted);position:relative;
}
[data-vibeui-block="cascader-006"] [data-part="glass"]::after{
content:"";position:absolute;right:-0.25rem;bottom:-0.1875rem;
width:0.3125rem;height:1.5px;border-radius:1px;
background:var(--vibeui-cascader-006-muted);transform:rotate(45deg);
}
[data-vibeui-block="cascader-006"] input{
flex:1 1 auto;min-width:0;border:0;background:transparent;color:inherit;
font:inherit;font-size:0.8125rem;outline:none;
}
[data-vibeui-block="cascader-006"] [data-part="count"]{
flex:0 0 auto;font-size:0.6875rem;color:var(--vibeui-cascader-006-muted);
}
[data-vibeui-block="cascader-006"] [data-part="results"]{
display:flex;flex-direction:column;gap:0.125rem;margin:0;padding:0;list-style:none;
height:11rem;overflow:auto;overscroll-behavior:contain;
}
[data-vibeui-block="cascader-006"] [data-part="hit"]{
display:flex;flex-direction:column;gap:0.125rem;width:100%;
padding:0.4375rem 0.5rem;border:0;border-radius:0.625rem;
background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;
transition:background-color .14s ease;
}
[data-vibeui-block="cascader-006"] [data-part="hit"]:hover{
background:color-mix(in oklab,var(--vibeui-cascader-006-accent) 10%,transparent);
}
[data-vibeui-block="cascader-006"] [data-part="hit"]:focus-visible{
outline:2px solid var(--vibeui-cascader-006-accent);outline-offset:-2px;
}
[data-vibeui-block="cascader-006"] [data-part="hit-name"]{
font-size:0.8125rem;font-weight:600;line-height:1.25;
}
[data-vibeui-block="cascader-006"] [data-part="hit-name"] mark{
background:color-mix(in oklab,var(--vibeui-cascader-006-accent) 28%,transparent);
color:inherit;border-radius:0.1875rem;padding:0 0.0625rem;
}
[data-vibeui-block="cascader-006"] [data-part="hit-path"]{
font-size:0.6875rem;line-height:1.3;color:var(--vibeui-cascader-006-muted);
}
[data-vibeui-block="cascader-006"] [data-part="empty"]{
margin:0;padding:1.5rem 0.5rem;text-align:center;
font-size:0.75rem;line-height:1.4;color:var(--vibeui-cascader-006-muted);
}
@media (prefers-reduced-motion:reduce){
[data-vibeui-block="cascader-006"] *{animation:none!important;transition:none!important}
}
`
const DEFAULT_TREE: Cascader006Node[] = [
{
label: "Продукт",
children: [
{
label: "Онбординг",
children: [
{ label: "Приветственный экран" },
{ label: "Импорт данных" },
{ label: "Общие настройки" },
],
},
{
label: "Уведомления",
children: [{ label: "Почта" }, { label: "Пуш-сообщения" }],
},
],
},
{
label: "Инфраструктура",
children: [
{
label: "Хранилище",
children: [{ label: "Резервные копии" }, { label: "Общие настройки" }],
},
{
label: "Сеть",
children: [{ label: "Домены" }, { label: "Сертификаты" }],
},
],
},
{
label: "Поддержка",
children: [
{
label: "Обращения",
children: [{ label: "Очередь" }, { label: "Шаблоны ответов" }],
},
],
},
]
type Hit = { label: string; path: string[] }
function flatten(nodes: Cascader006Node[], trail: string[] = []): Hit[] {
return nodes.flatMap((node) =>
node.children?.length
? flatten(node.children, [...trail, node.label])
: [{ label: node.label, path: trail }],
)
}
function split(label: string, query: string) {
const at = label.toLowerCase().indexOf(query.toLowerCase())
if (!query || at < 0) {
return [label, "", ""] as const
}
return [
label.slice(0, at),
label.slice(at, at + query.length),
label.slice(at + query.length),
] as const
}
/**
* Каскад с поиском по всему дереву: результат несёт полный путь до узла.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Cascader006({
label = "Раздел настроек",
placeholder = "Искать по всем уровням",
tree = DEFAULT_TREE,
accent,
className,
style,
}: Cascader006Props) {
const id = useId()
const [query, setQuery] = useState("общие")
const [chosen, setChosen] = useState<string | null>(null)
const all = useMemo(() => flatten(tree), [tree])
const trimmed = query.trim()
const hits = trimmed
? all.filter((hit) =>
[...hit.path, hit.label]
.join(" ")
.toLowerCase()
.includes(trimmed.toLowerCase()),
)
: all
const palette = {
...(accent ? { "--vibeui-cascader-006-accent": accent } : null),
...style,
} as CSSProperties
return (
<>
<style href="vibeui-cascader-006" precedence="medium">
{STYLES}
</style>
<div
data-vibeui-block="cascader-006"
className={className}
style={palette}
>
<label data-part="label" htmlFor={id}>
{label}
</label>
<div data-part="field">
<i data-part="glass" aria-hidden="true" />
<input
id={id}
type="search"
value={query}
placeholder={placeholder}
onChange={(event) => setQuery(event.target.value)}
/>
<span data-part="count" aria-live="polite">
{hits.length}
</span>
</div>
{hits.length ? (
<ul data-part="results">
{hits.map((hit) => {
const [before, match, after] = split(hit.label, trimmed)
return (
<li key={[...hit.path, hit.label].join("/")}>
<button
data-part="hit"
type="button"
aria-current={
chosen === [...hit.path, hit.label].join(" › ")
? "true"
: undefined
}
onClick={() =>
setChosen([...hit.path, hit.label].join(" › "))
}
>
<span data-part="hit-name">
{before}
{match ? <mark>{match}</mark> : null}
{after}
</span>
<span data-part="hit-path">{hit.path.join(" › ")}</span>
</button>
</li>
)
})}
</ul>
) : (
<p data-part="empty">
Ничего не найдено. Попробуйте название родительского раздела — поиск
смотрит и на путь тоже.
</p>
)}
</div>
</>
)
}