Inputs

Rubric Selects

A classified-ad rubric built from three native selects: the cascade uses the system picker on a phone, and changing an upper level resets the lower ones.

  • cascader
  • select
  • mobile
  • classifieds

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/cascader-015?lang=en

Ad rubric

Объявление уйдёт в рубрику Транспорт / Автомобили / С пробегом

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 "cascader-015" (Rubric Selects) 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/cascader-015.json

Registry item: https://vibeui.ru/r/cascader-015.json
Installs to: components/vibeui/cascader-015.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 classified-ad rubric built from three native selects: the cascade uses the system picker on a phone, and changing an upper level resets the lower ones.

A cascade of three native dropdowns: section, category, sub-rubric. On a phone that means the system picker instead of a hand-built panel; on desktop it is a familiar select with keyboard support out of the box. Changing an upper level resets the lower ones, and when a category has no third level the field dims instead of disappearing. Zero dependencies, one file, its own palette.

## 3. How to use it
import { Cascader015 } from "@/components/vibeui/cascader-015"

<Cascader015 label="Ad rubric" />

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-cascader-015-* palette — do not swap it for your theme tokens (bg-input, border-input and the like)
- native <select> elements instead of hand-built lists: on mobile they bring the system picker, autofill and keyboard for free
- resetting lower levels when an upper one changes: otherwise a rubric survives into a section that does not have it
- keeping the path as a single array in state — three separate useState hooks drift apart on the first edit
- dimming an unused level instead of removing it: a disappearing field makes the form jump
- the <fieldset> root with a <legend> and a <label> on every select
- the readiness check that accounts for categories without a third level — otherwise the form could never be submitted

## 6. You may change
- label — the heading of the field group
- tree — the rubric tree, of any breadth
- defaultPath — the path selected at first render
- levelLabels — the level captions, also read by screen readers
- onChange — the handler for the path as an array of strings
- accent — the focus colour and the summary background

## 7. Rules
- A native <select> resists deep styling: if the design needs icons inside options, use a cascader with its own list.
- More than three levels of dropdowns is tiring: at that point a step-by-step panel is warranted.
- Values are stored by name: if rubrics have ids, track the path by id and only display the names.
- Do not hide the leading “not selected” option: without it the empty state is unreachable.

## 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/cascader-015.json
https://vibeui.ru/r/cascader-015.json

Компонент самодостаточен: один файл, ноль зависимостей, собственная палитра в локальных переменных --vibeui-cascader-015-*. Клиентский: путь по дереву живёт одним массивом в useState. Каждый уровень — нативный <select> со своей <label>, корень — <fieldset> с <legend>, поэтому вся группа объявляется формой. Смена уровня режет путь по глубине (path.slice(0, depth)) и дописывает новое значение, поэтому нижние уровни сбрасываются автоматически, без ручной синхронизации. Уровень без вариантов не прячется, а получает disabled и подпись «уровень не нужен»: исчезающее поле сдвигает форму. Признак готовности учитывает, что у части категорий третьего уровня нет, — иначе кнопка публикации никогда бы не разблокировалась. Анимаций нет намеренно: нативный select рисует систему.

Component source

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

"use client"

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

export type Cascader015Node = { name: string; children?: Cascader015Node[] }

export type Cascader015Props = Omit<
  ComponentPropsWithoutRef<"fieldset">,
  "children" | "onChange"
> & {
  label?: string
  tree?: Cascader015Node[]
  defaultPath?: string[]
  levelLabels?: string[]
  onChange?: (path: string[]) => void
  accent?: string
}

// Идея компонента: рубрику объявления заполняют с телефона, где выпадающий
// список — родной элемент системы, а самодельная панель со списками мешает.
// Поэтому каскад собран из трёх нативных <select>: он работает без JS до
// гидрации, открывается системным колесом на iOS и не ломает автозаполнение.
// Смена верхнего уровня сбрасывает нижние — иначе останется рубрика,
// которой в новом разделе нет.
const STYLES = `
:where([data-vibeui-block="cascader-015"]){
--vibeui-cascader-015-bg:oklch(1 0 0);
--vibeui-cascader-015-fg:oklch(0.22 0.014 60);
--vibeui-cascader-015-muted:oklch(0.55 0.014 60);
--vibeui-cascader-015-border:oklch(0.9 0.008 60);
--vibeui-cascader-015-field:oklch(0.985 0.004 60);
--vibeui-cascader-015-accent:oklch(0.55 0.13 60);
--vibeui-cascader-015-accentsoft:oklch(0.95 0.05 60);
--vibeui-cascader-015-radius:0.625rem;
--vibeui-cascader-015-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="cascader-015"]{
display:flex;flex-direction:column;gap:0.5rem;
width:100%;max-width:22rem;box-sizing:border-box;
margin:0;padding:0.875rem;
background:var(--vibeui-cascader-015-bg);
border:1px solid var(--vibeui-cascader-015-border);
border-radius:calc(var(--vibeui-cascader-015-radius) + 0.25rem);
color:var(--vibeui-cascader-015-fg);
font-family:var(--vibeui-cascader-015-font);
}
[data-vibeui-block="cascader-015"] legend{
padding:0;font-size:0.875rem;font-weight:700;letter-spacing:-0.01em;
}
[data-vibeui-block="cascader-015"] [data-part="level"]{
display:flex;flex-direction:column;gap:0.2rem;
}
[data-vibeui-block="cascader-015"] [data-part="level"] label{
font-size:0.7rem;font-weight:600;letter-spacing:0.03em;text-transform:uppercase;
color:var(--vibeui-cascader-015-muted);
}
[data-vibeui-block="cascader-015"] select{
box-sizing:border-box;width:100%;height:2.5rem;padding:0 0.55rem;
border:1px solid var(--vibeui-cascader-015-border);
border-radius:var(--vibeui-cascader-015-radius);
background:var(--vibeui-cascader-015-field);
color:inherit;font:inherit;font-size:0.875rem;
}
[data-vibeui-block="cascader-015"] select:focus-visible{
outline:2px solid var(--vibeui-cascader-015-accent);outline-offset:1px;border-color:transparent;
}
[data-vibeui-block="cascader-015"] select:disabled{
cursor:not-allowed;opacity:.55;background:var(--vibeui-cascader-015-bg);
}
[data-vibeui-block="cascader-015"] [data-part="path"]{
margin:0;padding:0.5rem 0.65rem;border-radius:var(--vibeui-cascader-015-radius);
background:var(--vibeui-cascader-015-accentsoft);
font-size:0.8125rem;line-height:1.35;
}
[data-vibeui-block="cascader-015"] [data-part="path"] b{font-weight:700}
[data-vibeui-block="cascader-015"] [data-part="path"][data-ready="false"]{
background:var(--vibeui-cascader-015-field);color:var(--vibeui-cascader-015-muted);
}
`

const RUBRICS: Cascader015Node[] = [
  {
    name: "Транспорт",
    children: [
      {
        name: "Автомобили",
        children: [{ name: "С пробегом" }, { name: "Новые" }],
      },
      { name: "Мотоциклы" },
      {
        name: "Запчасти",
        children: [{ name: "Кузов" }, { name: "Двигатель" }],
      },
    ],
  },
  {
    name: "Недвижимость",
    children: [
      {
        name: "Квартиры",
        children: [{ name: "Продам" }, { name: "Сдам" }],
      },
      { name: "Гаражи" },
    ],
  },
  {
    name: "Работа",
    children: [
      {
        name: "Вакансии",
        children: [{ name: "Полный день" }, { name: "Подработка" }],
      },
      { name: "Резюме" },
    ],
  },
]

/**
 * Рубрика объявления тремя нативными списками с каскадным сбросом.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Cascader015({
  label = "Рубрика объявления",
  tree = RUBRICS,
  defaultPath = ["Транспорт", "Автомобили", "С пробегом"],
  levelLabels = ["Раздел", "Категория", "Подрубрика"],
  onChange,
  accent,
  className,
  style,
  ...props
}: Cascader015Props) {
  const id = useId()
  const [path, setPath] = useState(defaultPath)

  const first = tree
  const second = tree.find((node) => node.name === path[0])?.children ?? []
  const third = second.find((node) => node.name === path[1])?.children ?? []

  const levels = [first, second, third]

  const change = (depth: number, value: string) => {
    const next = [...path.slice(0, depth), value].filter(Boolean)

    setPath(next)
    onChange?.(next)
  }

  const ready = third.length === 0 ? Boolean(path[1]) : Boolean(path[2])

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

  return (
    <>
      <style href="vibeui-cascader-015" precedence="medium">
        {STYLES}
      </style>
      <fieldset
        {...props}
        data-vibeui-block="cascader-015"
        className={className}
        style={palette}
      >
        <legend>{label}</legend>
        {levels.map((options, depth) => (
          <div key={levelLabels[depth]} data-part="level">
            <label htmlFor={`${id}-level-${depth}`}>{levelLabels[depth]}</label>
            <select
              id={`${id}-level-${depth}`}
              value={path[depth] ?? ""}
              disabled={options.length === 0}
              onChange={(event) => change(depth, event.target.value)}
            >
              <option value="">
                {options.length === 0 ? "уровень не нужен" : "не выбрано"}
              </option>
              {options.map((node) => (
                <option key={node.name} value={node.name}>
                  {node.name}
                </option>
              ))}
            </select>
          </div>
        ))}
        <p data-part="path" data-ready={ready} aria-live="polite">
          {ready ? (
            <>
              Объявление уйдёт в рубрику <b>{path.join(" / ")}</b>
            </>
          ) : (
            "Выберите рубрику до конца — от неё зависят поля объявления"
          )}
        </p>
      </fieldset>
    </>
  )
}