| 1 | <script lang="ts" context="module"> |
| 2 | export interface MenuItem { |
| 3 | label: string; |
| 4 | action: () => void; |
| 5 | danger?: boolean; |
| 6 | disabled?: boolean; |
| 7 | } |
| 8 | </script> |
| 9 | |
| 10 | <script lang="ts"> |
| 11 | import { onMount, tick } from 'svelte'; |
| 12 | |
| 13 | export let items: MenuItem[]; |
| 14 | export let x: number; |
| 15 | export let y: number; |
| 16 | export let onClose: () => void; |
| 17 | |
| 18 | const MARGIN = 8; |
| 19 | |
| 20 | let menu: HTMLDivElement; |
| 21 | let left = x; |
| 22 | let top = y; |
| 23 | |
| 24 | onMount(async () => { |
| 25 | await tick(); |
| 26 | const rect = menu.getBoundingClientRect(); |
| 27 | left = Math.max(MARGIN, Math.min(x, window.innerWidth - rect.width - MARGIN)); |
| 28 | top = Math.max(MARGIN, Math.min(y, window.innerHeight - rect.height - MARGIN)); |
| 29 | menu.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus(); |
| 30 | }); |
| 31 | |
| 32 | function pick(item: MenuItem): void { |
| 33 | onClose(); |
| 34 | item.action(); |
| 35 | } |
| 36 | |
| 37 | function outside(ev: PointerEvent): void { |
| 38 | if (!menu.contains(ev.target as Node)) onClose(); |
| 39 | } |
| 40 | |
| 41 | function key(ev: KeyboardEvent): void { |
| 42 | if (ev.key === 'Escape') { |
| 43 | ev.stopPropagation(); |
| 44 | onClose(); |
| 45 | } |
| 46 | } |
| 47 | </script> |
| 48 | |
| 49 | <svelte:window on:pointerdown|capture={outside} on:keydown|capture={key} on:resize={onClose} on:blur={onClose} /> |
| 50 | |
| 51 | <div class="context-menu" role="menu" style={`left:${left}px;top:${top}px`} bind:this={menu}> |
| 52 | {#each items as item} |
| 53 | <button type="button" role="menuitem" class:danger={item.danger} disabled={item.disabled} on:click={() => pick(item)}> |
| 54 | {item.label} |
| 55 | </button> |
| 56 | {/each} |
| 57 | </div> |
| 58 | |
| 59 | <style> |
| 60 | .context-menu { |
| 61 | position: fixed; |
| 62 | z-index: 90; |
| 63 | min-width: 180px; |
| 64 | display: flex; |
| 65 | flex-direction: column; |
| 66 | padding: 4px; |
| 67 | background: var(--panel-2); |
| 68 | border: 1px solid var(--border); |
| 69 | border-radius: 8px; |
| 70 | box-shadow: 0 8px 24px rgb(0 0 0 / 0.35); |
| 71 | } |
| 72 | button { |
| 73 | background: transparent; |
| 74 | border: none; |
| 75 | border-radius: 6px; |
| 76 | color: var(--text); |
| 77 | font: inherit; |
| 78 | font-size: 13.5px; |
| 79 | text-align: left; |
| 80 | padding: 7px 10px; |
| 81 | cursor: pointer; |
| 82 | } |
| 83 | button:hover:not(:disabled), button:focus-visible { background: var(--hover); outline: none; } |
| 84 | button:disabled { opacity: 0.5; cursor: default; } |
| 85 | .danger { color: var(--danger); } |
| 86 | </style> |