Context Menu
context-menuuiRight-click menu with items, checkbox/radio items, submenus, labels, and shortcuts. Hand-rolled runes behavior: cursor-anchored portal, viewport clamping, outside/Escape close, and arrow-key navigation with roving focus.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/context-menu.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/context-menu.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/context-menu.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/context-menu.jsonManifest:
https://uipkge.dev/r/svelte/context-menu.jsonInstalls to:src/lib/components/ui/context-menu/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | 'default''destructive' | default | optional |
inset | 'true''false' | — | optional |
open | boolean | — | optional |
onOpenChange | (open: boolean) => void | — | optional |
children | Snippet | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
MenuPositioninterface MenuPosition {
x: number
y: number
}ContextMenuContextValueinterface ContextMenuContextValue {
readonly open: boolean
openAt: (position: MenuPosition) => void
close: () => void
readonly position: MenuPosition
registerMenuElement: (el: HTMLElement) => void
unregisterMenuElement: (el: HTMLElement) => void
registerTrigger: (el: HTMLElement | null) => void
isInsideMenu: (target: EventTarget | null) => boolean
}ContextMenuSubContextValueinterface ContextMenuSubContextValue {
readonly open: boolean
setOpen: (open: boolean) => void
getTriggerElement: () => HTMLElement | null
registerTriggerElement: (el: HTMLElement | null) => void
registerContentElement: (el: HTMLElement | null) => void
focusFirstItem: () => void
focusTrigger: () => void
}ContextMenuRadioContextValueinterface ContextMenuRadioContextValue {
readonly value: string
setValue: (value: string) => void
}npm dependencies
Used by
Files installed (19)
src/lib/components/ui/context-menu/ContextMenu.svelte2.2 kB
<script lang="ts" module> import type { Snippet } from 'svelte' export interface ContextMenuProps { open?: boolean onOpenChange?: (open: boolean) => void children?: Snippet } </script> <script lang="ts"> import { setContextMenuContext, type MenuPosition } from './context' let { open = $bindable(false), onOpenChange, children }: ContextMenuProps = $props() let position = $state<MenuPosition>({ x: 0, y: 0 }) const menuElements = new Set<HTMLElement>() let triggerEl: HTMLElement | null = null function setOpen(next: boolean) { if (next === open) return open = next onOpenChange?.(next) } function isInsideMenu(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false if (triggerEl?.contains(target)) return true for (const el of menuElements) { if (el.contains(target)) return true } return false } $effect(() => { if (!open) return const onPointerDown = (e: PointerEvent) => { if (!isInsideMenu(e.target)) setOpen(false) } const onContextMenu = (e: MouseEvent) => { // Right-clicking elsewhere moves or closes the menu; the new trigger (if // any) reopens at its own position. if (!isInsideMenu(e.target)) setOpen(false) } const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() setOpen(false) } } window.addEventListener('pointerdown', onPointerDown, true) window.addEventListener('contextmenu', onContextMenu, true) window.addEventListener('keydown', onKeyDown) return () => { window.removeEventListener('pointerdown', onPointerDown, true) window.removeEventListener('contextmenu', onContextMenu, true) window.removeEventListener('keydown', onKeyDown) } }) setContextMenuContext({ get open() { return open }, openAt(pos) { position = pos setOpen(true) }, close() { setOpen(false) }, get position() { return position }, registerMenuElement(el) { menuElements.add(el) }, unregisterMenuElement(el) { menuElements.delete(el) }, registerTrigger(el) { triggerEl = el }, isInsideMenu, }) </script> {@render children?.()}src/lib/components/ui/context-menu/ContextMenuCheckboxItem.svelte2.4 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface ContextMenuCheckboxItemProps extends HTMLAttributes<HTMLDivElement> { checked?: boolean | 'indeterminate' onCheckedChange?: (checked: boolean | 'indeterminate') => void disabled?: boolean /** Override the check indicator. */ indicator?: Snippet ref?: HTMLDivElement | null } </script> <script lang="ts"> import { Check } from '@lucide/svelte' import { cn } from '$lib/utils' type OnClick = NonNullable<HTMLAttributes<HTMLDivElement>['onclick']> type OnMouseEnter = NonNullable<HTMLAttributes<HTMLDivElement>['onmouseenter']> let { class: className, checked = $bindable(false), onCheckedChange, disabled = false, indicator, children, ref = $bindable(null), onclick, onmouseenter, ...restProps }: ContextMenuCheckboxItemProps = $props() const handleClick: OnClick = (e) => { onclick?.(e) if (e.defaultPrevented || disabled) return const next = checked === true ? false : true checked = next onCheckedChange?.(next) } const handleMouseEnter: OnMouseEnter = (e) => { onmouseenter?.(e) if (!disabled) ref?.focus() } </script> <div bind:this={ref} data-uipkge data-slot="context-menu-checkbox-item" data-context-menu-item="" role="menuitemcheckbox" tabindex="-1" aria-checked={checked === 'indeterminate' ? 'mixed' : checked} data-state={checked === 'indeterminate' ? 'indeterminate' : checked ? 'checked' : 'unchecked'} data-disabled={disabled ? '' : undefined} aria-disabled={disabled || undefined} class={cn( "focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...restProps} onclick={handleClick} onmouseenter={handleMouseEnter} > <span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> {#if checked === true || checked === 'indeterminate'} {#if indicator} {@render indicator()} {:else} <Check class="size-4" aria-hidden="true" /> {/if} {/if} </span> {@render children?.()} </div>src/lib/components/ui/context-menu/ContextMenuContent.svelte3 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface ContextMenuContentProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { tick } from 'svelte' import { cn } from '$lib/utils' import { getContextMenuContext, menuItemsOf } from './context' import { portal } from './portal' type OnKeyDown = NonNullable<HTMLAttributes<HTMLDivElement>['onkeydown']> let { class: className, children, ref = $bindable(null), onkeydown, ...restProps }: ContextMenuContentProps = $props() const ctx = getContextMenuContext() let adjusted = $state<{ x: number; y: number } | null>(null) $effect(() => { if (!ctx.open || !ref) return ctx.registerMenuElement(ref) const el = ref // Clamp into the viewport once measured. tick().then(() => { const rect = el.getBoundingClientRect() adjusted = { x: Math.max(8, Math.min(ctx.position.x, window.innerWidth - rect.width - 8)), y: Math.max(8, Math.min(ctx.position.y, window.innerHeight - rect.height - 8)), } }) tick().then(() => el.focus({ preventScroll: true })) return () => { ctx.unregisterMenuElement(el) adjusted = null } }) const x = $derived(adjusted?.x ?? ctx.position.x) const y = $derived(adjusted?.y ?? ctx.position.y) const handleKeyDown: OnKeyDown = (e) => { onkeydown?.(e) if (e.defaultPrevented || !ref) return const items = menuItemsOf(ref) if (!items.length) return const current = items.indexOf(document.activeElement as HTMLElement) if (e.key === 'ArrowDown') { e.preventDefault() ;(items[current === -1 ? 0 : (current + 1) % items.length]!).focus() } else if (e.key === 'ArrowUp') { e.preventDefault() ;(items[current === -1 ? items.length - 1 : (current - 1 + items.length) % items.length]!).focus() } else if (e.key === 'Home') { e.preventDefault() items[0]!.focus() } else if (e.key === 'End') { e.preventDefault() items[items.length - 1]!.focus() } else if (e.key === 'Tab') { ctx.close() } } </script> {#if ctx.open} <div bind:this={ref} use:portal data-uipkge data-slot="context-menu-content" role="menu" tabindex="-1" data-state="open" data-side="bottom" style:position="fixed" style:left="{x}px" style:top="{y}px" class={cn( 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[min(24rem,calc(100vh-1rem))] min-w-32 overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', className, )} {...restProps} onkeydown={handleKeyDown} > {@render children?.()} </div> {/if}src/lib/components/ui/context-menu/ContextMenuGroup.svelte0.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface ContextMenuGroupProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> let { class: className, children, ref = $bindable(null), ...restProps }: ContextMenuGroupProps = $props() </script> <div bind:this={ref} data-uipkge data-slot="context-menu-group" role="group" class={className} {...restProps}> {@render children?.()} </div>src/lib/components/ui/context-menu/ContextMenuItem.svelte1.7 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { ContextMenuItemVariants } from './context-menu-item.variants' export interface ContextMenuItemProps extends HTMLAttributes<HTMLDivElement> { inset?: boolean variant?: ContextMenuItemVariants['variant'] disabled?: boolean onSelect?: () => void ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getContextMenuContext } from './context' import { contextMenuItemVariants } from './context-menu-item.variants' type OnClick = NonNullable<HTMLAttributes<HTMLDivElement>['onclick']> type OnMouseEnter = NonNullable<HTMLAttributes<HTMLDivElement>['onmouseenter']> let { class: className, inset = false, variant = 'default', disabled = false, onSelect, children, ref = $bindable(null), onclick, onmouseenter, ...restProps }: ContextMenuItemProps = $props() const ctx = getContextMenuContext() const handleClick: OnClick = (e) => { onclick?.(e) if (e.defaultPrevented || disabled) return onSelect?.() ctx.close() } const handleMouseEnter: OnMouseEnter = (e) => { onmouseenter?.(e) if (!disabled) ref?.focus() } </script> <div bind:this={ref} data-uipkge data-slot="context-menu-item" data-context-menu-item="" role="menuitem" tabindex="-1" data-inset={inset ? '' : undefined} data-variant={variant} data-disabled={disabled ? '' : undefined} aria-disabled={disabled || undefined} class={cn(contextMenuItemVariants({ variant, inset }), className)} {...restProps} onclick={handleClick} onmouseenter={handleMouseEnter} > {@render children?.()} </div>src/lib/components/ui/context-menu/context-menu-item.variants.ts1.3 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' /** * Variant definitions live in their own file (rather than the package * `index.ts`) so consuming components can import without creating a circular * dependency through the index. */ export const contextMenuItemVariants = cva( 'relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*="size-"])]:size-4 [&_svg:not([class*="text-"])]:text-muted-foreground', { variants: { variant: { default: '', destructive: 'data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive', }, inset: { true: 'data-[inset]:pl-8', false: '', }, }, defaultVariants: { variant: 'default', inset: false, }, }, ) export type ContextMenuItemVariants = VariantProps<typeof contextMenuItemVariants>src/lib/components/ui/context-menu/ContextMenuLabel.svelte0.7 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface ContextMenuLabelProps extends HTMLAttributes<HTMLDivElement> { inset?: boolean ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, inset = false, children, ref = $bindable(null), ...restProps }: ContextMenuLabelProps = $props() </script> <div bind:this={ref} data-uipkge data-slot="context-menu-label" data-inset={inset ? '' : undefined} class={cn('text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/context-menu/ContextMenuPortal.svelte0.4 kB
<script lang="ts" module> import type { Snippet } from 'svelte' export interface ContextMenuPortalProps { children?: Snippet } </script> <script lang="ts"> import { portal } from './portal' let { children }: ContextMenuPortalProps = $props() </script> <div use:portal data-uipkge data-slot="context-menu-portal" style:display="contents"> {@render children?.()} </div>src/lib/components/ui/context-menu/ContextMenuRadioGroup.svelte0.8 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface ContextMenuRadioGroupProps extends HTMLAttributes<HTMLDivElement> { value?: string onValueChange?: (value: string) => void ref?: HTMLDivElement | null } </script> <script lang="ts"> import { setContextMenuRadioContext } from './context' let { class: className, value = $bindable(''), onValueChange, children, ref = $bindable(null), ...restProps }: ContextMenuRadioGroupProps = $props() setContextMenuRadioContext({ get value() { return value }, setValue(v) { value = v onValueChange?.(v) }, }) </script> <div bind:this={ref} data-uipkge data-slot="context-menu-radio-group" role="group" class={className} {...restProps} > {@render children?.()} </div>src/lib/components/ui/context-menu/ContextMenuRadioItem.svelte2.3 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface ContextMenuRadioItemProps extends HTMLAttributes<HTMLDivElement> { value: string disabled?: boolean /** Override the dot indicator. */ indicator?: Snippet ref?: HTMLDivElement | null } </script> <script lang="ts"> import { Circle } from '@lucide/svelte' import { cn } from '$lib/utils' import { getContextMenuRadioContext } from './context' type OnClick = NonNullable<HTMLAttributes<HTMLDivElement>['onclick']> type OnMouseEnter = NonNullable<HTMLAttributes<HTMLDivElement>['onmouseenter']> let { class: className, value: valueProp, disabled = false, indicator, children, ref = $bindable(null), onclick, onmouseenter, ...restProps }: ContextMenuRadioItemProps = $props() const radio = getContextMenuRadioContext() const selected = $derived(radio.value === valueProp) const handleClick: OnClick = (e) => { onclick?.(e) if (e.defaultPrevented || disabled) return radio.setValue(valueProp) } const handleMouseEnter: OnMouseEnter = (e) => { onmouseenter?.(e) if (!disabled) ref?.focus() } </script> <div bind:this={ref} data-uipkge data-slot="context-menu-radio-item" data-context-menu-item="" role="menuitemradio" tabindex="-1" aria-checked={selected} data-state={selected ? 'checked' : 'unchecked'} data-disabled={disabled ? '' : undefined} aria-disabled={disabled || undefined} class={cn( "focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...restProps} onclick={handleClick} onmouseenter={handleMouseEnter} > <span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> {#if selected} {#if indicator} {@render indicator()} {:else} <Circle class="size-2 fill-current" aria-hidden="true" /> {/if} {/if} </span> {@render children?.()} </div>src/lib/components/ui/context-menu/ContextMenuSeparator.svelte0.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface ContextMenuSeparatorProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, ref = $bindable(null), ...restProps }: ContextMenuSeparatorProps = $props() </script> <div bind:this={ref} data-uipkge data-slot="context-menu-separator" role="separator" class={cn('bg-border -mx-1 my-1 h-px', className)} {...restProps} ></div>src/lib/components/ui/context-menu/ContextMenuShortcut.svelte0.6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface ContextMenuShortcutProps extends HTMLAttributes<HTMLElement> { ref?: HTMLElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: ContextMenuShortcutProps = $props() </script> <span bind:this={ref} data-uipkge data-slot="context-menu-shortcut" class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)} {...restProps} > {@render children?.()} </span>src/lib/components/ui/context-menu/ContextMenuSub.svelte1.1 kB
<script lang="ts" module> import type { Snippet } from 'svelte' export interface ContextMenuSubProps { open?: boolean onOpenChange?: (open: boolean) => void children?: Snippet } </script> <script lang="ts"> import { tick } from 'svelte' import { menuItemsOf, setContextMenuSubContext } from './context' let { open = $bindable(false), onOpenChange, children }: ContextMenuSubProps = $props() let triggerEl: HTMLElement | null = null let contentEl: HTMLElement | null = null function setOpen(next: boolean) { if (next === open) return open = next onOpenChange?.(next) } setContextMenuSubContext({ get open() { return open }, setOpen, getTriggerElement() { return triggerEl }, registerTriggerElement(el) { triggerEl = el }, registerContentElement(el) { contentEl = el }, focusFirstItem() { tick().then(() => { if (contentEl) menuItemsOf(contentEl)[0]?.focus() }) }, focusTrigger() { triggerEl?.focus() }, }) </script> {@render children?.()}src/lib/components/ui/context-menu/ContextMenuSubContent.svelte3.4 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface ContextMenuSubContentProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { tick } from 'svelte' import { cn } from '$lib/utils' import { getContextMenuContext, getContextMenuSubContext, menuItemsOf } from './context' import { portal } from './portal' type OnKeyDown = NonNullable<HTMLAttributes<HTMLDivElement>['onkeydown']> let { class: className, children, ref = $bindable(null), onkeydown, ...restProps }: ContextMenuSubContentProps = $props() const ctx = getContextMenuContext() const sub = getContextMenuSubContext() let pos = $state({ x: 0, y: 0 }) $effect(() => { if (!ctx.open || !sub.open || !ref) return const el = ref ctx.registerMenuElement(el) sub.registerContentElement(el) // Anchor to the trigger's right edge, then flip/clamp into the viewport. tick().then(() => { const trigger = sub.getTriggerElement() const rect = el.getBoundingClientRect() let x = pos.x || window.innerWidth / 2 let y = pos.y || window.innerHeight / 2 if (trigger) { const t = trigger.getBoundingClientRect() x = t.right - 4 y = t.top - 4 if (x + rect.width > window.innerWidth - 8) x = t.left - rect.width + 4 } pos = { x: Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)), y: Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)), } }) return () => { ctx.unregisterMenuElement(el) sub.registerContentElement(null) } }) const handleKeyDown: OnKeyDown = (e) => { onkeydown?.(e) if (e.defaultPrevented || !ref) return if (e.key === 'Escape' || e.key === 'ArrowLeft') { // Close only the submenu — stop the root Escape handler from closing all. e.preventDefault() e.stopPropagation() sub.setOpen(false) sub.focusTrigger() return } const items = menuItemsOf(ref) if (!items.length) return const current = items.indexOf(document.activeElement as HTMLElement) if (e.key === 'ArrowDown') { e.preventDefault() ;(items[current === -1 ? 0 : (current + 1) % items.length]!).focus() } else if (e.key === 'ArrowUp') { e.preventDefault() ;(items[current === -1 ? items.length - 1 : (current - 1 + items.length) % items.length]!).focus() } else if (e.key === 'Tab') { ctx.close() } } </script> {#if ctx.open && sub.open} <div bind:this={ref} use:portal data-uipkge data-slot="context-menu-sub-content" role="menu" tabindex="-1" data-state="open" data-side="right" style:position="fixed" style:left="{pos.x}px" style:top="{pos.y}px" class={cn( 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg', className, )} {...restProps} onkeydown={handleKeyDown} > {@render children?.()} </div> {/if}src/lib/components/ui/context-menu/ContextMenuSubTrigger.svelte2.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface ContextMenuSubTriggerProps extends HTMLAttributes<HTMLDivElement> { inset?: boolean disabled?: boolean ref?: HTMLDivElement | null } </script> <script lang="ts"> import { ChevronRight } from '@lucide/svelte' import { cn } from '$lib/utils' import { getContextMenuSubContext } from './context' type OnClick = NonNullable<HTMLAttributes<HTMLDivElement>['onclick']> type OnMouseEnter = NonNullable<HTMLAttributes<HTMLDivElement>['onmouseenter']> type OnKeyDown = NonNullable<HTMLAttributes<HTMLDivElement>['onkeydown']> let { class: className, inset = false, disabled = false, children, ref = $bindable(null), onclick, onmouseenter, onkeydown, ...restProps }: ContextMenuSubTriggerProps = $props() const sub = getContextMenuSubContext() $effect(() => { sub.registerTriggerElement(ref) return () => sub.registerTriggerElement(null) }) const handleClick: OnClick = (e) => { onclick?.(e) if (e.defaultPrevented || disabled) return sub.setOpen(!sub.open) if (!sub.open) sub.focusFirstItem() } const handleMouseEnter: OnMouseEnter = (e) => { onmouseenter?.(e) if (disabled) return ref?.focus() sub.setOpen(true) } const handleKeyDown: OnKeyDown = (e) => { onkeydown?.(e) if (e.defaultPrevented || disabled) return if (e.key === 'ArrowRight' || e.key === 'Enter') { e.preventDefault() sub.setOpen(true) sub.focusFirstItem() } } </script> <div bind:this={ref} data-uipkge data-slot="context-menu-sub-trigger" data-context-menu-item="" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded={sub.open} data-state={sub.open ? 'open' : 'closed'} data-inset={inset ? '' : undefined} data-disabled={disabled ? '' : undefined} aria-disabled={disabled || undefined} class={cn( "focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...restProps} onclick={handleClick} onmouseenter={handleMouseEnter} onkeydown={handleKeyDown} > {@render children?.()} <ChevronRight class="ml-auto" aria-hidden="true" /> </div>src/lib/components/ui/context-menu/ContextMenuTrigger.svelte1.2 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface ContextMenuTriggerProps extends HTMLAttributes<HTMLSpanElement> { disabled?: boolean ref?: HTMLSpanElement | null } </script> <script lang="ts"> import { getContextMenuContext } from './context' type OnContextMenu = NonNullable<HTMLAttributes<HTMLSpanElement>['oncontextmenu']> let { class: className, disabled = false, children, ref = $bindable(null), oncontextmenu, ...restProps }: ContextMenuTriggerProps = $props() const ctx = getContextMenuContext() $effect(() => { ctx.registerTrigger(ref) return () => ctx.registerTrigger(null) }) const handleContextMenu: OnContextMenu = (e) => { oncontextmenu?.(e) if (e.defaultPrevented || disabled) return e.preventDefault() ctx.openAt({ x: e.clientX, y: e.clientY }) } </script> <span bind:this={ref} data-uipkge data-slot="context-menu-trigger" data-state={ctx.open ? 'open' : 'closed'} data-disabled={disabled ? '' : undefined} class={className} {...restProps} oncontextmenu={handleContextMenu} > {@render children?.()} </span>src/lib/components/ui/context-menu/context.ts2.6 kB
import { getContext, setContext } from 'svelte' export interface MenuPosition { x: number y: number } export interface ContextMenuContextValue { readonly open: boolean openAt: (position: MenuPosition) => void close: () => void readonly position: MenuPosition registerMenuElement: (el: HTMLElement) => void unregisterMenuElement: (el: HTMLElement) => void registerTrigger: (el: HTMLElement | null) => void isInsideMenu: (target: EventTarget | null) => boolean } export interface ContextMenuSubContextValue { readonly open: boolean setOpen: (open: boolean) => void getTriggerElement: () => HTMLElement | null registerTriggerElement: (el: HTMLElement | null) => void registerContentElement: (el: HTMLElement | null) => void focusFirstItem: () => void focusTrigger: () => void } export interface ContextMenuRadioContextValue { readonly value: string setValue: (value: string) => void } const CONTEXT_MENU_CONTEXT_KEY = Symbol('uipkge-context-menu') const CONTEXT_MENU_SUB_CONTEXT_KEY = Symbol('uipkge-context-menu-sub') const CONTEXT_MENU_RADIO_CONTEXT_KEY = Symbol('uipkge-context-menu-radio') export function setContextMenuContext(value: ContextMenuContextValue): void { setContext(CONTEXT_MENU_CONTEXT_KEY, value) } export function getContextMenuContext(): ContextMenuContextValue { const value = getContext<ContextMenuContextValue | undefined>(CONTEXT_MENU_CONTEXT_KEY) if (!value) throw new Error('ContextMenu parts must be used inside <ContextMenu>') return value } export function setContextMenuSubContext(value: ContextMenuSubContextValue): void { setContext(CONTEXT_MENU_SUB_CONTEXT_KEY, value) } export function getContextMenuSubContext(): ContextMenuSubContextValue { const value = getContext<ContextMenuSubContextValue | undefined>(CONTEXT_MENU_SUB_CONTEXT_KEY) if (!value) throw new Error('ContextMenuSub parts must be used inside <ContextMenuSub>') return value } export function setContextMenuRadioContext(value: ContextMenuRadioContextValue): void { setContext(CONTEXT_MENU_RADIO_CONTEXT_KEY, value) } export function getContextMenuRadioContext(): ContextMenuRadioContextValue { const value = getContext<ContextMenuRadioContextValue | undefined>(CONTEXT_MENU_RADIO_CONTEXT_KEY) if (!value) throw new Error('ContextMenuRadioItem must be used inside <ContextMenuRadioGroup>') return value } /** Enabled menu items within a menu container, in DOM order. */ export function menuItemsOf(container: HTMLElement): HTMLElement[] { return [...container.querySelectorAll<HTMLElement>('[data-context-menu-item]')].filter( (el) => !el.hasAttribute('data-disabled') && el.getAttribute('aria-disabled') !== 'true', ) }src/lib/components/ui/context-menu/portal.ts0.5 kB
import type { Action } from 'svelte/action' /** * Move the node to `document.body` (or a selector/element target) while it is * mounted. Actions only run in the browser, so this is SSR-safe. */ export const portal: Action<HTMLElement, HTMLElement | string | undefined> = (node, target) => { const targetEl = typeof target === 'string' ? document.querySelector(target) : (target ?? document.body) ;(targetEl ?? document.body).appendChild(node) return { destroy() { node.remove() }, } }src/lib/components/ui/context-menu/index.ts1.7 kB
export { default as ContextMenu, type ContextMenuProps } from './ContextMenu.svelte' export { default as ContextMenuCheckboxItem, type ContextMenuCheckboxItemProps } from './ContextMenuCheckboxItem.svelte' export { default as ContextMenuContent, type ContextMenuContentProps } from './ContextMenuContent.svelte' export { default as ContextMenuGroup, type ContextMenuGroupProps } from './ContextMenuGroup.svelte' export { default as ContextMenuItem, type ContextMenuItemProps } from './ContextMenuItem.svelte' export { default as ContextMenuLabel, type ContextMenuLabelProps } from './ContextMenuLabel.svelte' export { default as ContextMenuPortal, type ContextMenuPortalProps } from './ContextMenuPortal.svelte' export { default as ContextMenuRadioGroup, type ContextMenuRadioGroupProps } from './ContextMenuRadioGroup.svelte' export { default as ContextMenuRadioItem, type ContextMenuRadioItemProps } from './ContextMenuRadioItem.svelte' export { default as ContextMenuSeparator, type ContextMenuSeparatorProps } from './ContextMenuSeparator.svelte' export { default as ContextMenuShortcut, type ContextMenuShortcutProps } from './ContextMenuShortcut.svelte' export { default as ContextMenuSub, type ContextMenuSubProps } from './ContextMenuSub.svelte' export { default as ContextMenuSubContent, type ContextMenuSubContentProps } from './ContextMenuSubContent.svelte' export { default as ContextMenuSubTrigger, type ContextMenuSubTriggerProps } from './ContextMenuSubTrigger.svelte' export { default as ContextMenuTrigger, type ContextMenuTriggerProps } from './ContextMenuTrigger.svelte' export { contextMenuItemVariants, type ContextMenuItemVariants } from './context-menu-item.variants'
Raw manifest:https://uipkge.dev/r/svelte/context-menu.json