Inputs
Place Picker
Выбор места «здание → этаж → комната», где каждый уровень оформлен по своей природе: вкладки, фишки и сетка плиток с вместимостью.
- cascader
- rooms
- booking
- space
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/cascader-016
Место встречи
Корпус А · 2 этаж · А-201 — 12 мест
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «cascader-016» (Place Picker) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/cascader-016.json
Registry item: https://vibeui.ru/r/cascader-016.json
Ставится в: components/vibeui/cascader-016.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Выбор места «здание → этаж → комната», где каждый уровень оформлен по своей природе: вкладки, фишки и сетка плиток с вместимостью.
Каскадер для помещений: здание вкладками, этаж фишками, комната плиткой в сетке. Последний уровень пространственный, и сетка плиток читается ближе к плану этажа, чем строка списка: видно вместимость, видно занятые. Ноль зависимостей, один файл, своя палитра.
## 3. Как использовать
import { Cascader016 } from "@/components/vibeui/cascader-016"
<Cascader016 label="Место встречи" defaultRoom="А-201" />
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- локальную палитру --vibeui-cascader-016-* и не подменять её токенами вашей темы (bg-card, bg-muted и подобными)
- разные элементы для разных уровней: вкладки, фишки и плитки читаются как три разных вопроса, а не как три одинаковых списка
- сетку auto-fill minmax: число колонок считается от ширины компонента, а не от ширины окна
- нативный disabled у занятых комнат вместе с подписью «занята» — цвет один не объясняет запрет
- вместимость в подписи и в aria-label: выбор переговорки идёт по числу мест
- обнуление этажа и комнаты при смене здания: иначе останется комната из другого корпуса
- итог с aria-live="polite", собирающий весь путь: по отдельности «А-201» ничего не значит
## 6. Можно менять
- label — заголовок над уровнями
- buildings — дерево зданий, этажей и комнат с вместимостью и флагом busy
- defaultRoom — комната, выбранная при первом рендере
- onSelect — обработчик, получает здание, этаж и комнату
- accent — цвет активной фишки и выбранной плитки
- минимальную ширину плитки в сетке, если названия комнат длиннее
## 7. Правила
- Вкладки здесь без клавиатурной стрелочной навигации по WAI-ARIA: если зданий больше пяти, добавьте её осознанно.
- Занятость приходит пропом и не обновляется сама: за живыми данными нужен свой опрос или подписка.
- Не сортируйте комнаты по вместимости: люди ищут по номеру, а номер связан с расположением.
- Для кампуса с десятком корпусов вкладки не масштабируются — там нужен поиск по зданию.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
npx shadcn@latest add https://vibeui.ru/r/cascader-016.jsonhttps://vibeui.ru/r/cascader-016.jsonКомпонент самодостаточен: один файл, ноль зависимостей, собственная палитра в локальных переменных --vibeui-cascader-016-*. Клиентский: здание, этаж и комната живут в трёх useState, смена верхнего уровня обнуляет нижние. Уровни намеренно оформлены разными элементами: здания — role="tablist" с aria-selected, этажи — кнопки-фишки с aria-pressed, комнаты — grid с auto-fill minmax(6.5rem, 1fr), который сам решает число колонок по ширине карточки, а не по ширине окна. Занятая комната получает нативный disabled и собственную подложку, вместимость подписана и продублирована в aria-label вместе со статусом. Итог внизу собирает полный путь и вместимость и объявляется через aria-live.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"use client"
import { useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"
export type Cascader016Room = { name: string; seats: number; busy?: boolean }
export type Cascader016Floor = { name: string; rooms: Cascader016Room[] }
export type Cascader016Building = { name: string; floors: Cascader016Floor[] }
export type Cascader016Props = Omit<
ComponentPropsWithoutRef<"section">,
"children" | "onSelect"
> & {
label?: string
buildings?: Cascader016Building[]
defaultRoom?: string
onSelect?: (building: string, floor: string, room: string) => void
accent?: string
}
// Идея компонента: «здание → этаж → комната» — это не список списков,
// а три разных по природе выбора. Здания сделаны вкладками, этажи —
// фишками, а комнаты сеткой плиток с вместимостью: последний уровень
// пространственный, и плитка ближе к плану этажа, чем строка списка.
const STYLES = `
:where([data-vibeui-block="cascader-016"]){
--vibeui-cascader-016-bg:oklch(1 0 0);
--vibeui-cascader-016-fg:oklch(0.22 0.014 210);
--vibeui-cascader-016-muted:oklch(0.55 0.014 210);
--vibeui-cascader-016-border:oklch(0.9 0.008 210);
--vibeui-cascader-016-soft:oklch(0.965 0.008 210);
--vibeui-cascader-016-busy:oklch(0.94 0.03 25);
--vibeui-cascader-016-busyfg:oklch(0.5 0.1 25);
--vibeui-cascader-016-accent:oklch(0.48 0.11 210);
--vibeui-cascader-016-accentsoft:oklch(0.94 0.045 210);
--vibeui-cascader-016-radius:0.625rem;
--vibeui-cascader-016-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="cascader-016"]{
display:flex;flex-direction:column;gap:0.55rem;
width:100%;max-width:24rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-cascader-016-bg);
border:1px solid var(--vibeui-cascader-016-border);
border-radius:calc(var(--vibeui-cascader-016-radius) + 0.25rem);
color:var(--vibeui-cascader-016-fg);
font-family:var(--vibeui-cascader-016-font);
}
[data-vibeui-block="cascader-016"] [data-part="title"]{
margin:0;font-size:0.875rem;font-weight:700;letter-spacing:-0.01em;
}
[data-vibeui-block="cascader-016"] [data-part="tabs"]{
display:flex;gap:0.15rem;padding:0.15rem;border-radius:0.55rem;
background:var(--vibeui-cascader-016-soft);
}
[data-vibeui-block="cascader-016"] [data-part="tab"]{
appearance:none;cursor:pointer;font:inherit;flex:1 1 0;min-width:0;
padding:0.4rem 0.5rem;border:0;border-radius:0.45rem;
background:transparent;color:var(--vibeui-cascader-016-muted);
font-size:0.78rem;font-weight:600;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
transition:background-color .16s ease,color .16s ease;
}
[data-vibeui-block="cascader-016"] [data-part="tab"][aria-selected="true"]{
background:var(--vibeui-cascader-016-bg);color:var(--vibeui-cascader-016-fg);
box-shadow:0 1px 2px oklch(0.2 0.02 210 / 12%);
}
[data-vibeui-block="cascader-016"] [data-part="tab"]:focus-visible,
[data-vibeui-block="cascader-016"] [data-part="floor"]:focus-visible,
[data-vibeui-block="cascader-016"] [data-part="room"]:focus-visible{
outline:2px solid var(--vibeui-cascader-016-accent);outline-offset:2px;
}
[data-vibeui-block="cascader-016"] [data-part="floors"]{
display:flex;flex-wrap:wrap;gap:0.3rem;margin:0;padding:0;list-style:none;
}
[data-vibeui-block="cascader-016"] [data-part="floor"]{
appearance:none;cursor:pointer;font:inherit;
padding:0.25rem 0.65rem;border-radius:999px;
border:1px solid var(--vibeui-cascader-016-border);
background:transparent;color:var(--vibeui-cascader-016-muted);
font-size:0.75rem;font-weight:600;
transition:background-color .16s ease,color .16s ease;
}
[data-vibeui-block="cascader-016"] [data-part="floor"][aria-pressed="true"]{
background:var(--vibeui-cascader-016-accent);border-color:transparent;
color:var(--vibeui-cascader-016-bg);
}
[data-vibeui-block="cascader-016"] [data-part="rooms"]{
display:grid;grid-template-columns:repeat(auto-fill,minmax(6.5rem,1fr));gap:0.35rem;
margin:0;padding:0;list-style:none;
}
[data-vibeui-block="cascader-016"] [data-part="room"]{
appearance:none;cursor:pointer;font:inherit;width:100%;
display:flex;flex-direction:column;gap:0.1rem;
box-sizing:border-box;padding:0.5rem 0.55rem;
border:1px solid var(--vibeui-cascader-016-border);
border-radius:0.5rem;background:transparent;color:inherit;text-align:left;
transition:background-color .16s ease,border-color .16s ease;
}
[data-vibeui-block="cascader-016"] [data-part="room"]:hover:not(:disabled){border-color:var(--vibeui-cascader-016-accent)}
[data-vibeui-block="cascader-016"] [data-part="room"][aria-pressed="true"]{
background:var(--vibeui-cascader-016-accentsoft);border-color:var(--vibeui-cascader-016-accent);
}
[data-vibeui-block="cascader-016"] [data-part="room"]:disabled{
cursor:not-allowed;background:var(--vibeui-cascader-016-busy);
border-color:transparent;color:var(--vibeui-cascader-016-busyfg);
}
[data-vibeui-block="cascader-016"] [data-part="roomname"]{font-size:0.8125rem;font-weight:700}
[data-vibeui-block="cascader-016"] [data-part="seats"]{font-size:0.7rem;color:var(--vibeui-cascader-016-muted)}
[data-vibeui-block="cascader-016"] [data-part="room"]:disabled [data-part="seats"]{color:inherit}
[data-vibeui-block="cascader-016"] [data-part="path"]{
margin:0;padding-top:0.55rem;border-top:1px solid var(--vibeui-cascader-016-border);
font-size:0.8125rem;color:var(--vibeui-cascader-016-muted);
}
[data-vibeui-block="cascader-016"] [data-part="path"] b{color:var(--vibeui-cascader-016-fg)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="cascader-016"] *{animation:none!important;transition:none!important}}
`
const CAMPUS: Cascader016Building[] = [
{
name: "Корпус А",
floors: [
{
name: "1 этаж",
rooms: [
{ name: "А-101", seats: 4 },
{ name: "А-102", seats: 8, busy: true },
{ name: "А-105", seats: 2 },
],
},
{
name: "2 этаж",
rooms: [
{ name: "А-201", seats: 12 },
{ name: "А-204", seats: 6 },
],
},
],
},
{
name: "Корпус Б",
floors: [
{
name: "3 этаж",
rooms: [
{ name: "Б-301", seats: 6, busy: true },
{ name: "Б-302", seats: 10 },
{ name: "Б-310", seats: 3 },
{ name: "Б-311", seats: 3 },
],
},
],
},
]
function pluralize(count: number, forms: [string, string, string]) {
const tens = count % 100
const ones = count % 10
if (tens > 10 && tens < 20) return forms[2]
if (ones === 1) return forms[0]
if (ones > 1 && ones < 5) return forms[1]
return forms[2]
}
/**
* Выбор места: здание вкладками, этаж фишками, комната плиткой сетки.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Cascader016({
label = "Место встречи",
buildings = CAMPUS,
defaultRoom = "А-201",
onSelect,
accent,
className,
style,
...props
}: Cascader016Props) {
const [building, setBuilding] = useState(buildings[0]?.name ?? "")
const [floor, setFloor] = useState(buildings[0]?.floors[1]?.name ?? "")
const [room, setRoom] = useState(defaultRoom)
const current = buildings.find((entry) => entry.name === building)
const floors = current?.floors ?? []
const rooms = floors.find((entry) => entry.name === floor)?.rooms ?? []
const picked = rooms.find((entry) => entry.name === room)
const palette = {
...(accent ? { "--vibeui-cascader-016-accent": accent } : null),
...style,
} as CSSProperties
return (
<>
<style href="vibeui-cascader-016" precedence="medium">
{STYLES}
</style>
<section
{...props}
data-vibeui-block="cascader-016"
className={className}
style={palette}
>
<h3 data-part="title">{label}</h3>
<div data-part="tabs" role="tablist" aria-label="Здания">
{buildings.map((entry) => (
<button
key={entry.name}
type="button"
role="tab"
data-part="tab"
aria-selected={entry.name === building}
onClick={() => {
setBuilding(entry.name)
setFloor(entry.floors[0]?.name ?? "")
setRoom("")
}}
>
{entry.name}
</button>
))}
</div>
<ul data-part="floors" aria-label="Этажи">
{floors.map((entry) => (
<li key={entry.name}>
<button
type="button"
data-part="floor"
aria-pressed={entry.name === floor}
onClick={() => {
setFloor(entry.name)
setRoom("")
}}
>
{entry.name}
</button>
</li>
))}
</ul>
<ul data-part="rooms" aria-label="Комнаты">
{rooms.map((entry) => (
<li key={entry.name}>
<button
type="button"
data-part="room"
disabled={entry.busy}
aria-pressed={entry.name === room}
aria-label={`${entry.name}, ${entry.seats} ${pluralize(entry.seats, ["место", "места", "мест"])}${entry.busy ? ", занята" : ""}`}
onClick={() => {
setRoom(entry.name)
onSelect?.(building, floor, entry.name)
}}
>
<span data-part="roomname">{entry.name}</span>
<span data-part="seats">
{entry.busy
? "занята"
: `${entry.seats} ${pluralize(entry.seats, ["место", "места", "мест"])}`}
</span>
</button>
</li>
))}
</ul>
<p data-part="path" aria-live="polite">
{picked ? (
<>
<b>
{building} · {floor} · {picked.name}
</b>{" "}
— {picked.seats}{" "}
{pluralize(picked.seats, ["место", "места", "мест"])}
</>
) : (
"Комната не выбрана"
)}
</p>
</section>
</>
)
}