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
Use it with AI
- 1. Copy the link.
- 2. Write to your agent in your own words and drop the link into the sentence.
- 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
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.jsonhttps://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>
</>
)
}