Add overlay menu documentation and QA demo

This commit is contained in:
thePR0M3TH3AN committed 2025-10-16 15:03:03 -04:00
1 parent 3fa7b6a73a
commit 3638a49503
4 files changed
+444 -2

No files matched your search

+1 -1
View File
@@ -329,7 +329,7 @@ Use `.popover` as the relative anchor for floating content. Nest `.popover__pane
</div>
```
Use the shared popover engine (`js/ui/overlay/popoverEngine.js`) instead of manual offsets. The helper attaches panels to the overlay root, flips placement when they collide with the viewport, and toggles `aria` + `data-popover-state` attributes as visibility changes. Width and height clamps can be sourced from design tokens (`maxWidthToken`, `maxHeightToken`) so surfaces inherit theme-specific limits without inline constants. 【F:js/ui/overlay/popoverEngine.js†L252-L266】【F:js/ui/overlay/popoverEngine.js†L633-L706】【F:css/tailwind.source.css†L358-L399】
Use the shared popover engine (`js/ui/overlay/popoverEngine.js`) instead of manual offsets. The helper attaches panels to the overlay root, flips placement when they collide with the viewport, and toggles `aria` + `data-popover-state` attributes as visibility changes. Width and height clamps can be sourced from design tokens (`maxWidthToken`, `maxHeightToken`) so surfaces inherit theme-specific limits without inline constants. Consult [docs/menus.md](./menus.md) for overlay-root wiring, Tailwind overlay helpers, and the manual QA surface that exercises card and modal menus. 【F:js/ui/overlay/popoverEngine.js†L252-L266】【F:js/ui/overlay/popoverEngine.js†L633-L706】【F:css/tailwind.source.css†L358-L399】
```js
import createPopover from "../overlay/popoverEngine.js";
+157
View File
@@ -0,0 +1,157 @@
# Menu & Popover Integration
Menus, quick actions, and contextual overlays all flow through the shared popover
engine. This guide documents how to request the overlay root, create and manage
popovers, and apply the Tailwind overlay helpers that keep menus consistent with
the design system.
## Request the overlay root
Use `ensureOverlayRoot` before mounting any floating UI so the engine has a
stable portal target. The helper resolves the right `document` instance and
appends the root container when it is missing.
```js
import ensureOverlayRoot from "../js/ui/overlay/overlayRoot.js";
const doc = trigger?.ownerDocument || document;
const overlayRoot = ensureOverlayRoot(doc);
// overlayRoot is a `<div id="uiOverlay">` tagged with `data-overlay-root`.
// Popovers, sheets, and modals render inside this element.
```
`ensureOverlayRoot` is idempotent—call it during setup for each controller or
dev surface without worrying about duplicates.
## Create a popover
`createPopover(trigger, render, options)` wires focus management, aria
attributes, collision detection, and auto updates. The render callback receives
a portal `container` inside the overlay root. Return the panel element so the
engine can toggle `data-popover-state` values while it opens and closes.
```js
import createPopover from "../js/ui/overlay/popoverEngine.js";
function attachMenu(trigger) {
const popover = createPopover(
trigger,
({ container, close }) => {
const panel = container.ownerDocument.createElement("div");
panel.className = "popover__panel menu space-y-1";
[
{ label: "Play", action: "play" },
{ label: "Save to playlist", action: "save" },
{ label: "Share", action: "share" },
].forEach(({ label, action }) => {
const item = container.ownerDocument.createElement("button");
item.type = "button";
item.className = "menu__item justify-between";
item.dataset.action = action;
item.textContent = label;
item.addEventListener("click", () => close({ restoreFocus: true }));
panel.appendChild(item);
});
container.append(panel);
return panel;
},
{
document: trigger?.ownerDocument,
placement: "bottom-end",
restoreFocusOnClose: true,
},
);
trigger.addEventListener("click", async (event) => {
event.preventDefault();
await popover.open();
});
}
```
Pass `document`, `placement`, and other floating-ui options through the third
argument. The helper exposes `open()`, `close()`, `toggle()`, `isOpen()`, and
`destroy()` for lifecycle control.
## Tailwind overlay classes
Tailwind ships token-backed overlay helpers that keep surfaces aligned with the
palette:
- `popover__panel` / `popover-panel` — rounded overlay panel with opacity and
scale transitions. Add `menu` and `menu__item` classes for stacked button
menus.
- `overlay-scrim` and `overlay-panel` — lightweight surfaces for inline overlays
and frosted tooltips. Use the `data-strength` / `data-variant` modifiers to
switch tones.
- `ds-overlay-backdrop` — full-screen backdrop helper for async states.
- `bv-modal`, `bv-modal-backdrop`, and `bv-modal__panel` — modal scaffolding
used by upload/edit flows. Mobile breakpoints flatten the panel automatically.
Avoid ad-hoc opacity or blur utilities—let the semantic classes map to overlay
color tokens so theme swaps stay centralized in
`css/tailwind.source.css`.
## Video card actions
Video cards lean on `MoreMenuController` to attach a popover to the card's
"More" button. The controller registers the overlay root, builds the menu panel,
and opens the popover on demand.
```js
// js/ui/moreMenuController.js
const render = this.createPopoverRender(entry);
const popover = createPopover(trigger, render, {
document: documentRef,
placement: "bottom-end",
});
// Inside the render callback (createVideoMoreMenuPanel):
panel.className = "popover__panel menu space-y-1";
button.className = "menu__item";
```
Each menu button dispatches `video:context-action` events so cards, lists, and
player overlays can respond without duplicating menu markup.
## Modal actions
`VideoModal` follows the same pattern for its in-modal menu and zap actions. It
requests the overlay root from the modal's `ownerDocument`, builds a
`popover__panel`, and delegates action handling back to the controller.
```js
// js/ui/components/VideoModal.js
const render = ({ document: doc, close }) => {
const panel = this.buildModalMoreMenuPanel({ document: doc, close });
return panel;
};
const popover = createPopover(this.modalMoreBtn, render, {
document: documentRef,
placement: "bottom-end",
restoreFocusOnClose: true,
});
```
Modal action buttons reuse the same `menu__item` styling and call `close()` to
collapse the overlay before dispatching the selected action.
## Manual QA surface
A lightweight QA harness lives at `views/dev/popover-demo.html`. Launch the
static dev server from the repository root:
```bash
python -m http.server 8000
# or
npx serve
```
Then open `http://localhost:8000/views/dev/popover-demo.html` to interact with a
sample video card and modal wired to the popover engine. The page ensures the
overlay root exists, demonstrates Tailwind overlay helpers, and exercises both
card and modal action menus without relying on production data.
+1 -1
View File
@@ -4,7 +4,7 @@ Run this checklist before publishing releases or merging changes that touch uplo
> **Note:** Align every UI styling review with the token-first rules in [`AGENTS.md`](../AGENTS.md) and the [README CSS build pipeline](../README.md#css-build-pipeline). When QA covers styling changes, run `npm run lint:css` (and any related build/test scripts) to catch regressions early.
Before starting, open `docs/kitchen-sink.html` in a browser to review the modal surface reference. With that page as your visual baseline, perform this quick accessibility spot check in the running app:
Before starting, open `docs/kitchen-sink.html` in a browser to review the modal surface reference. Spin up the static dev server (`python -m http.server 8000` or `npx serve`) and load `views/dev/popover-demo.html` to sanity-check overlay menus following [docs/menus.md](./menus.md). With those pages as your visual baseline, perform this quick accessibility spot check in the running app:
Automated regression coverage now exercises these flows on the unified design-system variant:
+285
View File
@@ -0,0 +1,285 @@
<!-- views/dev/popover-demo.html -->
<!DOCTYPE html>
<html lang="en" data-ds="new">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Popover & Menu Demo • bitvid</title>
<link rel="stylesheet" href="../../css/tailwind.generated.css" />
</head>
<body class="bg-surface text-text">
<main class="mx-auto flex min-h-screen max-w-docs flex-col gap-12 px-lg py-xl">
<header class="bv-stack bv-stack--tight">
<h1 class="text-3xl font-semibold text-text-strong">Popover &amp; Menu Demo</h1>
<p class="text-muted">
This manual QA page mounts a sample video card and modal so you can verify
overlay menus with the shared popover engine. Review the implementation notes in
<a href="../../docs/menus.md" class="text-info hover:text-info-strong">docs/menus.md</a>
for architectural guidance.
</p>
</header>
<section class="space-y-6">
<h2 class="text-2xl font-semibold text-text">Video card</h2>
<article class="card overflow-hidden" data-demo-card>
<div class="relative">
<div class="ratio-16-9 bg-overlay-panel">
<div class="absolute inset-0 flex items-center justify-center gap-3 text-center">
<span class="rounded-full bg-overlay-black/60 px-3 py-1 text-xs uppercase tracking-wide text-text">
Preview thumbnail
</span>
</div>
</div>
<button
type="button"
class="btn-ghost absolute right-3 top-3 inline-flex h-10 w-10 items-center justify-center text-lg"
aria-label="Open card menu"
data-demo-card-more
>
⋮
</button>
</div>
<div class="bv-stack bv-stack--tight border-t border-border px-md py-md">
<h3 class="text-lg font-semibold text-text">Understanding overlay primitives</h3>
<p class="text-sm text-muted">
Tap the more button to open an actions menu rendered through
<code class="font-mono text-3xs text-info">createPopover</code>.
</p>
</div>
</article>
</section>
<section class="space-y-6">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-2xl font-semibold text-text">Modal actions</h2>
<p class="text-sm text-muted">
Launch the demo modal to test menus layered above a backdrop.
</p>
</div>
<button
type="button"
class="btn"
data-demo-open-modal
aria-haspopup="dialog"
aria-controls="popoverDemoModal"
>
Open modal demo
</button>
</div>
</section>
</main>
<div
id="popoverDemoModal"
class="bv-modal hidden items-center"
role="dialog"
aria-modal="true"
aria-hidden="true"
aria-labelledby="popoverDemoModalTitle"
data-demo-modal
>
<div class="bv-modal-backdrop" data-variant="veil" data-demo-modal-backdrop></div>
<div
class="bv-modal__panel max-h-modal-shell overflow-y-auto space-y-6"
data-demo-modal-panel
tabindex="-1"
>
<div class="flex items-start justify-between gap-4">
<div class="bv-stack bv-stack--tight">
<h3 id="popoverDemoModalTitle" class="text-xl font-semibold text-text">
Modal context menu demo
</h3>
<p class="text-sm text-muted">
Use the menu button to confirm popovers behave while a modal is open.
</p>
</div>
<button
type="button"
class="btn-ghost"
aria-label="Close modal"
data-demo-modal-close
>
✕
</button>
</div>
<div class="space-y-4">
<p class="text-sm text-text">
The popover below targets the modal action trigger. Confirm focus returns to the
trigger after closing the menu and that the modal backdrop remains intact.
</p>
<button
type="button"
class="btn"
data-demo-modal-more
>
Modal action menu
</button>
</div>
<div class="space-y-2 rounded-lg border border-border/60 bg-overlay-panel-soft p-4 text-sm text-muted">
<p class="font-semibold text-text">QA checklist</p>
<ul class="list-disc space-y-1 pl-5">
<li>Open the card and modal menus and verify they render inside the overlay root.</li>
<li>Resize the viewport; menus should reposition without clipping their trigger.</li>
<li>Confirm focus is restored to the trigger when each menu closes.</li>
</ul>
</div>
</div>
</div>
<script type="module">
import ensureOverlayRoot from "../../js/ui/overlay/overlayRoot.js";
import createPopover from "../../js/ui/overlay/popoverEngine.js";
const doc = document;
ensureOverlayRoot(doc);
function buildMenu({ container, heading, actions, close }) {
const panel = container.ownerDocument.createElement("div");
panel.className = "popover__panel bv-stack bv-stack--tight";
if (heading) {
const title = container.ownerDocument.createElement("p");
title.className = "text-xs font-semibold uppercase tracking-wide text-muted";
title.textContent = heading;
panel.appendChild(title);
}
const list = container.ownerDocument.createElement("div");
list.className = "menu space-y-1";
actions.forEach(({ label, action, variant }) => {
const item = container.ownerDocument.createElement("button");
item.type = "button";
item.className = "menu__item justify-between";
if (variant) {
item.dataset.variant = variant;
}
item.dataset.action = action;
item.textContent = label;
item.addEventListener("click", () => close({ restoreFocus: true }));
list.appendChild(item);
});
panel.appendChild(list);
container.append(panel);
return panel;
}
function wirePopover(trigger, config) {
if (!trigger) {
return null;
}
const popover = createPopover(
trigger,
({ container, close }) =>
buildMenu({ container, close, heading: config.heading, actions: config.actions }),
{
document: trigger.ownerDocument,
placement: config.placement || "bottom-end",
restoreFocusOnClose: true,
},
);
trigger.addEventListener("click", async (event) => {
event.preventDefault();
await popover.open();
});
return popover;
}
const cardMoreButton = doc.querySelector("[data-demo-card-more]");
const cardPopover = wirePopover(cardMoreButton, {
heading: "Video actions",
actions: [
{ label: "Play now", action: "play" },
{ label: "Add to playlist", action: "playlist" },
{ label: "Share link", action: "share" },
{ label: "Report", action: "report", variant: "critical" },
],
});
const modal = doc.querySelector("[data-demo-modal]");
const modalPanel = modal?.querySelector("[data-demo-modal-panel]");
const modalBackdrop = modal?.querySelector("[data-demo-modal-backdrop]");
const openModalButton = doc.querySelector("[data-demo-open-modal]");
const closeModalButton = modal?.querySelector("[data-demo-modal-close]");
const modalMoreButton = modal?.querySelector("[data-demo-modal-more]");
let modalPopover = null;
function trapScroll(active) {
const method = active ? "add" : "remove";
doc.documentElement.classList[method]("modal-open");
doc.body.classList[method]("modal-open");
}
function showModal() {
if (!modal) {
return;
}
modal.classList.remove("hidden");
modal.removeAttribute("hidden");
modal.setAttribute("aria-hidden", "false");
trapScroll(true);
if (typeof modalPanel?.focus === "function") {
modalPanel.focus({ preventScroll: true });
}
}
function hideModal({ reason } = {}) {
if (!modal) {
return;
}
if (modalPopover?.isOpen?.()) {
modalPopover.close({ restoreFocus: false });
}
modal.classList.add("hidden");
modal.setAttribute("hidden", "true");
modal.setAttribute("aria-hidden", "true");
trapScroll(false);
if (openModalButton && reason !== "restore-focus-skip") {
openModalButton.focus();
}
}
openModalButton?.addEventListener("click", (event) => {
event.preventDefault();
showModal();
});
closeModalButton?.addEventListener("click", (event) => {
event.preventDefault();
hideModal();
});
modalBackdrop?.addEventListener("click", () => hideModal());
modal?.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.stopPropagation();
hideModal();
}
});
modalPopover = wirePopover(modalMoreButton, {
heading: "Modal actions",
actions: [
{ label: "Pin video", action: "pin" },
{ label: "Save for later", action: "save" },
{ label: "Block channel", action: "block", variant: "critical" },
],
});
// Keep popovers healthy when the DOM tears down.
doc.addEventListener("visibilitychange", () => {
if (doc.visibilityState === "hidden") {
cardPopover?.close?.({ restoreFocus: false });
modalPopover?.close?.({ restoreFocus: false });
}
});
</script>
</body>
</html>