Inputs

Full Path Field

Каскадер, который после выбора держит в поле весь путь, а не последний уровень: длинный путь обрезается слева, поэтому конец всегда виден.

  • cascader
  • path
  • truncation
  • field

Превью

1440pxHost theme

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

  1. 1. Скопируйте ссылку.
  2. 2. Напишите агенту своими словами и вставьте её в предложение.
  3. 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.json
https://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>
    </>
  )
}