Files
siftlode/frontend/src/lib/backgroundTarget.ts
T
peter 97381e65d0 feat(design): a page's background menu on the five list pages (E28 S3e4c)
A right-click on the EMPTY area of a page opens a menu of that page's own
toolbar: the feed (sort, reshuffle, view, watch state, content, source, clear
filters), the Plex library grid (sort, watch state, clear), the channel manager
(layout, the cards' sort, sync status, edit tags, clear the tag filter, read
subscriptions), a playlist (play all, group by, reset view, save order,
undo/redo, the list's own menu) and the notification center (mark all read).
No destructive row and no backfill.

PageShell listens on the page area; lib/backgroundTarget decides empty (not
text, a control, a link, media, or a row/card with its own menu). Shift+right-
click, a touch long-press and a right-click on selected words keep the
browser's menu. ui/PageMenu registers while mounted, so a loading or error
state keeps the browser's menu too. The rows mirror each control through
ui/pageMenuItems (sortSubmenu via pickSort, choiceSubmenu, multiSubmenu).
pageMenuScope.contract.test.ts pins the five pages.
2026-09-26 20:48:53 +02:00

56 lines
1.8 KiB
TypeScript

// docs/DESIGN.md "Background menu": a right-click on EMPTY content area opens the page's menu; over
// text, a control, a link, media or an object with its own menu the browser's (or the object's) stays.
// This decides "empty", from the element the pointer is over up to the page root that listens.
/** An element that is not background: a control, a link, a field, media, or an object (a row or card
* with its own menu, a list item the keyboard walks). Anything inside one is not background either. */
const NOT_BACKGROUND = [
"a",
"button",
"input",
"textarea",
"select",
"label",
"summary",
"[contenteditable]",
// A widget by its role (a div acting as a button, a menu, a listbox and its options, …).
...[
"button",
"link",
"menuitem",
"tab",
"option",
"slider",
"checkbox",
"switch",
"dialog",
"menu",
"listbox",
].map((role) => `[role="${role}"]`),
"img",
"picture",
"video",
"audio",
"canvas",
"svg",
"iframe",
"[data-context-menu-target]",
"[data-nav-index]",
].join(",");
/** True when `target` is text itself: an element holding words of its own (not only inside children).
* A right-click on a paragraph, a heading or a label's padding is on text: its menu is the browser's. */
function holdsText(el: Element): boolean {
for (const node of el.childNodes)
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) return true;
return false;
}
/** True when a right-click on `target` falls on the empty area of `root` (the page). */
export function isBackgroundPoint(target: EventTarget | null, root: Element): boolean {
if (!(target instanceof Element) || !root.contains(target)) return false;
if (holdsText(target)) return false;
const hit = target.closest(NOT_BACKGROUND);
return !hit || !root.contains(hit);
}