Navigation

Open With

Контекстное меню файла с подменю «Открыть с помощью»: список программ спрятан на второй уровень, куда ведут наведение и стрелка вправо.

  • contextmenu
  • submenu
  • files
  • keyboard

Превью

1440pxHost theme

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

  1. 1. Скопируйте ссылку.
  2. 2. Напишите агенту своими словами и вставьте её в предложение.
  3. 3. Агент откроет ссылку и поставит компонент из registry.

размести это в шапке: https://vibeui.ru/c/contextmenu-003

макет-главной.svg

Показать полную инструкцию

То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.

# Установка и размещение «contextmenu-003» (Open With) из VibeUI

## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/contextmenu-003.json

Registry item: https://vibeui.ru/r/contextmenu-003.json
Ставится в: components/vibeui/contextmenu-003.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.

Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.

## 2. Что это
Контекстное меню файла с подменю «Открыть с помощью»: список программ спрятан на второй уровень, куда ведут наведение и стрелка вправо.

Контекстное меню с подменю выбора программы: второй уровень открывается наведением и стрелкой вправо, стрелка влево возвращает назад. Один файл, ноль зависимостей.

## 3. Как использовать
import { Contextmenu003 } from "@/components/vibeui/contextmenu-003"

<Contextmenu003
  fileName="макет.svg"
  submenuLabel="Открыть с помощью"
  apps={["Просмотр", "Редактор кода"]}
/>

Полный список пропсов — в установленном файле.

## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.

Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)

## 5. Сохранить как установлено
- локальную палитру --vibeui-contextmenu-003-*: компонент обязан выглядеть одинаково в любом проекте
- подменю обычным слоем внутри popover: второй popover=auto закрыл бы первый, и меню замигает
- кнопку-дублёр рядом с файлом — правый клик недоступен ни с клавиатуры, ни на телефоне
- пару aria-haspopup и aria-expanded на родительском пункте: иначе о втором уровне знают только зрячие
- возврат фокуса на родителя по стрелке влево — без него из подменю нет выхода
- собственную светлую подложку: на тёмной карточке каталога тёмный текст пропадает

## 6. Можно менять
- имя файла через fileName
- заголовок подменю через submenuLabel и список программ через apps
- цвет значка и фокуса через accent
- состав основного уровня прямо в разметке

## 7. Правила
- Подменю всегда раскрывается вправо: у правого края экрана оно уедет, переверните его своим правилом.
- Уход курсора закрывает подменю сразу; если это слишком резко, добавьте задержку в onMouseLeave.
- Пункт «Другое приложение…» с многоточием обещает диалог — не оставляйте его пустым.
- Не выносите CSS-переменные в globals.css: компонент обязан остаться одним файлом.

## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.

Для разработчика

npx shadcn@latest add https://vibeui.ru/r/contextmenu-003.json
https://vibeui.ru/r/contextmenu-003.json

Один файл, ноль зависимостей, палитра --vibeui-contextmenu-003-*. Клиентский: раскрытие подменю и координаты курсора держит useState. Первый уровень — HTML popover у курсора через переменные x/y, второй — обычный абсолютный слой внутри него: вложенный popover=auto закрыл бы родителя. Родительский пункт помечен aria-haspopup=menu и aria-expanded, подменю — role=menu с aria-label. Стрелка вправо открывает подменю и уводит в него фокус, влево — закрывает и возвращает фокус на родителя. Кнопка «Действия» дублирует правый клик, иначе меню недоступно без мыши.

Исходник компонента

Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.

"use client"

import { useRef, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties, MouseEvent } from "react"

export type Contextmenu003Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children"
> & {
  fileName?: string
  submenuLabel?: string
  apps?: string[]
  accent?: string
}

// Идея компонента: контекстное меню с подменю «Открыть с помощью». Список
// программ не помещается в основное меню и не нужен в девяти случаях из
// десяти, поэтому он спрятан на второй уровень. Подменю — обычный слой внутри
// popover, а не второй popover: вложенные popover закрывают друг друга.
// Стрелка вправо входит в подменю, влево возвращает фокус на родителя.
const STYLES = `
:where([data-vibeui-block="contextmenu-003"]){
--vibeui-contextmenu-003-bg:oklch(1 0 0);
--vibeui-contextmenu-003-fg:oklch(0.24 0.014 265);
--vibeui-contextmenu-003-muted:oklch(0.55 0.014 265);
--vibeui-contextmenu-003-border:oklch(0.9 0.006 265);
--vibeui-contextmenu-003-hover:oklch(0.96 0.004 265);
--vibeui-contextmenu-003-accent:oklch(0.56 0.16 165);
--vibeui-contextmenu-003-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--vibeui-contextmenu-003-x:50%;
--vibeui-contextmenu-003-y:50%;
}
[data-vibeui-block="contextmenu-003"]{
display:flex;flex-direction:column;gap:0.625rem;
width:100%;max-width:21rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-contextmenu-003-bg);color:var(--vibeui-contextmenu-003-fg);
border:1px solid var(--vibeui-contextmenu-003-border);border-radius:1rem;
font-family:var(--vibeui-contextmenu-003-font);
}
[data-vibeui-block="contextmenu-003"] [data-part="file"]{
display:flex;align-items:center;gap:0.625rem;
padding:0.625rem;box-sizing:border-box;
border:1px dashed var(--vibeui-contextmenu-003-border);border-radius:0.75rem;
font-size:0.8125rem;touch-action:manipulation;
}
[data-vibeui-block="contextmenu-003"] [data-part="icon"]{
display:flex;align-items:center;justify-content:center;flex:none;
width:2rem;height:2rem;border-radius:0.5rem;
background:color-mix(in oklab,var(--vibeui-contextmenu-003-accent) 15%,transparent);
color:var(--vibeui-contextmenu-003-accent);font-size:0.625rem;font-weight:700;
}
[data-vibeui-block="contextmenu-003"] [data-part="open"]{
appearance:none;cursor:pointer;margin-left:auto;
height:1.75rem;padding:0 0.625rem;
border:1px solid var(--vibeui-contextmenu-003-border);border-radius:0.5rem;
background:var(--vibeui-contextmenu-003-bg);color:inherit;font:inherit;font-size:0.75rem;
}
[data-vibeui-block="contextmenu-003"] [data-part="open"]:hover{background:var(--vibeui-contextmenu-003-hover)}
[data-vibeui-block="contextmenu-003"] [data-part="open"]:focus-visible{outline:2px solid var(--vibeui-contextmenu-003-accent);outline-offset:2px}
[data-vibeui-block="contextmenu-003"] [data-part="log"]{
margin:0;font-size:0.6875rem;color:var(--vibeui-contextmenu-003-muted);min-height:1rem;
}
[data-vibeui-block="contextmenu-003"] [data-part="menu"]{
position:fixed;margin:0;padding:0.3125rem;
top:var(--vibeui-contextmenu-003-y);left:var(--vibeui-contextmenu-003-x);
min-width:12.5rem;box-sizing:border-box;
background:var(--vibeui-contextmenu-003-bg);color:var(--vibeui-contextmenu-003-fg);
border:1px solid var(--vibeui-contextmenu-003-border);border-radius:0.75rem;
box-shadow:0 18px 40px -20px oklch(0.2 0.03 265 / 50%);
font-family:var(--vibeui-contextmenu-003-font);
}
[data-vibeui-block="contextmenu-003"] [data-part="item"]{
display:flex;align-items:center;gap:0.75rem;
width:100%;box-sizing:border-box;
appearance:none;border:0;background:none;cursor:pointer;
padding:0.4375rem 0.5rem;border-radius:0.5rem;
font:inherit;font-size:0.8125rem;color:inherit;text-align:left;
transition:background-color .14s ease;
}
[data-vibeui-block="contextmenu-003"] [data-part="item"]:hover,
[data-vibeui-block="contextmenu-003"] [data-part="item"][aria-expanded="true"]{background:var(--vibeui-contextmenu-003-hover)}
[data-vibeui-block="contextmenu-003"] [data-part="item"]:focus-visible{outline:2px solid var(--vibeui-contextmenu-003-accent);outline-offset:-2px}
[data-vibeui-block="contextmenu-003"] [data-part="arrow"]{
margin-left:auto;flex:none;width:0.3125rem;height:0.3125rem;
border-top:1.5px solid var(--vibeui-contextmenu-003-muted);
border-right:1.5px solid var(--vibeui-contextmenu-003-muted);
transform:rotate(45deg);
}
[data-vibeui-block="contextmenu-003"] [data-part="nest"]{position:relative}
/* Второй уровень — слой внутри popover: вложенный popover закрыл бы первый. */
[data-vibeui-block="contextmenu-003"] [data-part="sub"]{
position:absolute;top:-0.3125rem;left:calc(100% + 0.25rem);z-index:1;
padding:0.3125rem;min-width:11rem;box-sizing:border-box;
background:var(--vibeui-contextmenu-003-bg);
border:1px solid var(--vibeui-contextmenu-003-border);border-radius:0.75rem;
box-shadow:0 18px 40px -20px oklch(0.2 0.03 265 / 50%);
}
[data-vibeui-block="contextmenu-003"] [data-part="badge"]{
display:flex;align-items:center;justify-content:center;flex:none;
width:1.25rem;height:1.25rem;border-radius:0.375rem;
background:var(--vibeui-contextmenu-003-hover);
font-size:0.625rem;font-weight:700;color:var(--vibeui-contextmenu-003-muted);
}
[data-vibeui-block="contextmenu-003"] [data-part="rule"]{
height:1px;margin:0.3125rem 0.25rem;background:var(--vibeui-contextmenu-003-border);
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="contextmenu-003"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_APPS = [
  "Просмотр",
  "Редактор кода",
  "Браузер",
  "Другое приложение…",
]

/**
 * Контекстное меню с подменю «Открыть с помощью» и переходом по стрелкам.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Contextmenu003({
  fileName = "макет-главной.svg",
  submenuLabel = "Открыть с помощью",
  apps = DEFAULT_APPS,
  accent,
  className,
  style,
  ...props
}: Contextmenu003Props) {
  const menu = useRef<HTMLDivElement>(null)
  const parent = useRef<HTMLButtonElement>(null)
  const sub = useRef<HTMLDivElement>(null)
  const [open, setOpen] = useState(false)
  const [spot, setSpot] = useState<{ x: string; y: string } | null>(null)
  const [done, setDone] = useState("")

  const openAt = (x: number, y: number) => {
    setSpot({ x: `${Math.round(x)}px`, y: `${Math.round(y)}px` })
    menu.current?.showPopover()
    requestAnimationFrame(() =>
      menu.current?.querySelector<HTMLElement>('[data-part="item"]')?.focus(),
    )
  }

  const enterSub = () => {
    setOpen(true)
    requestAnimationFrame(() =>
      sub.current?.querySelector<HTMLElement>('[data-part="item"]')?.focus(),
    )
  }

  const choose = (label: string) => {
    setDone(`${fileName} → ${label}`)
    setOpen(false)
    menu.current?.hidePopover()
  }

  const palette = {
    ...(accent ? { "--vibeui-contextmenu-003-accent": accent } : null),
    ...(spot
      ? {
          "--vibeui-contextmenu-003-x": spot.x,
          "--vibeui-contextmenu-003-y": spot.y,
        }
      : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-contextmenu-003" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="contextmenu-003"
        aria-label="Файл"
        className={className}
        style={palette}
      >
        <div
          data-part="file"
          onContextMenu={(event: MouseEvent<HTMLDivElement>) => {
            event.preventDefault()
            openAt(event.clientX, event.clientY)
          }}
        >
          <span data-part="icon" aria-hidden="true">
            SVG
          </span>
          {fileName}
          <button
            type="button"
            data-part="open"
            aria-haspopup="menu"
            onClick={(event) => {
              const box = event.currentTarget.getBoundingClientRect()
              openAt(box.left, box.bottom + 6)
            }}
          >
            Действия
          </button>
        </div>
        <p data-part="log" role="status">
          {done}
        </p>
        <div
          ref={menu}
          data-part="menu"
          popover="auto"
          role="menu"
          aria-label={`Действия: ${fileName}`}
          onToggle={() => setOpen(false)}
          onKeyDown={(event) => {
            if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
              return
            }

            event.preventDefault()
            const items = Array.from(
              menu.current?.querySelectorAll<HTMLElement>(
                '[data-part="item"]',
              ) ?? [],
            ).filter((item) => item.offsetParent !== null)

            if (items.length === 0) {
              return
            }

            const delta = event.key === "ArrowDown" ? 1 : -1
            const from = items.indexOf(document.activeElement as HTMLElement)
            items[(from + delta + items.length) % items.length].focus()
          }}
        >
          <button
            type="button"
            role="menuitem"
            data-part="item"
            onClick={() => choose("Открыть")}
          >
            Открыть
          </button>
          <div
            data-part="nest"
            onMouseEnter={() => setOpen(true)}
            onMouseLeave={() => setOpen(false)}
          >
            <button
              type="button"
              ref={parent}
              role="menuitem"
              data-part="item"
              aria-haspopup="menu"
              aria-expanded={open}
              onClick={() => (open ? setOpen(false) : enterSub())}
              onKeyDown={(event) => {
                if (event.key === "ArrowRight") {
                  event.preventDefault()
                  enterSub()
                }
              }}
            >
              {submenuLabel}
              <span data-part="arrow" aria-hidden="true" />
            </button>
            {open ? (
              <div
                ref={sub}
                data-part="sub"
                role="menu"
                aria-label={submenuLabel}
                onKeyDown={(event) => {
                  if (event.key === "ArrowLeft") {
                    event.preventDefault()
                    setOpen(false)
                    parent.current?.focus()
                  }
                }}
              >
                {apps.map((app) => (
                  <button
                    key={app}
                    type="button"
                    role="menuitem"
                    data-part="item"
                    onClick={() => choose(app)}
                  >
                    <span data-part="badge" aria-hidden="true">
                      {app[0]}
                    </span>
                    {app}
                  </button>
                ))}
              </div>
            ) : null}
          </div>
          <div data-part="rule" role="separator" />
          <button
            type="button"
            role="menuitem"
            data-part="item"
            onClick={() => choose("Свойства")}
          >
            Свойства
          </button>
        </div>
      </section>
    </>
  )
}