CollaborationContext menu
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises, from Astro sites to React apps and AI products.
Book a callContext menu
Right-click an agent reply for its actions, with a submenu that asks the agent about it. Free and MIT licensed, as HTML, a typed React component or an Astro component, in two styles with light and dark mode.
Most of the checkout drop-off is on mobile. 61% of the people who leave do it at the address step, where the keyboard covers the Continue button. Moving Continue above the keyboard should win back about 300 orders a week.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add context-menuReact
npx designme add context-menu --reactAstro
npx designme add context-menu --astroThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro islands. The Astro version lands in src/components/designme too, with the same markup, CSS and script as the HTML version. See Astro.
shadcn CLI, React
npx shadcn@latest add https://designme.dev/r/shadcn/context-menu.json shadcn CLI, Astro
npx shadcn@latest add https://designme.dev/r/shadcn/astro/context-menu.json In a project set up for shadcn/ui, its CLI adds the same files to your components folder, under designme/, with the shared core. To give the components your shadcn or Tailwind v4 theme, add the theme bridge.
About
What it is for and what it does.
A menu that opens where you right-click an agent reply, or where you long-press it on a touch screen, or at its corner with Shift+F10 or the Menu key. It holds the usual actions with their shortcuts, an Ask the agent item with a submenu (Explain, Summarise, Rewrite shorter), a checkable item and a delete that asks again before it runs. In the demo the agent's summary streams in under the reply.
- On messages, files or rows that have several actions you don't want to show as buttons all the time.
- When some of those actions hand the item to the agent, grouped in a submenu.
- When a destructive action needs a second press rather than a separate dialog.
- Opens at the pointer for a right-click or a long press (half a second), and at the target's top left for Shift+F10 or the Menu key.
- Turns back from the right edge so it stays inside the card, and near the foot of the screen opens upward where it fits over the reply.
- The submenu opens when the pointer rests on its item, with Right, or with Enter; it sits beside the menu on the right, or on the left when there's no room, and drops under its item on a phone.
- Items take an icon, a shortcut hint, a hairline above them (
separator),checkedfor a checkable item, anddangerfor red. confirmLabelmakes an item ask again: the first press changes its label ("Press again to delete"), the second runs it.- A press anywhere outside closes it; choosing an item closes it and calls
onSelectwith the item's key (a checkable item keeps it open).
- The menus have role="menu"; items are menuitem or menuitemcheckbox with aria-checked, and the submenu's item has aria-haspopup and aria-expanded.
- Up and Down move through the items and wrap, Home and End go to the ends, and a letter jumps to the next item starting with it.
- Right opens the submenu with the focus on its first item; Left or Escape closes it and puts the focus back on its item.
- Escape closes the menu and gives the focus back to the target; Tab closes it.
- The target is focusable and its label says to press Shift+F10 for actions. Opened from the keyboard or by a real right-click, the menu takes the focus; a demo leaves it where it is.
- A closed menu is inert, so Tab and screen readers pass over it.
Usage
Import it in React and drive it from your own state, or place it in an Astro page.
React
import { ContextMenu } from '@/components/designme/context-menu/ContextMenu';React example
The demo above as a React component, 69 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: right-click the reply, ask the agent for a
// summary, and it streams in under the reply.
import { useRef, useState } from 'react';
import { ContextMenu, type ContextMenuItem } from './ContextMenu';
import { Icon } from '../lib/icons';
import { useStory, useWait } from '../lib/useScript';
const TEXT = 'Most of the checkout drop-off is on mobile. 61% of the people who leave do it at the address step, where the keyboard covers the Continue button. Moving Continue above the keyboard should win back about 300 orders a week.';
const ASK: Record<string, [string, 'info' | 'list' | 'edit', string]> = {
explain: ['Explanation', 'info', 'On a phone the keyboard takes the lower half of the screen. The Continue button sits there, so people have to close the keyboard to find it, and many leave instead.'],
sum: ['Summary', 'list', 'Mobile shoppers drop at the address step because the keyboard hides Continue. Moving it up could win back about 300 orders a week.'],
rewrite: ['Shorter', 'edit', 'Mobile checkout loses people at the address step: the keyboard hides Continue. Move it up to win back about 300 orders a week.']
};
export default function ContextMenuDemo() {
const ref = useRef<HTMLDivElement>(null);
const wait = useWait();
const [follow, setFollow] = useState(false);
const [pinned, setPinned] = useState(false);
const [deleted, setDeleted] = useState(false);
const [note, setNote] = useState('');
const [out, setOut] = useState<{ k: string; text: string; busy: boolean } | null>(null);
const run = useRef(0);
const items: ContextMenuItem[] = [
{ key: 'copy', label: 'Copy text', icon: 'copy', shortcut: '⌘C' },
{ key: 'quote', label: 'Quote in reply', icon: 'quote', shortcut: 'R' },
{ key: 'pin', label: pinned ? 'Unpin' : 'Pin to thread', icon: 'pin', shortcut: 'P' },
{ key: 'ask', label: 'Ask the agent', icon: 'spark', separator: true, items: [
{ key: 'explain', label: 'Explain', icon: 'info' }, { key: 'sum', label: 'Summarise', icon: 'list' }, { key: 'rewrite', label: 'Rewrite shorter', icon: 'edit' }] },
{ key: 'follow', label: 'Follow replies', icon: 'bell', checked: follow },
{ key: 'del', label: 'Delete reply', icon: 'trash', shortcut: '⌫', danger: true, confirmLabel: 'Press again to delete', separator: true }
];
const ask = async (k: string) => {
const id = ++run.current, text = ASK[k][2].split(' ');
setOut({ k, text: '', busy: true }); await wait(900);
for (let i = 1; i <= text.length && id === run.current; i++) { setOut({ k, text: text.slice(0, i).join(' '), busy: false }); await wait(55); }
};
const select = (k: string) => {
if (k === 'copy') { navigator.clipboard?.writeText(TEXT).catch(() => {}); setNote('Copied to clipboard'); }
if (k === 'quote') setNote('Quoted in your reply');
if (k === 'pin') { setPinned(p => !p); setNote(pinned ? 'Unpinned' : 'Pinned to the thread'); }
if (k === 'follow') { setFollow(f => !f); setNote(follow ? 'You will not hear about replies' : 'You will hear about replies to this'); }
if (k === 'del') { setDeleted(true); setNote(''); }
if (ASK[k]) void ask(k);
};
// the preview's story: right-click the reply, open Ask the agent, pick Summarise
useStory(ref, async ({ wait, press }) => {
await wait(1500);
const p = ref.current?.querySelector('.dm-cxm-tx'); if (!p) return;
const b = p.getBoundingClientRect();
p.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: b.left + b.width * .62, clientY: b.top + b.height * .58 }));
await wait(1000); press('.dm-cxm-sub');
await wait(1000); press('.dm-cxm-pop2 [data-k="sum"]');
});
return (
<div ref={ref} style={{ width: '100%' }}>
{deleted
? <div className="dm-cxm" data-dm=""><div className="dm-cxm-gone">Reply deleted<button type="button" className="dm-pill dm-ghost dm-sm" onClick={() => setDeleted(false)}>Undo</button></div></div>
: <ContextMenu items={items} onSelect={select} label="Reply actions" targetLabel="Agent reply. Press Shift+F10 for actions">
<div className="dm-cxm-hd"><span className="dm-cxm-av"><Icon name="spark" /></span><b>Agent</b><span className="dm-muted dm-num">2:14 pm</span>{pinned && <span className="dm-cxm-pin"><Icon name="pin" />Pinned</span>}</div>
<p className="dm-cxm-tx">{TEXT}</p>
<div className="dm-cxm-out" aria-live="polite">{out && <div className="dm-cxm-res dm-enter"><span className="dm-k"><Icon name={ASK[out.k][1]} size="xs" /><span className={out.busy ? 'dm-shim' : undefined}>{out.busy ? `Writing a ${ASK[out.k][0].toLowerCase()}` : ASK[out.k][0]}</span></span><span>{out.text}</span></div>}</div>
</ContextMenu>}
<p className="dm-cxm-note dm-muted" data-dm="" aria-live="polite">{note}</p>
</div>
);
}
Astro
In a page in src/pages. It plays the demo above; change its context-menu.js to show your own data. Attributes like class go on its root element.
---
import ContextMenu from '../components/designme/context-menu/ContextMenu.astro';
---
<ContextMenu />
<ContextMenu data-style="soft" class="my-class" />Props
Everything ContextMenu takes.
| Prop | Type | What it does |
|---|---|---|
itemsrequired | ContextMenuItem[] | |
onSelectrequired | (key: string, item: ContextMenuItem) => void | Called with the item chosen (in a submenu too) |
label | string | The menu's name for screen readers, like "Reply actions" |
targetLabel | string | The target's name for screen readers. Defaults to "Press Shift+F10 for actions" |
targetClassName | string | Classes for the target. Defaults to an agent reply's panel |
childrenrequired | ReactNode | What you right-click: an agent reply, a row, a file |
className | string |
Code
The full source, as the CLI installs it.
ContextMenu.tsxReact · 138 lines
'use client';
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useIsoLayoutEffect } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import '../designme.css';
import './context-menu.css';
export interface ContextMenuItem {
key: string;
label: string;
icon?: IconName;
/** A hint at the right, like "⌘C" */
shortcut?: string;
/** Makes it a checkable item (menuitemcheckbox) */
checked?: boolean;
/** Red, for something that deletes */
danger?: boolean;
/** Asks again before it runs: the first press changes the item to this label, the second runs it */
confirmLabel?: string;
/** A submenu, opened with the pointer resting on the item, Right or Enter */
items?: ContextMenuItem[];
/** A hairline above this item */
separator?: boolean;
}
export interface ContextMenuProps {
items: ContextMenuItem[];
/** Called with the item chosen (in a submenu too) */
onSelect: (key: string, item: ContextMenuItem) => void;
/** The menu's name for screen readers, like "Reply actions" */
label?: string;
/** The target's name for screen readers. Defaults to "Press Shift+F10 for actions" */
targetLabel?: string;
/** Classes for the target. Defaults to an agent reply's panel */
targetClassName?: string;
/** What you right-click: an agent reply, a row, a file */
children: ReactNode;
className?: string;
}
type At = { x: number; y: number; keyed: boolean };
/** A menu that opens where you right-click, long-press, or press Shift+F10 or the Menu key, with a submenu, a checkable item and a delete that asks again. */
export function ContextMenu({ items, onSelect, label = 'Actions', targetLabel = 'Press Shift+F10 for actions', targetClassName = 'dm-cxm-msg dm-panel', children, className }: ContextMenuProps) {
const root = useRef<HTMLDivElement>(null), target = useRef<HTMLDivElement>(null), menu = useRef<HTMLDivElement>(null), subMenu = useRef<HTMLDivElement>(null);
const [at, setAt] = useState<At | null>(null);
const [sub, setSub] = useState<{ key: string; focus: boolean } | null>(null);
const [asking, setAsking] = useState<string | null>(null);
const openedAt = useRef(0), press = useRef<ReturnType<typeof setTimeout>>(undefined), hov = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => { clearTimeout(press.current); clearTimeout(hov.current); }, []);
const open = !!at;
const itemsOf = (m: HTMLElement | null) => m ? [...m.querySelectorAll<HTMLElement>('.dm-mi')] : [];
const bounds = () => { const card = (root.current!.closest('[data-c],.dm-card') || document.body).getBoundingClientRect(); return { lo: Math.max(card.left, 0) + 8, hi: Math.min(card.right, innerWidth) - 8 }; };
// where a menu goes: at the point, turned back from the right and bottom edges so it stays on screen
const fit = (m: HTMLElement, x: number, y: number) => {
const rb = root.current!.getBoundingClientRect(), { lo, hi } = bounds(), w = m.offsetWidth, h = m.offsetHeight;
if (rb.left + x + w > hi) x -= w;
x = Math.max(lo - rb.left, Math.min(x, hi - rb.left - w));
// near the foot of the screen it opens upward, but only where it fits over the target, so it never covers what is above
const up = rb.top + y + h > innerHeight - 8 && y - h >= 0 && rb.top + y - h > 8; if (up) y -= h;
m.style.left = x + 'px'; m.style.top = y + 'px'; m.style.setProperty('--dm-origin', (up ? 'bottom ' : 'top ') + 'left');
};
useIsoLayoutEffect(() => { if (at && menu.current) { fit(menu.current, at.x, at.y); if (at.keyed) itemsOf(menu.current)[0]?.focus({ preventScroll: true }); } }, [at]);
// the submenu opens beside its item, on the right or else the left; where neither side has room it drops under the item
useIsoLayoutEffect(() => {
const m = subMenu.current, p = menu.current; if (!sub || !m || !p) return;
const row = p.querySelector<HTMLElement>(`[data-k="${sub.key}"]`)!, pr = p.getBoundingClientRect(), b = row.getBoundingClientRect(), rb = root.current!.getBoundingClientRect(), { lo, hi } = bounds(), w = m.offsetWidth;
if (pr.right - 4 + w <= hi) fit(m, pr.right - rb.left - 4, b.top - rb.top - 6);
else if (pr.left + 4 - w >= lo) fit(m, pr.left - rb.left + 4 - w, b.top - rb.top - 6);
else fit(m, b.left - rb.left + 24, b.bottom - rb.top + 2);
if (sub.focus) itemsOf(m)[0]?.focus({ preventScroll: true });
}, [sub]);
const openAt = (x: number, y: number, keyed: boolean) => { setSub(null); setAsking(null); openedAt.current = performance.now(); setAt({ x, y, keyed }); };
const close = (back = false) => {
const inside = !!(menu.current?.contains(document.activeElement) || subMenu.current?.contains(document.activeElement));
setAt(null); setSub(null); setAsking(null); if (back && inside) target.current?.focus({ preventScroll: true });
};
// a press anywhere else closes it
useEffect(() => {
if (!open) return;
const down = (e: PointerEvent) => { const t = e.target as Node; if (!menu.current?.contains(t) && !subMenu.current?.contains(t)) close(); };
document.addEventListener('pointerdown', down, true);
return () => document.removeEventListener('pointerdown', down, true);
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const local = (cx0: number, cy0: number) => { const rb = root.current!.getBoundingClientRect(); return [cx0 - rb.left, cy0 - rb.top] as const; };
// a submenu opened with Enter or Space takes the focus; one opened with a click (or by a demo) leaves it
const choose = (it: ContextMenuItem, inSub: boolean, keyed: boolean) => {
if (it.items) { setSub(s => s ? null : { key: it.key, focus: keyed }); return; }
if (it.confirmLabel && asking !== it.key) { setAsking(it.key); return; }
if (it.checked === undefined || inSub) close(true);
onSelect(it.key, it);
};
const keys = (list: ContextMenuItem[], inSub: boolean) => (e: React.KeyboardEvent<HTMLDivElement>) => {
const its = itemsOf(e.currentTarget), i = its.indexOf(document.activeElement as HTMLElement), k = e.key;
let to: HTMLElement | undefined;
if (k === 'ArrowDown') to = its[(i + 1) % its.length]; else if (k === 'ArrowUp') to = its[(i - 1 + its.length) % its.length];
else if (k === 'Home') to = its[0]; else if (k === 'End') to = its[its.length - 1];
else if (k === 'ArrowRight' && !inSub && list[i]?.items) { e.preventDefault(); setSub({ key: list[i].key, focus: true }); return; }
else if ((k === 'ArrowLeft' || k === 'Escape') && inSub) { e.preventDefault(); e.stopPropagation(); const key = sub?.key; setSub(null); menu.current?.querySelector<HTMLElement>(`[data-k="${key}"]`)?.focus({ preventScroll: true }); return; }
else if (k === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); return; }
else if (k === 'Tab') { close(); return; }
else if (k.length === 1 && /\S/.test(k)) { const n = its.length, from = Math.max(i, 0); for (let j = 1; j <= n; j++) { const it = its[(from + j) % n]; if ((it.textContent || '').trim().toLowerCase().startsWith(k.toLowerCase())) { to = it; break; } } }
if (to) { e.preventDefault(); to.focus({ preventScroll: true }); if (!inSub && !list[its.indexOf(to)]?.items) setSub(null); }
};
const row = (it: ContextMenuItem, inSub: boolean) => {
const ask = asking === it.key;
return (
<Fragment key={it.key}>
{it.separator && <div className="dm-sep" role="separator" />}
<button type="button" data-k={it.key} role={it.checked !== undefined ? 'menuitemcheckbox' : 'menuitem'} aria-checked={it.checked} aria-haspopup={it.items ? 'menu' : undefined}
aria-expanded={it.items ? sub?.key === it.key : undefined} aria-label={ask ? `${it.label}: press again to confirm` : undefined}
className={cx('dm-mi', it.items && 'dm-cxm-sub', it.danger && 'dm-cxm-del', ask && 'dm-ask')} onClick={e => choose(it, inSub, e.isTrusted && e.detail === 0)}
onPointerEnter={() => { if (inSub) return; clearTimeout(hov.current); hov.current = setTimeout(() => setSub(it.items ? { key: it.key, focus: false } : null), it.items ? 160 : 200); }}>
{it.icon && <Icon name={it.icon} />}{ask ? it.confirmLabel : it.label}
{it.items ? <span className="dm-r"><Icon name="chev-r" size="xs" /></span> : it.checked !== undefined ? <span className="dm-ck"><Icon name="check" size="xs" /></span> : it.shortcut && <span className="dm-r">{it.shortcut}</span>}
</button>
</Fragment>
);
};
const subItems = sub ? items.find(i => i.key === sub.key)?.items : undefined;
return (
<div ref={root} data-dm="" className={cx('dm-cxm', className)}>
<div ref={target} className={cx(targetClassName, open && 'dm-on')} tabIndex={0} aria-haspopup="menu" aria-label={targetLabel}
onContextMenu={e => { e.preventDefault(); if (open && performance.now() - openedAt.current < 600) return; const [x, y] = local(e.clientX, e.clientY); openAt(x, y, e.isTrusted); }}
onPointerDown={e => { if (e.pointerType !== 'touch') return; const [x, y] = local(e.clientX, e.clientY); clearTimeout(press.current); press.current = setTimeout(() => openAt(x, y, true), 500); }}
onPointerUp={() => clearTimeout(press.current)} onPointerCancel={() => clearTimeout(press.current)}
onKeyDown={e => { if ((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu') { e.preventDefault(); const b = e.currentTarget.getBoundingClientRect(), [x, y] = local(b.left + 16, b.top + 36); openAt(x, y, true); } }}>
{children}
</div>
<div ref={menu} className={cx('dm-pop dm-cxm-pop', open && 'dm-open')} role="menu" aria-label={label} inert={!open} onKeyDown={keys(items, false)}>{items.map(it => row(it, false))}</div>
<div ref={subMenu} className={cx('dm-pop dm-cxm-pop2', open && subItems && 'dm-open')} role="menu" aria-label={items.find(i => i.key === sub?.key)?.label} inert={!(open && subItems)} onKeyDown={keys(subItems || [], true)}>
{(subItems || []).map(it => row(it, true))}
</div>
</div>
);
}
context-menu.cssReact · 29 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Context menu ---------- */
/* an agent reply with a menu of its own: right-click, long-press, Shift+F10 or the Menu key opens it where you are */
.dm-cxm{position:relative;width:100%;display:flex;flex-direction:column;gap:var(--dm-s-2)}
.dm-cxm-msg{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-2);font-size:13.5px;line-height:1.55;-webkit-touch-callout:none;transition:box-shadow var(--dm-t-fast)}
.dm-cxm-msg:focus-visible{outline-offset:3px;border-radius:var(--dm-r-card)}
.dm-cxm-msg.dm-on{box-shadow:var(--dm-shadow),0 0 0 2px color-mix(in srgb,var(--dm-line) 70%,transparent)}
.dm-cxm-hd{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13px}
.dm-cxm-hd b{font-weight:500}
.dm-cxm-hd .dm-muted{font-size:12px}
.dm-cxm-av{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
.dm-cxm-pin{display:inline-flex;align-items:center;gap:4px;margin-left:auto;font-size:12px;color:var(--dm-muted)}
.dm-cxm-tx{margin:0;color:var(--dm-fg)}
.dm-cxm-tx.dm-out{opacity:.4}
.dm-cxm-out:empty{display:none}
.dm-cxm-res{display:flex;flex-direction:column;gap:var(--dm-s-1);margin-top:var(--dm-s-1);padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-row);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:13px;color:var(--dm-fg2)}
.dm-cxm-res .dm-k{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dm-muted)}
.dm-cxm-res .dm-k .dm-i{color:var(--dm-accent-fg,var(--dm-accent))}
.dm-cxm-res .dm-w{animation:dm-tkword .42s var(--dm-ease) backwards}
.dm-cxm-note{margin:0;min-height:20px;font-size:12.5px;text-align:center}
.dm-cxm-gone{display:flex;align-items:center;justify-content:center;gap:var(--dm-s-2);padding:var(--dm-s-3);font-size:13px;color:var(--dm-muted)}
.dm-cxm-pop,.dm-cxm-pop2{top:0;left:0;min-width:220px}
.dm-cxm-pop2{min-width:180px}
.dm-cxm-pop .dm-mi .dm-r .dm-i{color:var(--dm-muted)}
.dm-cxm-sub[aria-expanded="true"]{background:var(--dm-row)}
.dm-cxm-del,.dm-cxm-del .dm-i{color:var(--dm-bad)}
.dm-cxm-del.dm-ask{background:var(--dm-bad-bg)}
:root:not([data-style="soft"]) .dm-cxm-msg.dm-on:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-fg2) 50%,transparent)}
ContextMenu.astroAstro · 46 lines
---
// Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync.
// Context menu: Right-click an agent reply for its actions, with a submenu that asks the agent about it.
// The markup, CSS and script are designme.dev's own, run by the shared core in ../core, which mounts the component when it scrolls
// into view and plays its demo. To show your own data, change context-menu.js. The markup is kept exactly as it is (is:raw).
import type { HTMLAttributes } from 'astro/types';
import '../core/designme.css';
import './context-menu.css';
export interface Props extends HTMLAttributes<'div'> {
/** "soft" draws it in the Soft style. Leave it out for Blueprint, or for the style set on <html> (data-style="soft") */
'data-style'?: 'soft';
}
const { class: className, ...rest } = Astro.props;
---
<div class:list={['card', className]} data-c="context-menu" {...rest}>
<div class="stage-in"><Fragment is:raw>
<div class="cxm">
<div class="cxm-msg panel" tabindex="0" aria-haspopup="menu" aria-label="Agent reply. Press Shift+F10 for actions">
<div class="cxm-hd"><span class="cxm-av"><svg class="i s"><use href="#i-spark"/></svg></span><b>Agent</b><span class="muted num">2:14 pm</span><span class="cxm-pin" hidden><svg class="i s"><use href="#i-pin"/></svg>Pinned</span></div>
<p class="cxm-tx">Most of the checkout drop-off is on mobile. 61% of the people who leave do it at the address step, where the keyboard covers the Continue button. Moving Continue above the keyboard should win back about 300 orders a week.</p>
<div class="cxm-out" aria-live="polite"></div>
</div>
<p class="cxm-note muted" aria-live="polite"></p>
<div class="pop cxm-pop" role="menu" aria-label="Reply actions">
<button class="mi" role="menuitem" data-a="copy"><svg class="i s"><use href="#i-copy"/></svg>Copy text<span class="r">⌘C</span></button>
<button class="mi" role="menuitem" data-a="quote"><svg class="i s"><use href="#i-quote"/></svg>Quote in reply<span class="r">R</span></button>
<button class="mi" role="menuitem" data-a="pin"><svg class="i s"><use href="#i-pin"/></svg><span class="t">Pin to thread</span><span class="r">P</span></button>
<div class="sep" role="separator"></div>
<button class="mi cxm-sub" role="menuitem" aria-haspopup="menu" aria-expanded="false" data-a="ask"><svg class="i s"><use href="#i-spark"/></svg>Ask the agent<span class="r"><svg class="i xs"><use href="#i-chev-r"/></svg></span></button>
<button class="mi" role="menuitemcheckbox" aria-checked="false" data-a="follow"><svg class="i s"><use href="#i-bell"/></svg>Follow replies<span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
<div class="sep" role="separator"></div>
<button class="mi cxm-del" role="menuitem" data-a="del"><svg class="i s"><use href="#i-trash"/></svg><span class="t">Delete reply</span><span class="r">⌫</span></button>
</div>
<div class="pop cxm-pop2" role="menu" aria-label="Ask the agent">
<button class="mi" role="menuitem" data-a="explain"><svg class="i s"><use href="#i-info"/></svg>Explain</button>
<button class="mi" role="menuitem" data-a="sum"><svg class="i s"><use href="#i-list"/></svg>Summarise</button>
<button class="mi" role="menuitem" data-a="rewrite"><svg class="i s"><use href="#i-edit"/></svg>Rewrite shorter</button>
</div>
</div>
</Fragment></div>
</div>
<script>
import './context-menu.js';
</script>
context-menu.htmlHTML · 28 lines
<div class="card" data-c="context-menu">
<div class="stage-in">
<div class="cxm">
<div class="cxm-msg panel" tabindex="0" aria-haspopup="menu" aria-label="Agent reply. Press Shift+F10 for actions">
<div class="cxm-hd"><span class="cxm-av"><svg class="i s"><use href="#i-spark"/></svg></span><b>Agent</b><span class="muted num">2:14 pm</span><span class="cxm-pin" hidden><svg class="i s"><use href="#i-pin"/></svg>Pinned</span></div>
<p class="cxm-tx">Most of the checkout drop-off is on mobile. 61% of the people who leave do it at the address step, where the keyboard covers the Continue button. Moving Continue above the keyboard should win back about 300 orders a week.</p>
<div class="cxm-out" aria-live="polite"></div>
</div>
<p class="cxm-note muted" aria-live="polite"></p>
<div class="pop cxm-pop" role="menu" aria-label="Reply actions">
<button class="mi" role="menuitem" data-a="copy"><svg class="i s"><use href="#i-copy"/></svg>Copy text<span class="r">⌘C</span></button>
<button class="mi" role="menuitem" data-a="quote"><svg class="i s"><use href="#i-quote"/></svg>Quote in reply<span class="r">R</span></button>
<button class="mi" role="menuitem" data-a="pin"><svg class="i s"><use href="#i-pin"/></svg><span class="t">Pin to thread</span><span class="r">P</span></button>
<div class="sep" role="separator"></div>
<button class="mi cxm-sub" role="menuitem" aria-haspopup="menu" aria-expanded="false" data-a="ask"><svg class="i s"><use href="#i-spark"/></svg>Ask the agent<span class="r"><svg class="i xs"><use href="#i-chev-r"/></svg></span></button>
<button class="mi" role="menuitemcheckbox" aria-checked="false" data-a="follow"><svg class="i s"><use href="#i-bell"/></svg>Follow replies<span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
<div class="sep" role="separator"></div>
<button class="mi cxm-del" role="menuitem" data-a="del"><svg class="i s"><use href="#i-trash"/></svg><span class="t">Delete reply</span><span class="r">⌫</span></button>
</div>
<div class="pop cxm-pop2" role="menu" aria-label="Ask the agent">
<button class="mi" role="menuitem" data-a="explain"><svg class="i s"><use href="#i-info"/></svg>Explain</button>
<button class="mi" role="menuitem" data-a="sum"><svg class="i s"><use href="#i-list"/></svg>Summarise</button>
<button class="mi" role="menuitem" data-a="rewrite"><svg class="i s"><use href="#i-edit"/></svg>Rewrite shorter</button>
</div>
</div>
</div>
</div>
context-menu.cssHTML · 28 lines
/* ---------- Context menu ---------- */
/* an agent reply with a menu of its own: right-click, long-press, Shift+F10 or the Menu key opens it where you are */
.cxm{position:relative;width:100%;display:flex;flex-direction:column;gap:var(--dm-s-2)}
.cxm-msg{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-2);font-size:13.5px;line-height:1.55;-webkit-touch-callout:none;transition:box-shadow var(--dm-t-fast)}
.cxm-msg:focus-visible{outline-offset:3px;border-radius:var(--dm-r-card)}
.cxm-msg.on{box-shadow:var(--dm-shadow),0 0 0 2px color-mix(in srgb,var(--dm-line) 70%,transparent)}
.cxm-hd{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13px}
.cxm-hd b{font-weight:500}
.cxm-hd .muted{font-size:12px}
.cxm-av{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
.cxm-pin{display:inline-flex;align-items:center;gap:4px;margin-left:auto;font-size:12px;color:var(--dm-muted)}
.cxm-tx{margin:0;color:var(--dm-fg)}
.cxm-tx.out{opacity:.4}
.cxm-out:empty{display:none}
.cxm-res{display:flex;flex-direction:column;gap:var(--dm-s-1);margin-top:var(--dm-s-1);padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-row);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:13px;color:var(--dm-fg2)}
.cxm-res .k{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dm-muted)}
.cxm-res .k .i{color:var(--dm-accent-fg,var(--dm-accent))}
.cxm-res .w{animation:tkword .42s var(--dm-ease) backwards}
.cxm-note{margin:0;min-height:20px;font-size:12.5px;text-align:center}
.cxm-gone{display:flex;align-items:center;justify-content:center;gap:var(--dm-s-2);padding:var(--dm-s-3);font-size:13px;color:var(--dm-muted)}
.cxm-pop,.cxm-pop2{top:0;left:0;min-width:220px}
.cxm-pop2{min-width:180px}
.cxm-pop .mi .r .i{color:var(--dm-muted)}
.cxm-sub[aria-expanded="true"]{background:var(--dm-row)}
.cxm-del,.cxm-del .i{color:var(--dm-bad)}
.cxm-del.ask{background:var(--dm-bad-bg)}
:root:not([data-style="soft"]) .cxm-msg.on:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-fg2) 50%,transparent)}
context-menu.jsHTML · 114 lines
DM.def('context-menu', (r, c) => {
const ICON = { spark:'spark', copy:'copy', quote:'quote', pin:'pin', 'chev-r':'chev-r', bell:'bell', check:'check', trash:'trash', info:'info', list:'list', edit:'edit' };
r.innerHTML = r.innerHTML.replace(/\{([\w-]+)\}/g, (m, k) => ICON[k] ? I(ICON[k], k === 'chev-r' || k === 'check' ? 'xs' : 's') : m);
const root = $('.cxm', r), msg = $('.cxm-msg', r), tx = $('.cxm-tx', r), out = $('.cxm-out', r), note = $('.cxm-note', r);
const pop = $('.cxm-pop', r), sub = $('.cxm-pop2', r), subRow = $('.cxm-sub', r), del = $('.cxm-del', r);
const items = m => $$('.mi', m);
let open = false, subOpen = false, opener = null, openedAt = 0;
// where a menu goes: at the point, turned back from the right and bottom edges so it stays on screen and inside the card
const fit = (m, x, y, { left } = {}) => {
const rb = root.getBoundingClientRect(), card = (root.closest('.card,.comp,[data-c]') || document.body).getBoundingClientRect();
const lo = Math.max(card.left, 0) + 8 - rb.left, hi = Math.min(card.right, innerWidth) - 8 - rb.left, w = m.offsetWidth, h = m.offsetHeight;
if (x + w > hi && left != null) x = left - w; else if (x + w > hi) x = x - w;
x = Math.max(lo, Math.min(x, hi - w));
// near the foot of the screen it opens upward, but only where it fits over the component, so it never covers what is above
const up = rb.top + y + h > innerHeight - 8 && y - h >= 0 && rb.top + y - h > 8; if (up) y -= h;
m.style.left = x + 'px'; m.style.top = y + 'px'; m.style.setProperty('--dm-origin', (up ? 'bottom ' : 'top ') + 'left');
};
const show = (m, on) => { m.classList.toggle('open', on); m.inert = !on; };
function openAt(x, y, keyed){
closeSub(); del.classList.remove('ask'); $('.t', del).textContent = 'Delete reply';
open = true; openedAt = performance.now(); opener = document.activeElement; msg.classList.add('on'); show(pop, true); fit(pop, x, y);
// opened by a person, the menu takes the focus on its first item; the demo leaves the focus where it is
if (keyed) items(pop)[0].focus({ preventScroll:true });
}
function close(back){ if (!open) return; back = back && (pop.contains(document.activeElement) || sub.contains(document.activeElement)); open = false; closeSub(); show(pop, false); msg.classList.remove('on'); if (back) msg.focus({ preventScroll:true }); }
// the submenu opens beside its row, on the right or else the left; where neither side has room (a phone) it drops under the row
function openSub(focus){
subOpen = true; subRow.setAttribute('aria-expanded', 'true'); show(sub, true);
const p = pop.getBoundingClientRect(), b = subRow.getBoundingClientRect(), rb = root.getBoundingClientRect(), card = (root.closest('.card,.comp,[data-c]') || document.body).getBoundingClientRect(), w = sub.offsetWidth;
const lo = Math.max(card.left, 0) + 8, hi = Math.min(card.right, innerWidth) - 8;
if (p.right - 4 + w <= hi) fit(sub, p.right - rb.left - 4, b.top - rb.top - 6);
else if (p.left + 4 - w >= lo) fit(sub, p.left - rb.left + 4 - w, b.top - rb.top - 6);
else fit(sub, b.left - rb.left + 24, b.bottom - rb.top + 2);
if (focus) items(sub)[0].focus({ preventScroll:true });
}
function closeSub(focus){ if (!subOpen) return; subOpen = false; subRow.setAttribute('aria-expanded', 'false'); show(sub, false); if (focus) subRow.focus({ preventScroll:true }); }
show(pop, false); show(sub, false);
// right-click, or a long press on a touch screen
c.on(msg, 'contextmenu', e => { e.preventDefault(); if (open && performance.now() - openedAt < 600) return; const rb = root.getBoundingClientRect(); openAt(e.clientX - rb.left, e.clientY - rb.top, e.isTrusted && !e.button && e.detail === 0 ? false : e.isTrusted); });
let press = 0;
c.on(msg, 'pointerdown', e => { if (e.pointerType !== 'touch') return; const rb = root.getBoundingClientRect(), x = e.clientX - rb.left, y = e.clientY - rb.top; clearTimeout(press); press = setTimeout(() => openAt(x, y, true), 500); });
['pointerup', 'pointercancel', 'pointermove'].forEach(t => c.on(msg, t, e => { if (t !== 'pointermove' || Math.abs(e.movementX) + Math.abs(e.movementY) > 4) clearTimeout(press); }));
// Shift+F10 or the Menu key on the reply opens it at the reply's corner
c.on(msg, 'keydown', e => { if ((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu'){ e.preventDefault(); const b = msg.getBoundingClientRect(), rb = root.getBoundingClientRect(); openAt(b.left - rb.left + 16, b.top - rb.top + 36, true); } });
// a press anywhere else closes it
c.on(document, 'pointerdown', e => { if (open && !pop.contains(e.target) && !sub.contains(e.target)) close(); }, true);
// the submenu opens as the pointer rests on its row, and shuts when it moves to another row
let hov = 0;
c.on(pop, 'pointerover', e => { const it = e.target.closest('.mi'); if (!it) return; clearTimeout(hov); if (it === subRow){ if (!subOpen) hov = setTimeout(() => openSub(), 160); } else if (subOpen) hov = setTimeout(() => closeSub(), 200); });
// the keys: up and down wrap, Home and End, a letter jumps to the next item starting with it, Right opens the submenu and Left
// or Escape shuts it, Escape shuts the menu and gives the focus back to the reply, Tab shuts it
const keys = (m, inSub) => c.on(m, 'keydown', e => {
const its = items(m), i = its.indexOf(document.activeElement), k = e.key;
let to = null;
if (k === 'ArrowDown') to = its[(i + 1) % its.length]; else if (k === 'ArrowUp') to = its[(i - 1 + its.length) % its.length];
else if (k === 'Home') to = its[0]; else if (k === 'End') to = its[its.length - 1];
else if (k === 'ArrowRight' && !inSub && document.activeElement === subRow){ e.preventDefault(); openSub(true); return; }
else if ((k === 'ArrowLeft' || k === 'Escape') && inSub){ e.preventDefault(); e.stopPropagation(); closeSub(true); return; }
else if (k === 'Escape'){ e.preventDefault(); e.stopPropagation(); close(true); return; }
else if (k === 'Tab'){ close(); return; }
else if (k.length === 1 && /\S/.test(k)){ const n = its.length, from = Math.max(i, 0); for (let j = 1; j <= n; j++){ const it = its[(from + j) % n]; if (it.textContent.trim().toLowerCase().startsWith(k.toLowerCase())){ to = it; break; } } }
if (to){ e.preventDefault(); to.focus({ preventScroll:true }); if (!inSub && to !== subRow) closeSub(); }
});
keys(pop, false); keys(sub, true);
// what each item does
const say = t => { swap(note, t); };
const words = t => t.split(' ').map((w, i, a) => `<span class="w" style="animation-delay:${i * 45}ms">${w}${i < a.length - 1 ? ' ' : ''}</span>`).join('');
const ASK = {
explain:['Explanation', 'info', 'On a phone the keyboard takes the lower half of the screen. The Continue button sits there, so people have to close the keyboard to find it, and many leave instead.'],
sum:['Summary', 'list', 'Mobile shoppers drop at the address step because the keyboard hides Continue. Moving it up could win back about 300 orders a week.'],
rewrite:['Shorter', 'edit', 'Mobile checkout loses people at the address step: the keyboard hides Continue. Move it up to win back about 300 orders a week.']
};
let asking = 0;
const ask = k => c.run(async () => {
const id = ++asking, [lab, ic, text] = ASK[k];
out.innerHTML = `<div class="cxm-res"><span class="k">${I(ic,'xs')}<span class="shim">Writing a ${lab.toLowerCase()}</span></span><span class="b"></span></div>`; enter(out.firstElementChild);
await c.wait(900); if (id !== asking) return;
const k2 = $('.k span', out); k2.classList.remove('shim'); k2.textContent = lab;
const b = $('.b', out), ws = text.split(' ');
for (let i = 1; i <= ws.length; i++){ if (id !== asking) return; b.textContent = ws.slice(0, i).join(' '); await c.wait(55); }
});
let pinned = false;
const act = it => {
const a = it.dataset.a;
if (a === 'ask'){ subOpen ? closeSub(true) : openSub(true); return; }
if (a === 'follow'){ const on = it.getAttribute('aria-checked') !== 'true'; it.setAttribute('aria-checked', on); say(on ? 'You will hear about replies to this' : 'You will not hear about replies'); return; }
// deleting asks again in the same row; the second press deletes, with Undo
if (a === 'del'){
if (!del.classList.contains('ask')){ del.classList.add('ask'); $('.t', del).textContent = 'Press again to delete'; del.setAttribute('aria-label', 'Delete reply: press again to confirm'); return; }
close(); hold(root); const was = msg.innerHTML; msg.hidden = true;
const g = html(`<div class="cxm-gone">Reply deleted<button class="pill ghost sm">Undo</button></div>`); root.insertBefore(g, note); enter(g); say('');
$('button', g).onclick = () => { g.remove(); msg.hidden = false; msg.innerHTML = was; release(root); msg.focus({ preventScroll:true }); };
return;
}
close(true);
if (a === 'copy'){ try { navigator.clipboard.writeText(tx.textContent).catch(() => {}); } catch(e){} say('Copied to clipboard'); }
if (a === 'quote') say('Quoted in your reply');
if (a === 'pin'){ pinned = !pinned; $('.cxm-pin', r).hidden = !pinned; $('.t', $('[data-a="pin"]', pop)).textContent = pinned ? 'Unpin' : 'Pin to thread'; say(pinned ? 'Pinned to the thread' : 'Unpinned'); }
if (ASK[a]) ask(a);
};
[pop, sub].forEach(m => c.on(m, 'click', e => { const it = e.target.closest('.mi'); if (it) act(it); }));
// the preview's story: right-click the reply, ask the agent for a summary, and it streams in under the reply
if (r === c.st) c.story(async s => {
await s.beat(3);
await s.point(tx); await s.beat(1);
const h = $('.hand', c.st).getBoundingClientRect();
tx.dispatchEvent(new MouseEvent('contextmenu', { bubbles:true, cancelable:true, clientX:h.left + 3, clientY:h.top + 2, button:2 }));
await s.beat(2);
await s.point(subRow, false); openSub(); await s.beat(2);
await s.tap($('[data-a="sum"]', sub)); s.away();
await s.until(() => !asking || ($('.b', out) && $('.b', out).textContent.length > 120)); await s.beat(6);
});
});
Questions
What people ask about it.
- Can I use it on something other than a reply?
- Yes.
childrenis whatever you right-click, andtargetClassNamereplaces the reply's panel classes with your own. - How do I make a checkable item?
- Give the item
checked(true or false). It becomes a menuitemcheckbox with a check mark, andonSelecttells you it was pressed so you can flip it. - Does it replace the browser's own menu everywhere?
- No, only on the target. Everywhere else a right-click still opens the browser's menu.
More in Collaboration
Threads, inboxes and boards for teams working alongside an agent.