mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add overlay menu documentation and QA demo
This commit is contained in:
1 parent
3fa7b6a73a
commit
3638a49503
4 files changed
+444
-2
No files matched your search
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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 & 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>
|
||||
Reference in new issue
Block a user