Navigation

Open With

A file context menu with an "Open with" submenu: the list of applications is tucked into a second level reached by hover and the right arrow.

  • contextmenu
  • submenu
  • files
  • keyboard

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/contextmenu-003?lang=en

mockup-home.svg

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 "contextmenu-003" (Open With) 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/contextmenu-003.json

Registry item: https://vibeui.ru/r/contextmenu-003.json
Installs to: components/vibeui/contextmenu-003.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 file context menu with an "Open with" submenu: the list of applications is tucked into a second level reached by hover and the right arrow.

A context menu with an application submenu: the second level opens on hover and the right arrow, the left arrow walks back. One file, zero dependencies.

## 3. How to use it
import { Contextmenu003 } from "@/components/vibeui/contextmenu-003"

<Contextmenu003
  fileName="mockup.svg"
  submenuLabel="Open with"
  apps={["Preview", "Code editor"]}
/>

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-contextmenu-003-* palette: the component must look the same in any project
- the submenu as a plain layer inside the popover: a second popover=auto would close the first and make the menu blink
- the duplicate button beside the file — a right click is available neither from the keyboard nor on a phone
- the aria-haspopup and aria-expanded pair on the parent item: otherwise only sighted users learn about the second level
- returning focus to the parent on the left arrow — without it there is no way out of the submenu
- the component's own light surface: on the dark catalog card dark text disappears

## 6. You may change
- the file name through fileName
- the submenu heading through submenuLabel and the application list through apps
- the icon and focus colour through accent
- the first-level entries directly in the markup

## 7. Rules
- The submenu always opens to the right: near the right edge it runs off, flip it with your own rule.
- Leaving with the pointer closes the submenu at once; add a delay in onMouseLeave if that feels abrupt.
- The "Other application…" entry with an ellipsis promises a dialog — do not leave it empty.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 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/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. Стрелка вправо открывает подменю и уводит в него фокус, влево — закрывает и возвращает фокус на родителя. Кнопка «Действия» дублирует правый клик, иначе меню недоступно без мыши.

Component source

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

"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>
    </>
  )
}