I asked claude to scaffold a project. I also made changes to it afterwards but they were mostly in getting workflows and testing stuff.
3.8 KiB
3.8 KiB
Context Menu — Design Spec
Date: 2026-04-15 Status: Approved
Overview
Custom right-click context menu for the Oxyde chat app. Replaces the browser default. Context-aware: menu items differ based on the element right-clicked. Copy-only for now, with a "Copied!" confirmation. Built with Approach A — shared component, state lifted to +page.svelte.
1. New Type
Add to src/lib/types.ts:
export interface ContextMenuItem { label: string; action: () => void }
2. New Component
File: src/lib/components/ContextMenu.svelte
Props
{ x: number; y: number; items: ContextMenuItem[]; onclose: () => void }
Positioning
position: fixedat(x, y)fromMouseEvent.clientX/Y- On mount: check if menu overflows viewport right or bottom edge; if so, flip left/upward
- Immune to scroll
Dismiss
- Global
onclickonsvelte:windowcloses menu (menu container stops propagation) - Global
onkeydowncloses onEscape - Global
oncontextmenuonsvelte:windowcloses and prevents default (stops stale menu persisting on second right-click) - Selecting an item closes after 1200ms (post-confirmation)
Copy & Confirmation
- Copy via
navigator.clipboard.writeText() - On click: item label changes to
"Copied!", color shifts tovar(--accent)withvar(--accent-soft)background - After 1200ms: menu closes
- Uses per-item
copiedIndexstate (index of last-copied item)
3. State in +page.svelte
let contextMenu = $state<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
function showMenu(e: MouseEvent, items: ContextMenuItem[]) {
e.preventDefault();
contextMenu = { x: e.clientX, y: e.clientY, items };
}
ContextMenu renders at the bottom of the {:else} (app) block, gated on contextMenu !== null:
{#if contextMenu}
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={contextMenu.items}
onclose={() => contextMenu = null}
/>
{/if}
showMenu is passed as a prop to both Sidebar and ChatMain.
4. Trigger Targets
| Component | Element | Right-click handler | Menu item | Copies |
|---|---|---|---|---|
Sidebar |
.room-item button |
oncontextmenu |
"Copy room name" | room.name |
ChatMain |
.msg-author span |
oncontextmenu + stopPropagation |
"Copy username" | msg.author_username ?? sid(msg.author) |
ChatMain |
.msg div |
oncontextmenu |
"Copy message" | msg.body |
Author stopPropagation prevents the parent .msg handler from also firing.
Prop additions
Sidebar:onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => voidChatMain:onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void
5. Visual Style
| Property | Value |
|---|---|
| Background | var(--surface) |
| Border | 1px solid var(--border) |
| Border radius | var(--r) (2px) |
| Box shadow | 0 4px 16px rgba(0,0,0,0.4) |
| Min width | 160px |
| List padding | 4px |
| Item padding | 7px 12px |
| Font | inherit (Martian Mono), 11px |
| Item color | var(--text-2) |
| Item hover | bg var(--surface-2), color var(--text), left border 2px solid var(--accent) |
| Copied state | color var(--accent), bg var(--accent-soft) |
| Entrance animation | Reuse existing rise keyframe (opacity + translateY, 0.15s) |
6. Files Changed
| File | Change |
|---|---|
src/lib/types.ts |
Add ContextMenuItem interface |
src/lib/components/ContextMenu.svelte |
New component |
src/routes/+page.svelte |
Add state, showMenu helper, render ContextMenu, pass prop to children |
src/lib/components/Sidebar.svelte |
Add onShowMenu prop, wire oncontextmenu on room items |
src/lib/components/ChatMain.svelte |
Add onShowMenu prop, wire oncontextmenu on .msg and .msg-author |