component ContextMenu { outputs { open(payload: { x: number; y: number; trigger: string; sourceEvent: Event }) close(payload: { reason: string; sourceEvent: Event }) select(payload: { item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event }) action(payload: { item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event }) } props { items: unknown[] = [] open: boolean = false defaultOpen: boolean = false trigger: string = "contextmenu" placement: string = "pointer" align: string = "start" size: string = "default" color: string = "primary" variant: string = "raised" title: string = "" description: string = "" label: string = "Context menu" closeOnSelect: boolean = true closeOnOutside: boolean = true disabled: boolean = false minWidth: string = "14rem" maxWidth: string = "20rem" class: string = "" } state visible = defaultOpen state positionX: number = 16 state positionY: number = 16 functions { shared function isOpen() { return open || visible } client function showMenu(sourceEvent) { if (disabled) { return } if (sourceEvent && sourceEvent.type === "contextmenu") { sourceEvent.preventDefault() } if (placement === "pointer" && sourceEvent) { // Place the menu at the pointer and let the anchored clamp in the // runtime pull it back on screen once it has been laid out and can // actually be measured. Subtracting a guessed 340x420 here instead // pushed every menu that was not that size away from the pointer. positionX = Math.max(12, sourceEvent.clientX || 12) positionY = Math.max(12, sourceEvent.clientY || 12) } visible = true output.open({ x: positionX, y: positionY, trigger: trigger, sourceEvent: sourceEvent }) } client function hideMenu(reason, sourceEvent) { visible = false output.close({ reason: reason, sourceEvent: sourceEvent }) } client function toggleMenu(sourceEvent) { if (isOpen()) { hideMenu("toggle", sourceEvent) } else { showMenu(sourceEvent) } } client function chooseItem(item, itemIndex, sourceEvent) { if (disabled || item.disabled || item.type === "header" || item.type === "divider") { sourceEvent.preventDefault() return } output.select({ item: item, itemIndex: itemIndex, value: item.value || "", sourceEvent: sourceEvent }) if (item.action) { output.action({ item: item, itemIndex: itemIndex, value: item.value || "", sourceEvent: sourceEvent }) } if (closeOnSelect) { hideMenu("select", sourceEvent) } } client function moveFocus(sourceEvent, direction) { const root = sourceEvent.currentTarget.closest(".wire-context-menu") || sourceEvent.currentTarget const options = [...root.querySelectorAll("[data-context-menu-item]:not([disabled])")] if (!options.length) { return } const activeIndex = options.indexOf(document.activeElement) const nextIndex = (activeIndex + direction + options.length) % options.length options[nextIndex].focus() } client function handleKeydown(sourceEvent) { if (sourceEvent.key === "Escape") { sourceEvent.preventDefault() hideMenu("escape", sourceEvent) } else if (sourceEvent.key === "ArrowDown") { sourceEvent.preventDefault() moveFocus(sourceEvent, 1) } else if (sourceEvent.key === "ArrowUp") { sourceEvent.preventDefault() moveFocus(sourceEvent, -1) } } } view {
} style { .wire-context-menu { --context-accent: var(--wire-color-primary); --context-soft: var(--wire-color-primary-soft); position: relative; display: block; min-width: 0; } .wire-context-menu[data-color="secondary"] { --context-accent: var(--wire-color-secondary); --context-soft: var(--wire-color-secondary-soft); } .wire-context-menu[data-color="info"] { --context-accent: var(--wire-color-info); --context-soft: var(--wire-color-info-soft); } .wire-context-menu[data-color="success"] { --context-accent: var(--wire-color-success); --context-soft: var(--wire-color-success-soft); } .wire-context-menu[data-color="warning"] { --context-accent: var(--wire-color-warning); --context-soft: var(--wire-color-warning-soft); } .wire-context-menu[data-color="danger"] { --context-accent: var(--wire-color-danger); --context-soft: var(--wire-color-danger-soft); } .wire-context-menu__trigger { display: block; min-width: 0; outline: none; } .wire-context-menu__trigger:focus-visible { border-radius: 0.6rem; outline: 2px solid var(--wire-color-focus); outline-offset: 3px; } .wire-context-menu__dismiss-layer { position: fixed; inset: 0; z-index: 1090; appearance: none; padding: 0; background: transparent; border: 0; } .wire-context-menu__panel { position: absolute; z-index: 1091; top: calc(100% + 0.5rem); left: 0; width: max-content; min-width: var(--wire-context-min-width); max-width: min(var(--wire-context-max-width), calc(100vw - 1.5rem)); padding: 0.45rem; color: var(--wire-color-text); background: linear-gradient(145deg, color-mix(in srgb, var(--context-accent) 5%, transparent), transparent 58%), color-mix(in srgb, var(--wire-color-surface-raised) 96%, transparent); border: 1px solid color-mix(in srgb, var(--context-accent) 18%, var(--wire-color-border)); border-radius: 1rem; box-shadow: 0 1px 0 color-mix(in srgb, white 5%, transparent) inset, 0 24px 64px color-mix(in srgb, black 24%, transparent); backdrop-filter: blur(18px); transform-origin: top left; } .wire-context-menu[data-placement="pointer"] .wire-context-menu__panel { position: fixed; top: var(--wire-context-y); left: var(--wire-context-x); } .wire-context-menu[data-placement="bottom-end"] .wire-context-menu__panel { right: 0; left: auto; transform-origin: top right; } .wire-context-menu[data-placement="top-start"] .wire-context-menu__panel { top: auto; bottom: calc(100% + 0.5rem); transform-origin: bottom left; } .wire-context-menu[data-placement="top-end"] .wire-context-menu__panel { top: auto; right: 0; bottom: calc(100% + 0.5rem); left: auto; transform-origin: bottom right; } .wire-context-menu[data-variant="soft"] .wire-context-menu__panel { background: linear-gradient(145deg, var(--context-soft), transparent 72%), var(--wire-color-surface-raised); box-shadow: 0 18px 48px color-mix(in srgb, black 16%, transparent); } .wire-context-menu[data-variant="outline"] .wire-context-menu__panel { background: var(--wire-color-surface-raised); box-shadow: none; } .wire-context-menu__header { display: grid; gap: 0.2rem; padding: 0.65rem 0.75rem 0.75rem; border-bottom: 1px solid var(--wire-color-border); } .wire-context-menu__header strong { font-size: 0.86rem; font-weight: 650; } .wire-context-menu__header span { color: var(--wire-color-text-muted); font-size: 0.75rem; line-height: 1.45; } .wire-context-menu__items { display: grid; gap: 0.15rem; padding-block: 0.25rem; } .wire-context-menu__section-label { padding: 0.55rem 0.75rem 0.3rem; color: var(--wire-color-text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; } .wire-context-menu__divider { height: 1px; margin: 0.3rem 0.45rem; background: var(--wire-color-border); } .wire-context-menu__item { appearance: none; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.7rem; width: 100%; min-width: 0; padding: 0.65rem 0.7rem; color: var(--wire-color-text); background: transparent; border: 0; border-radius: 0.72rem; font: inherit; text-align: left; text-decoration: none; cursor: pointer; transition: background-color 150ms ease, color 150ms ease, transform 150ms ease; } .wire-context-menu__item:hover, .wire-context-menu__item:focus-visible, .wire-context-menu__item[data-selected="true"] { color: var(--context-accent); background: var(--context-soft); outline: none; } .wire-context-menu__item:active { transform: scale(0.985); } .wire-context-menu__item[data-danger="true"] { color: var(--wire-color-danger); } .wire-context-menu__item[data-danger="true"]:hover, .wire-context-menu__item[data-danger="true"]:focus-visible { background: var(--wire-color-danger-soft); } .wire-context-menu__item[disabled], .wire-context-menu__item[aria-disabled="true"] { opacity: 0.48; pointer-events: none; } .wire-context-menu__icon, .wire-context-menu__status { width: 1rem; height: 1rem; color: currentColor; } .wire-context-menu__copy { display: grid; gap: 0.12rem; min-width: 0; } .wire-context-menu__copy strong { overflow: hidden; font-size: 0.82rem; font-weight: 600; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } .wire-context-menu__copy small { overflow: hidden; color: var(--wire-color-text-muted); font-size: 0.7rem; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } .wire-context-menu kbd { padding: 0.16rem 0.38rem; color: var(--wire-color-text-muted); background: var(--wire-color-surface-soft); border: 1px solid var(--wire-color-border); border-radius: 0.36rem; font-size: 0.64rem; font-family: inherit; } .wire-context-menu[data-size="sm"] .wire-context-menu__item { padding: 0.52rem 0.6rem; } .wire-context-menu[data-size="lg"] .wire-context-menu__item { padding: 0.78rem 0.82rem; } @media (max-width: 639px) { .wire-context-menu[data-placement="pointer"] .wire-context-menu__panel { right: 0.75rem; bottom: 0.75rem; left: 0.75rem; top: auto; width: auto; max-width: none; } } @media (prefers-reduced-motion: reduce) { .wire-context-menu__item { transition: none; } } } }