Inputs
Full Path Field
Каскадер, который после выбора держит в поле весь путь, а не последний уровень: длинный путь обрезается слева, поэтому конец всегда виден.
- cascader
- path
- truncation
- field
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/cascader-020
В поле остаётся весь путь: длинный обрезается слева, начало прячется, последний уровень виден всегда
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «cascader-020» (Full Path Field) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/cascader-020.json
Registry item: https://vibeui.ru/r/cascader-020.json
Ставится в: components/vibeui/cascader-020.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Каскадер, который после выбора держит в поле весь путь, а не последний уровень: длинный путь обрезается слева, поэтому конец всегда виден.
Каскадер, у которого главное — поле, а не панель. После выбора в поле остаётся весь путь «Электроника / Ноутбуки / Игровые», а не одно слово «Игровые», которое вне контекста ничего не значит. Не помещающийся путь обрезается слева средствами CSS, поэтому последний уровень виден всегда, а полный текст лежит в title. Ноль зависимостей, один файл, своя палитра.
## 3. Как использовать
import { Cascader020 } from "@/components/vibeui/cascader-020"
<Cascader020 separator=" / " label="Категория товара" />
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- локальную палитру --vibeui-cascader-020-* и не подменять её токенами вашей темы (bg-input, text-muted-foreground и подобными)
- полный путь в поле вместо последнего уровня: «Игровые» без родителей — это не значение, а обрывок
- обрезку слева через direction: rtl с unicode-bidi: plaintext — она считается по реальной ширине, а не по числу символов
- title с полным путём на кнопке-поле: обрезанный текст должен быть доступен целиком
- отдельное состояние курсора обхода: пока не выбран лист, уже сохранённый путь не должен портиться
- aria-expanded и aria-controls на поле, aria-labelledby со связью с подписью
- закрытие панели после выбора листа: путь в поле и есть подтверждение выбора
## 6. Можно менять
- label — подпись над полем
- tree — дерево категорий произвольной глубины
- defaultPath — путь, выбранный при первом рендере
- separator — разделитель уровней в поле
- onSelect — обработчик пути массивом строк
- accent — цвет выделения и рамки поля при наведении
## 7. Правила
- direction: rtl без unicode-bidi: plaintext переставит куски строки местами — держите обе декларации вместе.
- Панель не закрывается по клику мимо: в реальной форме добавьте обработчик на document или используйте popover.
- Разделитель с пробелами (« / ») читается лучше слитного «/»: без пробелов путь склеивается в одно слово.
- Не делайте разделитель символом, который встречается в названиях категорий, — путь станет неразбираемым.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
npx shadcn@latest add https://vibeui.ru/r/cascader-020.jsonhttps://vibeui.ru/r/cascader-020.jsonКомпонент самодостаточен: один файл, ноль зависимостей, собственная палитра в локальных переменных --vibeui-cascader-020-*. Клиентский: выбранный путь, открытость панели и курсор обхода дерева живут в отдельных useState — курсор специально отделён от результата, поэтому хождение по уровням не портит уже выбранное значение, пока не дойдёт до листа. Обрезка пути слева сделана CSS: у строки direction: rtl вместе с text-overflow: ellipsis переносит многоточие в начало, а unicode-bidi: plaintext оставляет сам текст в естественном порядке. Это подстраивается под реальную ширину поля, в отличие от обрезки по числу символов. Поле — кнопка с aria-expanded, aria-controls и title с полным путём; подпись связана через aria-labelledby.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"use client"
import { useId, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"
export type Cascader020Node = { name: string; children?: Cascader020Node[] }
export type Cascader020Props = Omit<
ComponentPropsWithoutRef<"div">,
"children" | "onSelect"
> & {
label?: string
tree?: Cascader020Node[]
defaultPath?: string[]
separator?: string
onSelect?: (path: string[]) => void
accent?: string
}
// Идея компонента: каскадер, который после выбора показывает в поле только
// последний уровень, врёт: «Игровые» без «Ноутбуки» ничего не значит.
// Поэтому здесь в поле лежит весь путь, а когда он не помещается — обрезается
// слева, а не справа: у пути значим хвост. Обрезку делает CSS через
// direction:rtl, поэтому она подстраивается под реальную ширину поля,
// а не считается по символам.
const STYLES = `
:where([data-vibeui-block="cascader-020"]){
--vibeui-cascader-020-bg:oklch(1 0 0);
--vibeui-cascader-020-fg:oklch(0.22 0.014 20);
--vibeui-cascader-020-muted:oklch(0.55 0.014 20);
--vibeui-cascader-020-border:oklch(0.9 0.008 20);
--vibeui-cascader-020-field:oklch(0.985 0.004 20);
--vibeui-cascader-020-soft:oklch(0.965 0.008 20);
--vibeui-cascader-020-accent:oklch(0.53 0.14 20);
--vibeui-cascader-020-accentsoft:oklch(0.95 0.04 20);
--vibeui-cascader-020-radius:0.625rem;
--vibeui-cascader-020-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="cascader-020"]{
display:flex;flex-direction:column;gap:0.4rem;
width:100%;max-width:21rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-cascader-020-bg);
border:1px solid var(--vibeui-cascader-020-border);
border-radius:calc(var(--vibeui-cascader-020-radius) + 0.25rem);
color:var(--vibeui-cascader-020-fg);
font-family:var(--vibeui-cascader-020-font);
}
[data-vibeui-block="cascader-020"] [data-part="label"]{font-size:0.8125rem;font-weight:600}
[data-vibeui-block="cascader-020"] [data-part="field"]{
appearance:none;cursor:pointer;font:inherit;width:100%;
display:flex;align-items:center;gap:0.4rem;
box-sizing:border-box;min-height:2.5rem;padding:0.35rem 0.6rem;
border:1px solid var(--vibeui-cascader-020-border);
border-radius:var(--vibeui-cascader-020-radius);
background:var(--vibeui-cascader-020-field);color:inherit;text-align:left;
transition:border-color .16s ease;
}
[data-vibeui-block="cascader-020"] [data-part="field"]:hover{border-color:var(--vibeui-cascader-020-accent)}
[data-vibeui-block="cascader-020"] [data-part="field"]:focus-visible{
outline:2px solid var(--vibeui-cascader-020-accent);outline-offset:1px;border-color:transparent;
}
/* Обрезка пути слева: направление письма меняется только у контейнера
строки, а сам текст остаётся в естественном порядке из-за plaintext. */
[data-vibeui-block="cascader-020"] [data-part="pathtext"]{
flex:1 1 auto;min-width:0;
direction:rtl;unicode-bidi:plaintext;text-align:left;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
font-size:0.85rem;font-weight:600;
}
[data-vibeui-block="cascader-020"] [data-part="pathtext"][data-empty="true"]{
font-weight:500;color:var(--vibeui-cascader-020-muted);
}
[data-vibeui-block="cascader-020"] [data-part="chevron"]{
flex:none;color:var(--vibeui-cascader-020-muted);font-size:0.8rem;
}
[data-vibeui-block="cascader-020"] [data-part="panel"]{
display:flex;flex-direction:column;gap:0.3rem;
padding:0.35rem;
border:1px solid var(--vibeui-cascader-020-border);
border-radius:var(--vibeui-cascader-020-radius);
}
[data-vibeui-block="cascader-020"] [data-part="back"]{
appearance:none;cursor:pointer;font:inherit;align-self:flex-start;
padding:0.2rem 0.5rem;border:0;border-radius:0.4rem;
background:var(--vibeui-cascader-020-soft);color:var(--vibeui-cascader-020-muted);
font-size:0.72rem;font-weight:600;
}
[data-vibeui-block="cascader-020"] [data-part="back"]:focus-visible,
[data-vibeui-block="cascader-020"] [data-part="node"]:focus-visible{
outline:2px solid var(--vibeui-cascader-020-accent);outline-offset:-2px;
}
[data-vibeui-block="cascader-020"] ul{
margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:0.1rem;
max-height:11rem;overflow:auto;
}
[data-vibeui-block="cascader-020"] [data-part="node"]{
appearance:none;cursor:pointer;font:inherit;width:100%;
display:flex;align-items:center;justify-content:space-between;gap:0.4rem;
box-sizing:border-box;padding:0.4rem 0.5rem;
border:0;border-radius:0.4rem;background:transparent;color:inherit;text-align:left;
font-size:0.8125rem;
transition:background-color .16s ease;
}
[data-vibeui-block="cascader-020"] [data-part="node"]:hover{background:var(--vibeui-cascader-020-soft)}
[data-vibeui-block="cascader-020"] [data-part="node"][aria-current="true"]{
background:var(--vibeui-cascader-020-accentsoft);font-weight:600;
}
[data-vibeui-block="cascader-020"] [data-part="hint"]{
margin:0;font-size:0.72rem;color:var(--vibeui-cascader-020-muted);
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="cascader-020"] *{animation:none!important;transition:none!important}}
`
const CATALOG: Cascader020Node[] = [
{
name: "Электроника",
children: [
{
name: "Ноутбуки и компьютеры",
children: [
{ name: "Игровые ноутбуки" },
{ name: "Ультрабуки" },
{ name: "Моноблоки" },
],
},
{
name: "Аудиотехника",
children: [{ name: "Наушники" }, { name: "Колонки" }],
},
],
},
{
name: "Дом и сад",
children: [
{
name: "Кухонная техника",
children: [{ name: "Кофемашины" }, { name: "Блендеры" }],
},
{ name: "Инструменты", children: [{ name: "Шуруповёрты" }] },
],
},
]
/**
* Каскадер, показывающий в поле весь путь и обрезающий его слева.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Cascader020({
label = "Категория товара",
tree = CATALOG,
defaultPath = ["Электроника", "Ноутбуки и компьютеры", "Игровые ноутбуки"],
separator = " / ",
onSelect,
accent,
className,
style,
...props
}: Cascader020Props) {
const id = useId()
const [path, setPath] = useState(defaultPath)
const [open, setOpen] = useState(true)
const [cursor, setCursor] = useState<string[]>([])
let level = tree
for (const name of cursor) {
level = level.find((entry) => entry.name === name)?.children ?? []
}
const full = path.join(separator)
const palette = {
...(accent ? { "--vibeui-cascader-020-accent": accent } : null),
...style,
} as CSSProperties
return (
<>
<style href="vibeui-cascader-020" precedence="medium">
{STYLES}
</style>
<div
{...props}
data-vibeui-block="cascader-020"
className={className}
style={palette}
>
<span id={`${id}-label`} data-part="label">
{label}
</span>
<button
type="button"
data-part="field"
aria-labelledby={`${id}-label`}
aria-expanded={open}
aria-controls={`${id}-panel`}
title={full || undefined}
onClick={() => setOpen(!open)}
>
<span data-part="pathtext" data-empty={path.length === 0}>
{full || "Категория не выбрана"}
</span>
<span data-part="chevron" aria-hidden="true">
{open ? "▴" : "▾"}
</span>
</button>
{open ? (
<div id={`${id}-panel`} data-part="panel">
{cursor.length > 0 ? (
<button
type="button"
data-part="back"
onClick={() => setCursor(cursor.slice(0, -1))}
>
← {cursor[cursor.length - 1]}
</button>
) : null}
<ul aria-label={`Уровень ${cursor.length + 1}`}>
{level.map((node) => {
const branch = Boolean(node.children?.length)
const next = [...cursor, node.name]
return (
<li key={node.name}>
<button
type="button"
data-part="node"
aria-current={
!branch && path.join(separator) === next.join(separator)
}
onClick={() => {
if (branch) {
setCursor(next)
return
}
setPath(next)
setOpen(false)
onSelect?.(next)
}}
>
<span>{node.name}</span>
<span aria-hidden="true">{branch ? "›" : "•"}</span>
</button>
</li>
)
})}
</ul>
</div>
) : null}
<p data-part="hint">
В поле остаётся весь путь: длинный обрезается слева, начало прячется,
последний уровень виден всегда
</p>
</div>
</>
)
}