Kontextmenü für React
Ein Menü per Rechtsklick nach dem APG-Muster. Öffnet per Rechtsklick, langem Drücken, Kontextmenü-Taste oder Umschalt F10, mit Untermenüs, Häkchen, Optionen, Kürzeln und einer gefährlichen Aktion, die einmal nachfragt.
Die passenden Aktionen am Zeiger
Ein Kontextmenü bietet die Aktionen, die zum Ding unter dem Zeiger passen. Dieses öffnet per Rechtsklick, langem Drücken, Kontextmenütaste und Umschalt F10, verschachtelt Untermenüs, die mit Pfeil rechts öffnen und mit Pfeil links schließen, springt per getipptem Buchstaben, bleibt im Fenster und gibt den Fokus beim Schließen zurück.
Die „Dateiliste“ nutzt ein Menü für eine ganze Liste, jede Zeile trägt data-context, das Menü nennt also die Datei und die Aktion kennt sie, der Papierkorb fragt einmal nach. Die „Gestaltungsfläche“ bringt Bearbeiten mit Kürzeln, Untermenüs zum Anordnen und Ausrichten, Häkchen, bei denen das Menü für mehrere Klicks offen bleibt, und eine Optionsgruppe für den Zoom. „Rechtschreibung im Text“ setzt Vorschläge zu einem falsch geschriebenen Wort nach oben, darunter Bearbeiten, Formatierung mit Häkchen und ein Untermenü zum Übersetzen. „Chatnachrichten“ ist fürs Telefon genauso gemacht wie für die Maus, langes Drücken öffnet das Menü, Anheften bleibt als Haken stehen und Löschen für alle fragt einmal nach.
Setz die häufigste Aktion nach oben und die zerstörende ans Ende, durch eine Linie getrennt. Jede Aktion im Kontextmenü sollte auch auf anderem Weg erreichbar sein, viele Menschen klicken nie rechts. Zeig Tastenkürzel im Menü, dort lernen Menschen sie.
Variants
Details
Was Kontextmenü mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Overlays
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/context-menu.tsx
- Bringt mit
- utilskiticon
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 3 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ContextMenuProps aus components/ui/context-menu.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
itemsPflichtContextMenuItem[]onActionoptional(id: string, context: string | null, detail?: boolean | string) => voidCalled with the id of a chosen item (checkbox: id and the new state, radio: group and value) and the `data-context` value of the element that was right clicked, so one menu serves a whole list.
titleoptionalstring | ((context: string | null) => string | undefined)A heading inside the menu, e.g. the name of the file. A function gets the data-context value.
showHintoptionalbooleanShow a small line under the area that tells how to open the menu
defaultOpenoptionalbooleanOpen right away, at this point inside the area (px from its top left), for previews
defaultPositionoptional{ x: number; y: number }longPressoptionalbooleanOpen after a long press on touch screens, on by default
childrenPflichtReact.ReactNodeThe area. Elements inside with data-context="…" tell onAction what was clicked.
Eingebaute Texte
Diese Texte schreibt Kontextmenü selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 3 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| menu | Englisch Actions | Deutsch Aktionen |
| hint | Englisch Right click, long press, or press Shift F10 for actions. | Deutsch Rechtsklick, lange drücken oder Umschalt F10 für Aktionen. |
| back | Englisch Back | Deutsch Zurück |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Kontextmenü reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende, Bild hoch, Bild runter, Tab und Leertaste.
- Setzt aria-checked, aria-describedby, aria-disabled, aria-expanded, aria-haspopup, aria-keyshortcuts, aria-label und aria-orientation, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen menu, presentation, role und separator.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 492 Zeilen
'use client';
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
/*
* Name note: dropdown-menu.tsx also exports a small ContextMenu (right click only, flat MenuItem list).
* The CLI installs each component into its own file and neither imports the other, so both live
* side by side in one project. To use both in one file, rename on import:
* import { ContextMenu as RichContextMenu } from '@/components/ui/context-menu';
* This one adds long press, Shift F10, submenus and checkbox and radio items.
*/
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const TEXTS = {
en: { menu: 'Actions', hint: 'Right click, long press, or press Shift F10 for actions.', back: 'Back' },
de: { menu: 'Aktionen', hint: 'Rechtsklick, lange drücken oder Umschalt F10 für Aktionen.', back: 'Zurück' },
};
export type ContextMenuItem =
| {
type?: 'item';
/** Passed to onAction */
id?: string;
label: string;
icon?: IconName;
/** Shown on the right and set as aria-keyshortcuts, e.g. ⌘C */
shortcut?: string;
description?: string;
disabled?: boolean;
destructive?: boolean;
/** A destructive action asks once more in place: the label turns into this text, a second press confirms */
confirm?: string;
}
| { type: 'checkbox'; id: string; label: string; checked?: boolean; icon?: IconName; shortcut?: string; disabled?: boolean }
| { type: 'radio'; group: string; value: string; label: string; checked?: boolean; disabled?: boolean }
| { type: 'submenu'; label: string; icon?: IconName; items: ContextMenuItem[]; disabled?: boolean }
| { type: 'label'; label: string }
| { type: 'separator' };
export interface ContextMenuProps {
items: ContextMenuItem[];
/**
* Called with the id of a chosen item (checkbox: id and the new state, radio: group and value) and the
* `data-context` value of the element that was right clicked, so one menu serves a whole list.
*/
onAction?: (id: string, context: string | null, detail?: boolean | string) => void;
/** A heading inside the menu, e.g. the name of the file. A function gets the data-context value. */
title?: string | ((context: string | null) => string | undefined);
/** Show a small line under the area that tells how to open the menu */
showHint?: boolean;
/** Open right away, at this point inside the area (px from its top left), for previews */
defaultOpen?: boolean;
defaultPosition?: { x: number; y: number };
/** Open after a long press on touch screens, on by default */
longPress?: boolean;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
/** The area. Elements inside with data-context="…" tell onAction what was clicked. */
children: React.ReactNode;
}
type Point = { x: number; y: number };
const MARGIN = 8;
/* Items arrive one after another from the pointer */
const CSS =
'.kitfx-cm{transform-origin:var(--origin,top left);animation:kitfx-cm-in .2s cubic-bezier(.34,1.3,.64,1) both}' +
'.kitfx-cm-item{animation:kitfx-cm-item .28s var(--ease-kit) both;animation-delay:calc(var(--i,0) * 18ms + 20ms)}' +
'@keyframes kitfx-cm-in{from{opacity:0;transform:scale(.94)}}@keyframes kitfx-cm-item{from{opacity:0;transform:translateY(-3px)}}' +
'@media (prefers-reduced-motion:reduce){.kitfx-cm,.kitfx-cm-item{animation:none}}';
function initialState(items: ContextMenuItem[], checks: Record<string, boolean> = {}, radios: Record<string, string> = {}) {
for (const it of items) {
if (it.type === 'checkbox') checks[it.id] = Boolean(it.checked);
if (it.type === 'radio' && it.checked) radios[it.group] = it.value;
if (it.type === 'submenu') initialState(it.items, checks, radios);
}
return { checks, radios };
}
const isAction = (it: ContextMenuItem) => it.type !== 'label' && it.type !== 'separator';
/**
* A context menu after the APG menu pattern, opened by right click, long press, the context menu key or
* Shift F10. Arrows, Home, End and typeahead, submenus with Right and Left, checkbox and radio items,
* shortcuts, a destructive action that asks once more, kept inside the window, focus back on close.
* <ContextMenu items={[{ id: 'copy', label: 'Copy', shortcut: '⌘C' }, …]} onAction={(id, ctx) => …}><FileList /></ContextMenu>
*/
export function ContextMenu({ items, onAction, title, showHint, defaultOpen, defaultPosition, longPress = true, labels, className, children }: ContextMenuProps) {
const t = useTexts(TEXTS, labels);
const id = useId();
const areaRef = useRef<HTMLDivElement>(null);
// Where it opened, relative to the area, so the menu stays put while the page scrolls
const [at, setAt] = useState<(Point & { context: string | null; keyboard: boolean; preview?: boolean }) | null>(null);
const [mounted, setMounted] = useState(false);
const returnTo = useRef<HTMLElement | null>(null);
const [state, setState] = useState(() => initialState(items));
useEffect(() => {
setMounted(true);
if (!defaultOpen || !areaRef.current) return;
const pos = defaultPosition ?? { x: 24, y: 24 };
const r = areaRef.current.getBoundingClientRect();
const under = document.elementFromPoint(r.left + pos.x, r.top + pos.y)?.closest('[data-context]') ?? areaRef.current.querySelector('[data-context]');
setAt({ ...pos, context: under?.getAttribute('data-context') ?? null, keyboard: false, preview: true });
}, [defaultOpen, defaultPosition?.x, defaultPosition?.y]);
const openAt = (clientX: number, clientY: number, target: EventTarget | null, keyboard: boolean) => {
const area = areaRef.current;
if (!area) return;
const r = area.getBoundingClientRect();
const ctxEl = (target as HTMLElement | null)?.closest?.('[data-context]');
returnTo.current = (document.activeElement as HTMLElement) ?? area;
setAt({ x: clientX - r.left, y: clientY - r.top, context: ctxEl?.getAttribute('data-context') ?? null, keyboard });
};
const close = useCallback((focusBack: boolean) => {
setAt(null);
if (focusBack) (returnTo.current && document.contains(returnTo.current) ? returnTo.current : areaRef.current)?.focus({ preventScroll: true });
}, []);
/* ---------- long press on touch ---------- */
const press = useRef<{ timer: number; x: number; y: number } | null>(null);
const cancelPress = () => {
if (press.current) window.clearTimeout(press.current.timer);
press.current = null;
};
const choose = (it: ContextMenuItem, context: string | null) => {
if (it.type === 'checkbox') {
const next = !state.checks[it.id];
setState((s) => ({ ...s, checks: { ...s.checks, [it.id]: next } }));
onAction?.(it.id, context, next);
return false;
}
if (it.type === 'radio') {
setState((s) => ({ ...s, radios: { ...s.radios, [it.group]: it.value } }));
onAction?.(it.group, context, it.value);
return true;
}
if (it.type === undefined || it.type === 'item') onAction?.(it.id ?? it.label, context);
return true;
};
const heading = at ? (typeof title === 'function' ? title(at.context) : title) : undefined;
return (
<>
<div
ref={areaRef}
tabIndex={0}
aria-describedby={`${id}-hint`}
aria-haspopup="menu"
onContextMenu={(e) => {
e.preventDefault();
cancelPress();
if ((e.target as HTMLElement).closest?.('[data-cm-panel]')) return;
// A context menu key press reports 0,0: open at the focused element instead
if (e.clientX === 0 && e.clientY === 0) {
const el = (document.activeElement as HTMLElement) ?? e.currentTarget;
const r = el.getBoundingClientRect();
openAt(r.left + Math.min(24, r.width / 2), r.top + Math.min(r.height, 32), el, true);
} else openAt(e.clientX, e.clientY, e.target, false);
}}
onKeyDown={(e) => {
if (((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu') && !(e.target as HTMLElement).closest?.('[data-cm-panel]')) {
e.preventDefault();
const el = e.target as HTMLElement;
const r = el.getBoundingClientRect();
openAt(r.left + Math.min(24, r.width / 2), r.top + Math.min(r.height, 32), el, true);
}
}}
onPointerDown={(e) => {
if (!longPress || e.pointerType !== 'touch' || (e.target as HTMLElement).closest?.('[data-cm-panel]')) return;
const { clientX: x, clientY: y, target } = e;
cancelPress();
press.current = { x, y, timer: window.setTimeout(() => openAt(x, y, target, false), 520) };
}}
onPointerMove={(e) => press.current && Math.hypot(e.clientX - press.current.x, e.clientY - press.current.y) > 10 && cancelPress()}
onPointerUp={cancelPress}
onPointerCancel={cancelPress}
className={cn('relative rounded-xl outline-none [-webkit-touch-callout:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring', className)}
>
{children}
{mounted && at && (
<MenuPanel
items={items}
area={areaRef}
point={at}
depth={0}
label={heading ?? t.menu}
title={heading}
autoFocus={at.keyboard ? 'first' : at.preview ? 'skip' : 'none'}
state={state}
onChoose={(it) => choose(it, at.context)}
onCloseAll={close}
labels={t}
/>
)}
</div>
<p id={`${id}-hint`} className={cn(showHint ? 'mt-3 flex items-start gap-1.5 text-[13px] text-pretty text-muted-foreground' : 'sr-only')}>
{showHint && <Icon name="mouse" size={14} className="mt-0.5" />}
{t.hint}
</p>
</>
);
}
interface PanelProps {
items: ContextMenuItem[];
area?: React.RefObject<HTMLDivElement | null>;
point?: Point;
/** For a submenu: the item it belongs to */
parent?: HTMLElement;
depth: number;
label: string;
title?: string;
autoFocus: 'first' | 'none' | 'skip';
state: { checks: Record<string, boolean>; radios: Record<string, string> };
/** true closes the whole menu */
onChoose: (it: ContextMenuItem) => boolean;
onCloseAll: (focusBack: boolean) => void;
onBack?: () => void;
labels: (typeof TEXTS)['en'];
}
function MenuPanel({ items, area, point, parent, depth, label, title, autoFocus, state, onChoose, onCloseAll, onBack, labels }: PanelProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ x: number; y: number; origin: string } | null>(null);
const [sub, setSub] = useState<{ index: number; el: HTMLElement; focus: 'first' | 'none' } | null>(null);
const [armed, setArmed] = useState<number | null>(null);
const typed = useRef({ text: '', at: 0 });
const hoverTimer = useRef<number | undefined>(undefined);
const nodes = () => Array.from(ref.current?.querySelectorAll<HTMLElement>(':scope > [data-cm-item]:not([aria-disabled="true"])') ?? []);
// Place it at the pointer or beside its parent item, flipped and clamped into the window
useIsoLayoutEffect(() => {
const place = () => {
const el = ref.current;
if (!el) return;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const w = el.offsetWidth;
const h = el.offsetHeight;
let x: number;
let y: number;
let ox = 'left';
let oy = 'top';
if (parent) {
const r = parent.getBoundingClientRect();
x = r.right + 2;
y = r.top - 6;
if (x + w > vw - MARGIN) {
x = r.left - w - 2;
ox = 'right';
}
// A narrow screen has no room on either side: the submenu covers its parent instead, a bit lower
if (x < MARGIN) {
x = Math.min(Math.max(r.left + 12, MARGIN), vw - MARGIN - w);
y = r.bottom - 4;
ox = 'left';
}
} else {
const a = area?.current?.getBoundingClientRect();
x = (a?.left ?? 0) + (point?.x ?? 0);
y = (a?.top ?? 0) + (point?.y ?? 0);
if (x + w > vw - MARGIN) {
x = Math.max(MARGIN, x - w);
ox = 'right';
}
if (y + h > vh - MARGIN && y - h >= MARGIN) {
y -= h;
oy = 'bottom';
}
}
x = Math.min(Math.max(x, MARGIN), Math.max(MARGIN, vw - MARGIN - w));
y = Math.min(Math.max(y, MARGIN), Math.max(MARGIN, vh - MARGIN - h));
setPos({ x: Math.round(x), y: Math.round(y), origin: `${oy} ${ox}` });
};
place();
window.addEventListener('scroll', place, true);
window.addEventListener('resize', place);
return () => {
window.removeEventListener('scroll', place, true);
window.removeEventListener('resize', place);
};
}, [parent, point?.x, point?.y]);
useEffect(() => {
const id = requestAnimationFrame(() => {
if (autoFocus === 'first') nodes()[0]?.focus({ preventScroll: true });
else if (autoFocus === 'none' && depth === 0) ref.current?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(id);
}, []);
// The root panel closes on Escape anywhere and on a press outside every panel
useEffect(() => {
if (depth !== 0) return;
const onDown = (e: PointerEvent) => {
if ((e.target as HTMLElement).closest?.('[data-cm-panel]')) return;
onCloseAll(false);
};
const onBlurWindow = () => onCloseAll(false);
// A menu opened for a preview has no focus yet, Escape still closes it
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && !ref.current?.contains(document.activeElement) && onCloseAll(false);
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onDown, true);
window.addEventListener('blur', onBlurWindow);
return () => {
document.removeEventListener('pointerdown', onDown, true);
document.removeEventListener('keydown', onKey);
window.removeEventListener('blur', onBlurWindow);
};
}, [depth, onCloseAll]);
useEffect(() => () => window.clearTimeout(hoverTimer.current), []);
const move = (to: number | 'first' | 'last') => {
const list = nodes();
if (!list.length) return;
const i = list.indexOf(document.activeElement as HTMLElement);
if (i < 0 && typeof to === 'number') to = to > 0 ? 'first' : 'last';
const n = to === 'first' ? 0 : to === 'last' ? list.length - 1 : (i + to + list.length) % list.length;
list[n].focus({ preventScroll: true });
};
const activate = (it: ContextMenuItem, i: number, el: HTMLElement, keyboard: boolean) => {
if (!isAction(it) || ('disabled' in it && it.disabled)) return;
if (it.type === 'submenu') {
setSub({ index: i, el, focus: keyboard ? 'first' : 'none' });
return;
}
if ((it.type === undefined || it.type === 'item') && it.confirm && armed !== i) {
setArmed(i);
return;
}
if (onChoose(it)) onCloseAll(true);
};
const onKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
const list = nodes();
const current = document.activeElement as HTMLElement;
const index = Number(current?.dataset.index);
const it = Number.isFinite(index) ? items[index] : undefined;
if (e.key === 'ArrowDown') (e.preventDefault(), move(1));
else if (e.key === 'ArrowUp') (e.preventDefault(), move(-1));
else if (e.key === 'Home' || e.key === 'PageUp') (e.preventDefault(), move('first'));
else if (e.key === 'End' || e.key === 'PageDown') (e.preventDefault(), move('last'));
else if (e.key === 'ArrowRight' && it?.type === 'submenu') (e.preventDefault(), activate(it, index, current, true));
else if (e.key === 'ArrowLeft' && onBack) (e.preventDefault(), onBack());
else if (e.key === 'Escape') {
e.preventDefault();
if (onBack) onBack();
else onCloseAll(true);
} else if (e.key === 'Tab') {
e.preventDefault();
onCloseAll(true);
} else if ((e.key === 'Enter' || e.key === ' ') && it) {
e.preventDefault();
activate(it, index, current, true);
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
const now = Date.now();
typed.current = { text: (now - typed.current.at > 600 ? '' : typed.current.text) + e.key.toLowerCase(), at: now };
const start = list.indexOf(current);
const ordered = [...list.slice(start + 1), ...list.slice(0, start + 1)];
const hit = typed.current.text.length > 1 ? list.find((n) => n.dataset.label?.startsWith(typed.current.text)) : ordered.find((n) => n.dataset.label?.startsWith(typed.current.text));
hit?.focus({ preventScroll: true });
}
};
return createPortal(
<div
ref={ref}
data-cm-panel=""
role="menu"
aria-label={label}
aria-orientation="vertical"
tabIndex={-1}
onKeyDown={onKeyDown}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className={cn(
'kitfx-cm fixed left-0 top-0 z-50 max-h-[calc(100dvh-16px)] min-w-56 max-w-[calc(100vw-16px)] overflow-y-auto overscroll-contain rounded-xl border border-border bg-popover p-1.5 text-popover-foreground shadow-[var(--highlight),var(--shadow-lg)] outline-none kit-no-scrollbar',
depth > 0 && 'min-w-48',
)}
style={{ transform: `translate(${pos?.x ?? 0}px, ${pos?.y ?? 0}px)`, visibility: pos ? undefined : 'hidden', '--origin': pos?.origin } as React.CSSProperties}
>
<style href="kitfx-cm" precedence="default">
{CSS}
</style>
{title && depth === 0 && (
<div role="presentation" className="kitfx-cm-item -mx-1.5 -mt-1.5 mb-1.5 truncate border-b border-border px-4 py-2.5 text-[13px] font-medium text-foreground">
{title}
</div>
)}
{items.map((it, i) => {
const order = { '--i': i } as React.CSSProperties;
if (it.type === 'separator') return <div key={i} role="separator" className="kitfx-cm-item -mx-1.5 my-1.5 h-px bg-border" style={order} />;
if (it.type === 'label')
return (
<div key={i} role="presentation" style={order} className="kitfx-cm-item px-2.5 pb-1 pt-2 text-[11px] font-medium uppercase tracking-[0.08em] text-subtle-foreground">
{it.label}
</div>
);
const isItem = it.type === undefined || it.type === 'item';
const destructive = isItem && it.destructive;
const isArmed = isItem && armed === i;
const checked = it.type === 'checkbox' ? Boolean(state.checks[it.id]) : it.type === 'radio' ? state.radios[it.group] === it.value : undefined;
const role = it.type === 'checkbox' ? 'menuitemcheckbox' : it.type === 'radio' ? 'menuitemradio' : 'menuitem';
const icon = it.type === 'radio' ? undefined : it.icon;
const shortcut = isItem || it.type === 'checkbox' ? it.shortcut : undefined;
const open = sub?.index === i;
const text = isArmed && isItem ? it.confirm! : it.label;
return (
<div
key={i}
role={role}
tabIndex={-1}
data-cm-item=""
data-index={i}
data-label={it.label.toLowerCase()}
aria-disabled={it.disabled || undefined}
aria-checked={checked}
aria-haspopup={it.type === 'submenu' ? 'menu' : undefined}
aria-expanded={it.type === 'submenu' ? open : undefined}
aria-keyshortcuts={shortcut}
style={order}
onClick={(e) => activate(it, i, e.currentTarget, e.detail === 0)}
onPointerMove={(e) => {
if (e.pointerType !== 'mouse' || it.disabled) return;
if (document.activeElement !== e.currentTarget) e.currentTarget.focus({ preventScroll: true });
window.clearTimeout(hoverTimer.current);
const el = e.currentTarget;
if (it.type === 'submenu' && !open) hoverTimer.current = window.setTimeout(() => setSub({ index: i, el, focus: 'none' }), 140);
else if (it.type !== 'submenu' && sub) hoverTimer.current = window.setTimeout(() => setSub(null), 220);
}}
onBlur={() => isArmed && setArmed(null)}
className={cn(
'kitfx-cm-item group/cm relative flex min-h-9 w-full cursor-pointer select-none items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-foreground outline-none transition-colors max-sm:min-h-11',
'focus:bg-accent aria-disabled:cursor-not-allowed aria-disabled:opacity-45',
open && 'bg-accent',
destructive && 'text-destructive focus:bg-[color-mix(in_oklab,var(--destructive)_10%,transparent)]',
isArmed && 'bg-[color-mix(in_oklab,var(--destructive)_14%,transparent)] font-medium focus:bg-[color-mix(in_oklab,var(--destructive)_18%,transparent)]',
)}
>
{(it.type === 'checkbox' || it.type === 'radio') && (
<span aria-hidden className="grid size-4 shrink-0 place-items-center">
<span className={cn('grid place-items-center transition-[scale,opacity] duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none', checked ? 'scale-100 opacity-100' : 'scale-50 opacity-0')}>
{it.type === 'checkbox' ? <Icon name="check" size={15} strokeWidth={2} /> : <span className="size-[7px] rounded-full bg-current" />}
</span>
</span>
)}
{icon && <Icon name={isArmed ? 'alert-circle' : icon} size={16} className={cn('shrink-0', destructive ? 'text-destructive' : 'text-muted-foreground group-focus/cm:text-foreground')} />}
<span className="min-w-0 flex-1">
<span className="block truncate">{text}</span>
{isItem && it.description && !isArmed && <span className="block truncate text-xs text-muted-foreground">{it.description}</span>}
</span>
{shortcut && !isArmed && <span className="ms-4 shrink-0 text-xs tracking-wide text-subtle-foreground">{shortcut}</span>}
{it.type === 'submenu' && <Icon name="chevron-right" size={15} className="ms-3 shrink-0 text-muted-foreground" />}
</div>
);
})}
{sub && items[sub.index]?.type === 'submenu' && (
<MenuPanel
items={(items[sub.index] as Extract<ContextMenuItem, { type: 'submenu' }>).items}
parent={sub.el}
depth={depth + 1}
label={items[sub.index].type === 'submenu' ? (items[sub.index] as { label: string }).label : labels.menu}
autoFocus={sub.focus}
state={state}
onChoose={onChoose}
onCloseAll={onCloseAll}
onBack={() => {
const el = sub.el;
setSub(null);
el.focus({ preventScroll: true });
}}
labels={labels}
/>
)}
</div>,
document.body,
);
}Häufige Fragen
Wie installiere ich Kontextmenü?
Mit aiw add @ai-website/context-menu. Der Befehl legt die Datei components/ui/context-menu.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Kontextmenü?
4 Varianten, Dateiliste, Gestaltungsfläche, Rechtschreibung im Text und Chatnachrichten.
Kann ich die Texte von Kontextmenü übersetzen?
Ja. Kontextmenü bringt 3 eigene Texte auf Deutsch und Englisch mit, etwa „Aktionen“, „Rechtsklick, lange drücken oder Umschalt F10 für Aktionen.“ und „Zurück“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Kontextmenü einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Kontextmenü mit aiw_components und installieren sie mit aiw_add @ai-website/context-menu.