Navigation
Arrow Menubar
Строка меню, по которой ходят стрелками: влево и вправо переводят раздел, вниз открывает список и ставит фокус на первый пункт.
- menubar
- keyboard
- menu
- aria
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/menubar-002
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «menubar-002» (Arrow Menubar) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/menubar-002.json
Registry item: https://vibeui.ru/r/menubar-002.json
Ставится в: components/vibeui/menubar-002.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Строка меню, по которой ходят стрелками: влево и вправо переводят раздел, вниз открывает список и ставит фокус на первый пункт.
Строка меню приложения с полной клавиатурой: стрелки влево и вправо переводят раздел по кругу, Home и End прыгают к краям, стрелка вниз открывает меню и ставит фокус на первый пункт, Escape закрывает и возвращает фокус на кнопку раздела. Внутри открытого списка стрелки вверх и вниз перебирают пункты. Ноль зависимостей, один файл, своя палитра.
## 3. Как использовать
import { Menubar002 } from "@/components/vibeui/menubar-002"
<Menubar002
menus={[
{ label: "Файл", items: [{ label: "Открыть…", keys: "⌘O" }] },
{ label: "Правка", items: [{ label: "Отменить", keys: "⌘Z" }] },
]}
/>
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- локальную палитру --vibeui-menubar-002-* и не подменять её токенами вашей темы (bg-popover, text-muted-foreground и подобными)
- roving tabindex: tabIndex 0 только у текущего раздела, у остальных -1 — иначе Tab перебирает всю строку меню, а не проходит её насквозь
- возврат фокуса на кнопку раздела при закрытии по Escape: без него фокус проваливается в начало документа
- aria-expanded на кнопке раздела: по нему скринридер понимает, раскрыт список или нет
- перенос фокуса вместе со сменой раздела — выбор без фокуса рассинхронизирует клавиатуру и глаз
- слушатель pointerdown, закрывающий меню по клику мимо, и его снятие при закрытии — иначе он копится на каждом открытии
## 6. Можно менять
- массив menus: разделы, пункты, сочетания клавиш и признак disabled
- обработчики пунктов: сейчас клик только закрывает меню
- акцент через проп accent — цвет кольца фокуса
- высоту строки меню и ширину выпадающих списков
## 7. Правила
- Компонент клиентский: "use client" нужен ради состояния и обработки клавиш.
- Меню позиционируется абсолютно внутри своей обёртки: если строка меню лежит в контейнере с overflow:hidden, список обрежется.
- Первые буквы пунктов компонент не обрабатывает — в системных меню это привычный способ выбора, добавьте его при необходимости.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
npx shadcn@latest add https://vibeui.ru/r/menubar-002.jsonhttps://vibeui.ru/r/menubar-002.jsonКомпонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-menubar-002-*. Клиентский: "use client" ради состояния открытого меню и клавиатуры. Разметка следует паттерну menubar из WAI-ARIA: role="menubar", кнопки role="menuitem" с aria-haspopup и aria-expanded, списки role="menu". В Tab-порядке живёт только один раздел (roving tabindex), переход между разделами идёт стрелками. Меню позиционируется абсолютно от своей обёртки, поэтому anchor positioning не требуется. Клик мимо закрывает список через слушатель pointerdown, снимаемый вместе с закрытием.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"use client"
import { useEffect, useRef, useState } from "react"
import type { CSSProperties, KeyboardEvent } from "react"
export type Menubar002Item = {
label: string
keys?: string
disabled?: boolean
}
export type Menubar002Menu = {
label: string
items: Menubar002Item[]
}
export type Menubar002Props = {
menus?: Menubar002Menu[]
accent?: string
className?: string
style?: CSSProperties
}
// Идея компонента: строка меню, по которой ходят стрелками, как в системном
// приложении. Влево и вправо переводят раздел, вниз открывает список и ставит
// фокус на первый пункт, Escape возвращает фокус на кнопку раздела. В Tab-порядке
// живёт только один раздел (roving tabindex), поэтому меню не ловит табуляцию.
const STYLES = `
:where([data-vibeui-block="menubar-002"]){
--vibeui-menubar-002-bg:oklch(1 0 0);
--vibeui-menubar-002-fg:oklch(0.24 0.014 265);
--vibeui-menubar-002-muted:oklch(0.58 0.014 265);
--vibeui-menubar-002-border:oklch(0.9 0.006 265);
--vibeui-menubar-002-hover:oklch(0.55 0.02 265 / 10%);
--vibeui-menubar-002-accent:oklch(0.55 0.2 262);
--vibeui-menubar-002-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="menubar-002"]{
box-sizing:border-box;width:100%;max-width:34rem;padding:0.25rem;
display:flex;align-items:center;gap:0.125rem;
background:var(--vibeui-menubar-002-bg);color:var(--vibeui-menubar-002-fg);
border:1px solid var(--vibeui-menubar-002-border);border-radius:0.625rem;
font-family:var(--vibeui-menubar-002-font);
}
[data-vibeui-block="menubar-002"] [data-part="slot"]{position:relative}
[data-vibeui-block="menubar-002"] [data-part="trigger"]{
appearance:none;border:0;background:none;cursor:pointer;
height:1.875rem;padding:0 0.625rem;border-radius:0.4375rem;
font:inherit;font-size:0.8125rem;color:inherit;
transition:background-color .14s ease;
}
[data-vibeui-block="menubar-002"] [data-part="trigger"]:hover{background:var(--vibeui-menubar-002-hover)}
[data-vibeui-block="menubar-002"] [data-part="trigger"][aria-expanded="true"]{background:var(--vibeui-menubar-002-hover)}
[data-vibeui-block="menubar-002"] [data-part="trigger"]:focus-visible{outline:2px solid var(--vibeui-menubar-002-accent);outline-offset:-2px}
[data-vibeui-block="menubar-002"] [data-part="menu"]{
position:absolute;top:calc(100% + 0.375rem);left:0;z-index:30;
min-width:12rem;padding:0.25rem;box-sizing:border-box;
background:var(--vibeui-menubar-002-bg);color:var(--vibeui-menubar-002-fg);
border:1px solid var(--vibeui-menubar-002-border);border-radius:0.625rem;
box-shadow:0 16px 36px -18px oklch(0.2 0.03 265 / 45%);
}
[data-vibeui-block="menubar-002"] [data-part="item"]{
display:flex;align-items:center;justify-content:space-between;gap:1.5rem;
width:100%;min-height:1.875rem;padding:0 0.5rem;box-sizing:border-box;
appearance:none;border:0;background:none;cursor:pointer;border-radius:0.4375rem;
font:inherit;font-size:0.8125rem;color:inherit;text-align:left;
}
[data-vibeui-block="menubar-002"] [data-part="item"]:hover:not(:disabled){background:var(--vibeui-menubar-002-hover)}
[data-vibeui-block="menubar-002"] [data-part="item"]:focus-visible{outline:2px solid var(--vibeui-menubar-002-accent);outline-offset:-2px}
[data-vibeui-block="menubar-002"] [data-part="item"]:disabled{color:var(--vibeui-menubar-002-muted);cursor:default}
[data-vibeui-block="menubar-002"] [data-part="keys"]{font-size:0.75rem;color:var(--vibeui-menubar-002-muted)}
[data-vibeui-block="menubar-002"] [data-part="hint"]{
margin-left:auto;padding-right:0.375rem;font-size:0.6875rem;color:var(--vibeui-menubar-002-muted);
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="menubar-002"] *{animation:none!important;transition:none!important}}
`
const DEFAULT_MENUS: Menubar002Menu[] = [
{
label: "Файл",
items: [
{ label: "Новый проект", keys: "⌘N" },
{ label: "Открыть…", keys: "⌘O" },
{ label: "Сохранить", keys: "⌘S" },
{ label: "Вернуть версию", disabled: true },
],
},
{
label: "Правка",
items: [
{ label: "Отменить", keys: "⌘Z" },
{ label: "Повторить", keys: "⇧⌘Z" },
{ label: "Найти в проекте", keys: "⌘F" },
],
},
{
label: "Вид",
items: [
{ label: "Показать сетку" },
{ label: "Показать линейки" },
{ label: "Во весь экран", keys: "F11" },
],
},
]
/**
* Строка меню приложения с переходом между разделами стрелками.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Menubar002({
menus = DEFAULT_MENUS,
accent,
className,
style,
}: Menubar002Props) {
const [open, setOpen] = useState<number | null>(null)
const [focused, setFocused] = useState(0)
const rootRef = useRef<HTMLDivElement>(null)
const wanted = useRef<"first" | "last" | null>(null)
const palette = {
...(accent ? { "--vibeui-menubar-002-accent": accent } : null),
...style,
} as CSSProperties
// Клик мимо закрывает меню: без этого открытый список остаётся висеть,
// когда человек ушёл работать в другую часть страницы.
useEffect(() => {
if (open === null) {
return
}
function onOutside(event: PointerEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(null)
}
}
document.addEventListener("pointerdown", onOutside)
return () => document.removeEventListener("pointerdown", onOutside)
}, [open])
// Фокус на пункт ставится после отрисовки списка: до неё узла ещё нет.
useEffect(() => {
const mode = wanted.current
wanted.current = null
if (open === null || !mode) {
return
}
const items = itemNodes()
;(mode === "first" ? items[0] : items[items.length - 1])?.focus()
}, [open])
function itemNodes() {
return Array.from(
rootRef.current?.querySelectorAll<HTMLButtonElement>(
'[data-part="item"]:not(:disabled)',
) ?? [],
)
}
function focusTrigger(index: number) {
rootRef.current
?.querySelectorAll<HTMLButtonElement>('[data-part="trigger"]')
[index]?.focus()
}
function goTo(index: number, keepOpen: boolean) {
setFocused(index)
focusTrigger(index)
if (keepOpen) {
wanted.current = null
setOpen(index)
}
}
function onBarKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const last = menus.length - 1
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
event.preventDefault()
const step = event.key === "ArrowRight" ? 1 : -1
goTo((focused + step + menus.length) % menus.length, open !== null)
return
}
if (event.key === "Home" || event.key === "End") {
event.preventDefault()
goTo(event.key === "Home" ? 0 : last, open !== null)
return
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault()
wanted.current = event.key === "ArrowDown" ? "first" : "last"
setOpen(focused)
}
}
function onMenuKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
event.preventDefault()
setOpen(null)
focusTrigger(focused)
return
}
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault()
const step = event.key === "ArrowRight" ? 1 : -1
const next = (focused + step + menus.length) % menus.length
setFocused(next)
wanted.current = "first"
setOpen(next)
return
}
const keys = ["ArrowDown", "ArrowUp", "Home", "End"]
if (!keys.includes(event.key)) {
return
}
event.preventDefault()
const items = itemNodes()
const index = items.indexOf(document.activeElement as HTMLButtonElement)
const last = items.length - 1
const next =
event.key === "Home"
? 0
: event.key === "End"
? last
: event.key === "ArrowDown"
? (index + 1) % items.length
: (index - 1 + items.length) % items.length
items[next]?.focus()
}
return (
<>
<style href="vibeui-menubar-002" precedence="medium">
{STYLES}
</style>
<div
ref={rootRef}
data-vibeui-block="menubar-002"
role="menubar"
aria-label="Меню приложения"
className={className}
style={palette}
onKeyDown={onBarKeyDown}
>
{menus.map((menu, index) => (
<span key={menu.label} data-part="slot">
<button
type="button"
data-part="trigger"
role="menuitem"
aria-haspopup="menu"
aria-expanded={open === index}
tabIndex={index === focused ? 0 : -1}
onClick={() => {
setFocused(index)
setOpen(open === index ? null : index)
}}
>
{menu.label}
</button>
{open === index ? (
<div
data-part="menu"
role="menu"
aria-label={menu.label}
onKeyDown={onMenuKeyDown}
>
{menu.items.map((item) => (
<button
key={item.label}
type="button"
data-part="item"
role="menuitem"
disabled={item.disabled}
tabIndex={-1}
onClick={() => {
setOpen(null)
focusTrigger(index)
}}
>
{item.label}
{item.keys ? (
<span data-part="keys">{item.keys}</span>
) : null}
</button>
))}
</div>
) : null}
</span>
))}
<span data-part="hint">← → между разделами</span>
</div>
</>
)
}