ContextMenu
A menu where an area is right-clicked, held or Shift+F10 is pressed, with shortcuts, separators and the arrow keys.
Demo
Roadmap.pdfRight-click here, press and hold, or focus it and press Shift+F10.
No action yet.
LinksPlaced straight in the page: its items are links.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/context_menu.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/context_menu.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/context_menu.qwe
let context_menu_demo_css = css`.ctx-demo-card { display: flex; align-items: center; gap: 14px; padding: 18px; border: 1px dashed var(--ui-strong); border-radius: var(--ui-radius-sm); background: var(--ui-subtle); color: var(--ui-muted); user-select: none; }.ctx-demo-card b { display: block; color: var(--ui-fg); }.ctx-demo-card svg { flex: none; width: 34px; height: 34px; color: var(--ui-accent); }`let context_menu_demo_items = [ { label: "Open", shortcut: "Enter" }, { label: "Rename", shortcut: "F2" }, { label: "Duplicate", shortcut: "Ctrl D" }, { label: "Share…", disabled: true }, { divider: true }, { label: "Copy link", shortcut: "Ctrl Shift C" }, { label: "Download", shortcut: "Ctrl S" }, { divider: true }, { label: "Delete", shortcut: "Del", tone: "danger" },]fn context_menu_demo_card(title: str, text: str) = <div.ctx-demo-card> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M6 2.5h8l4.5 4.5v14.5H6z"/><path d="M14 2.5V7h4.5M9 12h6M9 15.5h6"/></svg> <span><b>{title}</b>{text}</span> </div>component ContextMenuDemoLive() { state last = null <div.demo-stack> <ContextMenu id="ctx-file" label="Actions on Roadmap.pdf" items={context_menu_demo_items} on_select={(it) => { last = it.label }}> {context_menu_demo_card("Roadmap.pdf", "Right-click here, press and hold, or focus it and press Shift+F10.")} </ContextMenu> <p.demo-note>{last == null ? "No action yet." : `Chose “${last}”.`}</p> </div>}view ContextMenuDemo() = <div.demo-stack> <style>{context_menu_demo_css}</style> <ContextMenuDemoLive/> <ContextMenu id="ctx-links" label="Go to" items={[ { label: "Context menu", href: "#context_menu" }, { label: "Dropdown menu", href: "#dropdown_menu" }, { divider: true }, { label: "Back to the top", href: "#", shortcut: "Home" }, ]}> {context_menu_demo_card("Links", "Placed straight in the page: its items are links.")} </ContextMenu> </div>Source
ui/context_menu.qwe
// ContextMenu: a menu of actions that opens where an area is right-clicked, held down// on a touch screen, or (with the area focused) where Shift+F10 or the Menu key is// pressed. It is the browser's `popover`, so a click elsewhere closes it. Inside it// the arrow keys, Home and End move, a letter jumps to the next item starting with it,// Enter chooses and Escape closes, putting focus back on the area.//// <ContextMenu id="file-menu" label="File actions" items={[// { label: "Open", href: "/files/7" },// { label: "Rename", shortcut: "F2" },// { label: "Duplicate", shortcut: "Ctrl D", disabled: true },// { divider: true },// { label: "Delete", shortcut: "Del", tone: "danger" },// ]}>// <FileCard file={f}/>// </ContextMenu>//// id (required): the menu's id, unique on the page. children: the area it belongs to.// items: a list of { label, shortcut?, href?, disabled?, tone?: "danger" }, or// { divider: true } for a line between groups. shortcut is shown beside the label// ("Ctrl C"). An item with an href is a link; one without is a button that closes the// menu and, inside one of your components, calls on_select(item). disabled: shown,// but it does nothing. label: the menu's and the area's name ("Actions").// hint: what screen readers hear on the area about the menu (null for nothing).import ui/theme.qwe use ui_clslet context_menu_css = css`.ui-ctx-area { border-radius: var(--ui-radius-sm); -webkit-touch-callout: none; }.ui-ctx-area:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-ctx-menu { position: fixed; inset: auto; box-sizing: border-box; min-width: 200px; max-width: min(300px, calc(100vw - 16px)); max-height: calc(100dvh - 16px); margin: 0; padding: 5px; overflow: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); font: 14px/1.4 var(--ui-font);}.ui-ctx-menu:popover-open { animation: ui-ctx-in .1s ease-out; }@keyframes ui-ctx-in { from { opacity: 0; transform: scale(.97); } }.ui-ctx-item { display: flex; align-items: center; gap: 16px; width: 100%; padding: 7px 10px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-fg); font: inherit; text-align: left; text-decoration: none; cursor: pointer;}.ui-ctx-item span:first-child { flex: 1; }.ui-ctx-item:hover, .ui-ctx-item:focus { outline: none; background: var(--ui-subtle); }.ui-ctx-item:focus-visible { box-shadow: var(--ui-focus); }.ui-ctx-item[aria-disabled="true"] { opacity: .5; cursor: not-allowed; }.ui-ctx-item[aria-disabled="true"]:hover { background: transparent; }.ui-ctx-danger { color: var(--ui-danger); }.ui-ctx-danger:hover, .ui-ctx-danger:focus { background: var(--ui-danger-soft); }.ui-ctx-key { color: var(--ui-muted); font-size: 12px; letter-spacing: .02em; white-space: nowrap; }.ui-ctx-danger .ui-ctx-key { color: inherit; opacity: .8; }.ui-ctx-sep { height: 1px; margin: 4px 6px; background: var(--ui-border); }@media (prefers-reduced-motion: reduce) { .ui-ctx-menu:popover-open { animation: none; } }`// The place among the menu's items of the one an event came from (or from inside), or// -1: isSameNode, as `==` on two page elements is not the same test on every Quillo version.fn context_menu_at(all, target) -> int { let item = target.closest("[role=menuitem]") item == null ? -1 : (index_of(for i in range(0, all.length) => all[i].isSameNode(item), true) ?? -1)}// The item an arrow key, Home or End goes to from item `at` (-1 for none) of n, round// at the ends, or null for another key.fn context_menu_next(key: str, at: int, n: int) -> int? = match key { "ArrowDown" => (at + 1 + n) % n "ArrowUp" => (at - 1 + n) % n "Home" => 0 "End" => n - 1 _ => null}component ContextMenu(id: str, items: list = [], label: str = "Actions", hint: str? = "Right-click, press and hold, or press Shift+F10 for actions.", on_select = null, class: str? = null, children) { style global context_menu_css state menu = null state px = 0 state py = 0 state press = null // Opens the menu with its corner at x, y, moved in when it would go off the screen. let open_at = (x: num, y: num) => { press = null if !menu.matches(":popover-open") { menu.showPopover() } let w = menu.offsetWidth let h = menu.offsetHeight let vw = js("innerWidth") let vh = js("innerHeight") px = max([8, x + w > vw - 8 ? x - w : x]) py = max([8, y + h > vh - 8 ? y - h : y]) let first_item = menu.querySelector("[role=menuitem]") first_item && first_item.focus() } let back = () => { menu.hidePopover() let area = menu.parentElement.querySelector(".ui-ctx-area") area && area.focus() } let jump = (e, ch: str) => { let all = menu.querySelectorAll("[role=menuitem]") let n = all.length let at = context_menu_at(all, e.target) let order = for k in range(1, n + 1) => (at + k) % n let hit = find(order, (i) => starts_with(lower(trim(all[i].textContent)), ch)) if hit != null { all[hit].focus() } } if press != null { every(550, () => { open_at(press.x, press.y) }) } <div class={ui_cls(["ui-ctx", class])}> <div.ui-ctx-area tabindex="0" role="group" aria-label={label} aria-describedby={hint != null ? `${id}-hint` : null} @contextmenu={(e) => { e.preventDefault() open_at(e.clientX, e.clientY) }} @keydown={(e) => { if (e.key == "F10" && e.shiftKey) || e.key == "ContextMenu" { e.preventDefault() let r = e.target.closest(".ui-ctx-area").getBoundingClientRect() open_at(r.left + 12, r.top + 12) } }} @pointerdown={(e) => { if e.pointerType == "touch" { press = { x: e.clientX, y: e.clientY } } }} @pointermove={(e) => { if press != null && abs(e.clientX - press.x) + abs(e.clientY - press.y) > 10 { press = null } }} @pointerup={() => { press = null }} @pointercancel={() => { press = null }}>{children}</div> {hint != null && <span.ui-sr id={`${id}-hint`}>{hint}</span>} <div.ui-ctx-menu id={id} popover="auto" role="menu" aria-label={label} style={`left: ${px}px; top: ${py}px`} @mount={(el) => { menu = el }} @keydown={(e) => { let k = e.key // Moved and focused before anything else: on some Quillo versions a `match`, // or a call to a function of the component's own, lets a second key sent at // once in first, which would find the focus where it was. let all = menu.querySelectorAll("[role=menuitem]") let next = context_menu_next(k, context_menu_at(all, e.target), all.length) if next != null { e.preventDefault() all[next].focus() } if k == "Escape" || k == "Tab" { e.preventDefault(); back() } if len(k) == 1 && k != " " && !e.ctrlKey && !e.metaKey { jump(e, lower(k)) } }}> {for [i, it] in enumerate(items) => get(it, "divider") == true ? <div.ui-ctx-sep key={i} role="separator"></div> : get(it, "href") != null && get(it, "disabled") != true ? <a key={i} role="menuitem" tabindex="-1" href={it.href} class={ui_cls(["ui-ctx-item", get(it, "tone") == "danger" && "ui-ctx-danger"])} @click={() => { menu.hidePopover() }}> <span>{it.label}</span> {get(it, "shortcut") != null && <span.ui-ctx-key>{it.shortcut}</span>} </a> : <button key={i} type="button" role="menuitem" tabindex="-1" class={ui_cls(["ui-ctx-item", get(it, "tone") == "danger" && "ui-ctx-danger"])} aria-disabled={get(it, "disabled") == true ? "true" : null} aria-keyshortcuts={get(it, "shortcut") != null ? replace(it.shortcut, " ", "+") : null} @click={() => { if get(it, "disabled") != true { back() on_select && on_select(it) } }}> <span>{it.label}</span> {get(it, "shortcut") != null && <span.ui-ctx-key>{it.shortcut}</span>} </button>} </div> </div>}Related
DropdownMenuA button that opens a short list of links and actions; Escape or a click elsewhere closes it.6 views · 7 downloadsCollapsibleNavSide navigation in groups that fold under their titles, the browser remembering which are open.6 views · 7 downloadsLanguageSwitcherA button showing the page's language that opens the others, each in its own name, as links to the same page in it.5 views · 6 downloadsMegaMenuA top bar whose items open wide panels of grouped links under the whole bar, on hover and from the keyboard, with no script.5 views · 7 downloadsMenuA vertical list of links and actions in titled sections, with icons, shortcuts, counts, disabled items and the active one marked.6 views · 6 downloadsMobileNavA menu button that opens the site's navigation over the whole screen, as a dialog: groups, the current page, actions at the bottom.5 views · 6 downloads
Thread
Thread 0 posts
On ContextMenu: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.