Navigation

ContextMenu

A menu where an area is right-clicked, held or Shift+F10 is pressed, with shortcuts, separators and the arrow keys.

#menu#overlay#interactive

Demo

Preview
Roadmap.pdfRight-click here, press and hold, or focus it and press Shift+F10.
Right-click, press and hold, or press Shift+F10 for actions.

No action yet.

LinksPlaced straight in the page: its items are links.
Right-click, press and hold, or press Shift+F10 for actions.

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.qwe
main.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>}
Thread
56Useful? Like it, or add to its thread below.

Thread 0 posts

On ContextMenu: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.