From b850c63aa57b104dfc64a807c1f1d31ee3c55a7b Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sat, 11 Oct 2025 16:58:03 -0400
Subject: [PATCH] Add floating panel positioner and update popover consumers
---
css/style.css | 32 ++
docs/design-system.md | 27 +-
js/channelProfile.js | 11 +
js/ui/components/VideoCard.js | 21 ++
js/ui/components/VideoModal.js | 37 +++
js/ui/moreMenuController.js | 14 +
js/ui/utils/positionFloatingPanel.js | 417 +++++++++++++++++++++++++
tests/position-floating-panel.test.mjs | 190 +++++++++++
8 files changed, 747 insertions(+), 2 deletions(-)
create mode 100644 js/ui/utils/positionFloatingPanel.js
create mode 100644 tests/position-floating-panel.test.mjs
diff --git a/css/style.css b/css/style.css
index 54b4ed02..f6d86a76 100644
--- a/css/style.css
+++ b/css/style.css
@@ -147,6 +147,38 @@ header img {
var(--shadow-lg);
}
+.popover__panel {
+ position: fixed;
+ z-index: 40;
+ opacity: 0;
+ transform: var(--floating-transform-closed, scale(0.97));
+ transform-origin: var(--floating-transform-origin, top center);
+ transition:
+ opacity var(--motion-duration-fast) var(--motion-ease-out),
+ transform var(--motion-duration-fast) var(--motion-ease-out);
+ pointer-events: none;
+}
+
+.popover__panel[data-state="open"] {
+ opacity: 1;
+ transform: var(--floating-transform-open, scale(1));
+ pointer-events: auto;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .popover__panel {
+ transition-duration: 1ms;
+ transition-timing-function: linear;
+ transform: none !important;
+ }
+ .popover__panel[data-state="open"] {
+ opacity: 1;
+ }
+ .popover__panel[data-state="closed"] {
+ opacity: 0;
+ }
+}
+
.card[data-motion="enter"] {
opacity: 0;
animation: card-motion-enter 220ms ease-out forwards;
diff --git a/docs/design-system.md b/docs/design-system.md
index 28cafb71..e102c3a2 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -155,7 +155,7 @@ Static modal partials (login, application, feedback, etc.) should call the helpe
### Popovers
-Use `.popover` as the relative anchor for floating content. Nest `.popover__panel` for the floating surface—it’s positioned absolutely below the trigger, inherits the shared focus ring, and animates with opacity/scale tokens. Toggle `[data-state="open"]` on the panel to switch between the collapsed (`opacity-0 scale-95`) and expanded (`opacity-100 scale-100`) states.
+Use `.popover` as the relative anchor for floating content. Nest `.popover__panel` for the floating surface—it’s positioned with the shared helper, inherits the shared focus ring, and animates with opacity/scale tokens. Toggle `[data-state="open"]` on the panel to switch between the collapsed (`opacity-0 scale-95`) and expanded (`opacity-100 scale-100`) states.
```html
@@ -166,7 +166,30 @@ Use `.popover` as the relative anchor for floating content. Nest `.popover__pane
```
-Mix utilities such as `right-0` or `translate-x-1/2` on `.popover__panel` when you need alternative alignments. The motion helpers defer to the global motion tokens so Task 5 can adjust timing globally without revisiting individual components.
+Use the shared positioning helper (`js/ui/utils/positionFloatingPanel.js`) instead of manual utility offsets. It calculates placement, flips when the surface collides with the viewport, and clamps to an optional padding gutter—all while listening for scroll/resize events and using `ResizeObserver` when available. Call it with the trigger and panel elements, then invoke `positioner.update()` once the panel is visible:
+
+```js
+import positionFloatingPanel from "../utils/positionFloatingPanel.js";
+
+const positioner = positionFloatingPanel(triggerEl, panelEl, {
+ placement: "bottom", // top | bottom | left | right
+ alignment: "end", // start | center | end (RTL aware)
+ offset: 8,
+ viewportPadding: 16,
+});
+
+panelEl.hidden = false;
+panelEl.dataset.state = "open";
+positioner.update();
+```
+
+Recommended container pattern:
+
+- Wrap the trigger and panel in `.popover` so the markup stays discoverable and inherits spacing tokens.
+- Leave the wrapper’s overflow visible; the helper defaults to `position: fixed`, so floating surfaces escape scroll containers without getting clipped. Opt into `{ strategy: "absolute" }` if you intentionally want the panel constrained.
+- When tearing down a component, call `positioner.destroy()` to drop scroll/resize listeners.
+
+The motion helpers defer to the global motion tokens so Task 5 can adjust timing globally without revisiting individual components. Animations respect `prefers-reduced-motion` by collapsing to opacity-only transitions.
### Menus
diff --git a/js/channelProfile.js b/js/channelProfile.js
index 63eadb1a..1518b3e5 100644
--- a/js/channelProfile.js
+++ b/js/channelProfile.js
@@ -11,6 +11,7 @@ import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import { accessControl } from "./accessControl.js";
import { getApplication } from "./applicationContext.js";
import { escapeHTML } from "./utils/domUtils.js";
+import positionFloatingPanel from "./ui/utils/positionFloatingPanel.js";
import { VideoCard } from "./ui/components/VideoCard.js";
import { ALLOW_NSFW_CONTENT } from "./config.js";
import {
@@ -124,6 +125,7 @@ let cachedZapSendBtn = null;
let pendingZapRetry = null;
let zapInFlight = false;
let zapControlsOpen = false;
+let zapControlsPositioner = null;
let currentVideoLoadToken = 0;
let currentProfileLoadToken = 0;
@@ -394,12 +396,21 @@ function openZapControls({ focus = false } = {}) {
if (!controls || !zapButton) {
return false;
}
+ if (!zapControlsPositioner) {
+ zapControlsPositioner = positionFloatingPanel(zapButton, controls, {
+ placement: "bottom",
+ alignment: "end",
+ offset: 8,
+ viewportPadding: 16,
+ });
+ }
if (!zapControlsOpen) {
controls.hidden = false;
controls.dataset.state = "open";
controls.setAttribute("aria-hidden", "false");
zapButton.setAttribute("aria-expanded", "true");
zapControlsOpen = true;
+ zapControlsPositioner?.update();
}
if (focus) {
focusZapAmountField();
diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js
index e89de522..ed23dcc4 100644
--- a/js/ui/components/VideoCard.js
+++ b/js/ui/components/VideoCard.js
@@ -1,4 +1,5 @@
import { normalizeDesignSystemContext } from "../../designSystem.js";
+import positionFloatingPanel from "../utils/positionFloatingPanel.js";
export class VideoCard {
constructor({
@@ -111,6 +112,8 @@ export class VideoCard {
this.deleteButton = null;
this.moreMenuButton = null;
this.moreMenu = null;
+ this.settingsDropdownPositioner = null;
+ this.moreMenuPositioner = null;
this.urlHealthBadgeEl = null;
this.torrentHealthBadgeEl = null;
this.viewCountEl = null;
@@ -660,6 +663,16 @@ export class VideoCard {
this.settingsButton = button;
this.settingsDropdown = dropdown;
+ this.settingsDropdownPositioner = positionFloatingPanel(
+ button,
+ dropdown,
+ {
+ placement: "bottom",
+ alignment: "end",
+ offset: 8,
+ viewportPadding: 16,
+ },
+ );
}
return container;
@@ -862,6 +875,12 @@ export class VideoCard {
this.moreMenuButton = button;
this.moreMenu = dropdown;
+ this.moreMenuPositioner = positionFloatingPanel(button, dropdown, {
+ placement: "bottom",
+ alignment: "end",
+ offset: 8,
+ viewportPadding: 16,
+ });
return wrapper;
}
@@ -1462,6 +1481,7 @@ export class VideoCard {
this.settingsDropdown.dataset.state = "open";
this.settingsDropdown.setAttribute("aria-hidden", "false");
this.settingsButton.setAttribute("aria-expanded", "true");
+ this.settingsDropdownPositioner?.update();
}
});
}
@@ -1524,6 +1544,7 @@ export class VideoCard {
this.moreMenu.dataset.state = "open";
this.moreMenu.setAttribute("aria-hidden", "false");
this.moreMenuButton.setAttribute("aria-expanded", "true");
+ this.moreMenuPositioner?.update();
}
});
diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js
index f92b8c68..a5ca2734 100644
--- a/js/ui/components/VideoModal.js
+++ b/js/ui/components/VideoModal.js
@@ -1,4 +1,5 @@
import { createModalAccessibility } from "./modalAccessibility.js";
+import positionFloatingPanel from "../utils/positionFloatingPanel.js";
export class VideoModal {
constructor({
@@ -66,6 +67,8 @@ export class VideoModal {
this.modalZapWalletLink = null;
this.modalZapDialogOpen = false;
this.modalZapPending = false;
+ this.modalZapPositioner = null;
+ this.modalMoreMenuPositioner = null;
this.modalPosterCleanup = null;
this.videoEventCleanup = null;
@@ -202,6 +205,15 @@ export class VideoModal {
}
this.modalAccessibility = null;
+ if (this.modalZapPositioner?.destroy) {
+ this.modalZapPositioner.destroy();
+ }
+ if (this.modalMoreMenuPositioner?.destroy) {
+ this.modalMoreMenuPositioner.destroy();
+ }
+ this.modalZapPositioner = null;
+ this.modalMoreMenuPositioner = null;
+
const previousScrollRegion = this.scrollRegion;
if (previousScrollRegion && this.modalNavScrollHandler) {
previousScrollRegion.removeEventListener(
@@ -272,6 +284,18 @@ export class VideoModal {
this.modalZapDialog.hidden = true;
this.modalZapDialog.setAttribute("aria-hidden", "true");
}
+ if (this.modalZapBtn) {
+ this.modalZapPositioner = positionFloatingPanel(
+ this.modalZapBtn,
+ this.modalZapDialog,
+ {
+ placement: "bottom",
+ alignment: "end",
+ offset: 8,
+ viewportPadding: 16,
+ },
+ );
+ }
}
if (this.modalMoreMenu) {
@@ -285,6 +309,18 @@ export class VideoModal {
this.modalMoreMenu.hidden = true;
this.modalMoreMenu.setAttribute("aria-hidden", "true");
}
+ if (this.modalMoreBtn) {
+ this.modalMoreMenuPositioner = positionFloatingPanel(
+ this.modalMoreBtn,
+ this.modalMoreMenu,
+ {
+ placement: "bottom",
+ alignment: "end",
+ offset: 8,
+ viewportPadding: 16,
+ },
+ );
+ }
}
const closeButton = playerModal.querySelector("#closeModal");
@@ -694,6 +730,7 @@ export class VideoModal {
if (this.modalZapBtn) {
this.modalZapBtn.setAttribute("aria-expanded", "true");
}
+ this.modalZapPositioner?.update();
this.focusZapAmount();
}
diff --git a/js/ui/moreMenuController.js b/js/ui/moreMenuController.js
index 5859479c..0747be2d 100644
--- a/js/ui/moreMenuController.js
+++ b/js/ui/moreMenuController.js
@@ -1,4 +1,5 @@
import { normalizeDesignSystemContext } from "../designSystem.js";
+import positionFloatingPanel from "./utils/positionFloatingPanel.js";
export default class MoreMenuController {
constructor(options = {}) {
@@ -57,6 +58,7 @@ export default class MoreMenuController {
this.boundVideoListShareListener = null;
this.boundVideoListContextListener = null;
this.boundVideoListBlacklistHandler = null;
+ this.dropdownPositioners = new Map();
}
setVideoModal(videoModal) {
@@ -284,6 +286,16 @@ export default class MoreMenuController {
return;
}
+ if (dropdown && !this.dropdownPositioners.has(dropdown)) {
+ const positioner = positionFloatingPanel(button, dropdown, {
+ placement: "bottom",
+ alignment: "end",
+ offset: 8,
+ viewportPadding: 16,
+ });
+ this.dropdownPositioners.set(dropdown, positioner);
+ }
+
if (dropdown && !dropdown.dataset.state) {
const isHidden =
dropdown.hasAttribute("hidden") ||
@@ -304,6 +316,8 @@ export default class MoreMenuController {
dropdown.dataset.state = "open";
dropdown.setAttribute("aria-hidden", "false");
button.setAttribute("aria-expanded", "true");
+ const positioner = this.dropdownPositioners.get(dropdown);
+ positioner?.update();
}
});
});
diff --git a/js/ui/utils/positionFloatingPanel.js b/js/ui/utils/positionFloatingPanel.js
new file mode 100644
index 00000000..db040a10
--- /dev/null
+++ b/js/ui/utils/positionFloatingPanel.js
@@ -0,0 +1,417 @@
+const DEFAULT_OPTIONS = {
+ placement: "bottom",
+ alignment: "start",
+ offset: 8,
+ flip: true,
+ viewportPadding: 12,
+ strategy: "fixed",
+ rtl: null,
+ onUpdate: null,
+};
+
+const OPPOSITE_PLACEMENT = {
+ top: "bottom",
+ bottom: "top",
+ left: "right",
+ right: "left",
+};
+
+function normalizeNumber(value, fallback) {
+ const numeric = Number(value);
+ return Number.isFinite(numeric) ? numeric : fallback;
+}
+
+function resolveDirection(anchor, documentRef, explicitRtl) {
+ if (typeof explicitRtl === "boolean") {
+ return explicitRtl;
+ }
+ const docDir =
+ documentRef?.documentElement?.dir || documentRef?.body?.dir || "";
+ if (docDir) {
+ return docDir.toLowerCase() === "rtl";
+ }
+ if (anchor && documentRef?.defaultView?.getComputedStyle) {
+ try {
+ const direction = documentRef.defaultView.getComputedStyle(anchor).direction;
+ return direction === "rtl";
+ } catch (error) {
+ // Ignore getComputedStyle failures (e.g., detached elements in jsdom).
+ }
+ }
+ return false;
+}
+
+function clamp(value, min, max) {
+ if (value < min) {
+ return min;
+ }
+ if (value > max) {
+ return max;
+ }
+ return value;
+}
+
+function computeAlignmentX({
+ alignment,
+ anchorRect,
+ panelWidth,
+ rtl,
+}) {
+ switch (alignment) {
+ case "center": {
+ return anchorRect.left + anchorRect.width / 2 - panelWidth / 2;
+ }
+ case "end": {
+ return rtl
+ ? anchorRect.left
+ : anchorRect.right - panelWidth;
+ }
+ case "start":
+ default: {
+ return rtl
+ ? anchorRect.right - panelWidth
+ : anchorRect.left;
+ }
+ }
+}
+
+function computeAlignmentY({ alignment, anchorRect, panelHeight }) {
+ switch (alignment) {
+ case "center": {
+ return anchorRect.top + anchorRect.height / 2 - panelHeight / 2;
+ }
+ case "end": {
+ return anchorRect.bottom - panelHeight;
+ }
+ case "start":
+ default: {
+ return anchorRect.top;
+ }
+ }
+}
+
+function computePosition({
+ placement,
+ alignment,
+ anchorRect,
+ panelSize,
+ offset,
+ rtl,
+}) {
+ const [panelWidth, panelHeight] = panelSize;
+ switch (placement) {
+ case "top": {
+ return {
+ top: anchorRect.top - panelHeight - offset,
+ left: computeAlignmentX({ alignment, anchorRect, panelWidth, rtl }),
+ };
+ }
+ case "left": {
+ return {
+ top: computeAlignmentY({ alignment, anchorRect, panelHeight }),
+ left: anchorRect.left - panelWidth - offset,
+ };
+ }
+ case "right": {
+ return {
+ top: computeAlignmentY({ alignment, anchorRect, panelHeight }),
+ left: anchorRect.right + offset,
+ };
+ }
+ case "bottom":
+ default: {
+ return {
+ top: anchorRect.bottom + offset,
+ left: computeAlignmentX({ alignment, anchorRect, panelWidth, rtl }),
+ };
+ }
+ }
+}
+
+function computeTransformOrigin({ placement, alignment, rtl }) {
+ let originY = "center";
+ let originX = "center";
+
+ if (placement === "top") {
+ originY = "bottom";
+ } else if (placement === "bottom") {
+ originY = "top";
+ } else if (placement === "left") {
+ originX = "right";
+ } else if (placement === "right") {
+ originX = "left";
+ }
+
+ if (placement === "top" || placement === "bottom") {
+ if (alignment === "start") {
+ originX = rtl ? "right" : "left";
+ } else if (alignment === "end") {
+ originX = rtl ? "left" : "right";
+ } else {
+ originX = "center";
+ }
+ } else if (placement === "left" || placement === "right") {
+ if (alignment === "start") {
+ originY = "top";
+ } else if (alignment === "end") {
+ originY = "bottom";
+ } else {
+ originY = "center";
+ }
+ }
+
+ return `${originX} ${originY}`;
+}
+
+function normalizePlacement(value) {
+ const normalized = typeof value === "string" ? value.toLowerCase() : "";
+ if (normalized === "top" || normalized === "bottom") {
+ return normalized;
+ }
+ if (normalized === "left" || normalized === "right") {
+ return normalized;
+ }
+ return "bottom";
+}
+
+function normalizeAlignment(value) {
+ const normalized = typeof value === "string" ? value.toLowerCase() : "";
+ if (normalized === "center" || normalized === "end") {
+ return normalized;
+ }
+ return "start";
+}
+
+function getViewportSize(windowRef, documentRef) {
+ const width = windowRef?.innerWidth || documentRef?.documentElement?.clientWidth || 0;
+ const height = windowRef?.innerHeight || documentRef?.documentElement?.clientHeight || 0;
+ return { width, height };
+}
+
+function resolvePanelSize(panel, previousSize) {
+ const rect = typeof panel.getBoundingClientRect === "function"
+ ? panel.getBoundingClientRect()
+ : { width: 0, height: 0 };
+ let width = rect.width || panel.offsetWidth || previousSize?.width || 0;
+ let height = rect.height || panel.offsetHeight || previousSize?.height || 0;
+
+ if (!width && previousSize?.width) {
+ width = previousSize.width;
+ }
+ if (!height && previousSize?.height) {
+ height = previousSize.height;
+ }
+
+ return { width, height };
+}
+
+export function positionFloatingPanel(anchor, panel, options = {}) {
+ const safeAnchor = anchor || null;
+ const safePanel = panel || null;
+
+ if (!safeAnchor || !safePanel) {
+ return {
+ update() {},
+ destroy() {},
+ };
+ }
+
+ const documentRef = safeAnchor.ownerDocument || safePanel.ownerDocument || globalThis.document;
+ const windowRef = documentRef?.defaultView || globalThis;
+
+ const config = {
+ ...DEFAULT_OPTIONS,
+ ...options,
+ };
+ config.placement = normalizePlacement(config.placement);
+ config.alignment = normalizeAlignment(config.alignment);
+ config.offset = clamp(normalizeNumber(config.offset, DEFAULT_OPTIONS.offset), 0, Number.MAX_SAFE_INTEGER);
+ config.viewportPadding = clamp(
+ normalizeNumber(config.viewportPadding, DEFAULT_OPTIONS.viewportPadding),
+ 0,
+ Number.MAX_SAFE_INTEGER,
+ );
+ config.strategy = config.strategy === "absolute" ? "absolute" : "fixed";
+ const rtl = resolveDirection(safeAnchor, documentRef, config.rtl);
+
+ const state = {
+ lastSize: { width: 0, height: 0 },
+ cleanup: [],
+ };
+
+ const applyPosition = ({ top, left, placement }) => {
+ safePanel.style.position = config.strategy;
+ safePanel.style.top = `${top}px`;
+ safePanel.style.left = `${left}px`;
+ safePanel.style.right = "auto";
+ safePanel.style.bottom = "auto";
+ safePanel.dataset.placement = placement;
+ const transformOrigin = computeTransformOrigin({
+ placement,
+ alignment: config.alignment,
+ rtl,
+ });
+ safePanel.style.setProperty("--floating-transform-origin", transformOrigin);
+ if (typeof config.onUpdate === "function") {
+ config.onUpdate({ top, left, placement, alignment: config.alignment, rtl });
+ }
+ };
+
+ const update = () => {
+ if (!safeAnchor.isConnected || !safePanel.isConnected) {
+ return;
+ }
+
+ const anchorRect = safeAnchor.getBoundingClientRect();
+ const { width: panelWidth, height: panelHeight } = resolvePanelSize(
+ safePanel,
+ state.lastSize,
+ );
+
+ if (!panelWidth && !panelHeight) {
+ return;
+ }
+
+ state.lastSize = { width: panelWidth, height: panelHeight };
+
+ const viewport = getViewportSize(windowRef, documentRef);
+
+ const placementsToTry = [config.placement];
+ if (config.flip !== false) {
+ const opposite = OPPOSITE_PLACEMENT[config.placement];
+ if (opposite) {
+ placementsToTry.push(opposite);
+ }
+ if (config.placement === "bottom" || config.placement === "top") {
+ placementsToTry.push("right", "left");
+ } else {
+ placementsToTry.push("bottom", "top");
+ }
+ }
+
+ let chosenPlacement = config.placement;
+ let chosenPosition = computePosition({
+ placement: chosenPlacement,
+ alignment: config.alignment,
+ anchorRect,
+ panelSize: [panelWidth, panelHeight],
+ offset: config.offset,
+ rtl,
+ });
+
+ const fitsViewport = ({ top, left }) => {
+ const withinVertical =
+ top >= config.viewportPadding &&
+ top + panelHeight <= viewport.height - config.viewportPadding;
+ const withinHorizontal =
+ left >= config.viewportPadding &&
+ left + panelWidth <= viewport.width - config.viewportPadding;
+ return withinVertical && withinHorizontal;
+ };
+
+ for (const placement of placementsToTry) {
+ const position = computePosition({
+ placement,
+ alignment: config.alignment,
+ anchorRect,
+ panelSize: [panelWidth, panelHeight],
+ offset: config.offset,
+ rtl,
+ });
+
+ if (fitsViewport(position)) {
+ chosenPlacement = placement;
+ chosenPosition = position;
+ break;
+ }
+ }
+
+ let { top, left } = chosenPosition;
+
+ const maxTop = Math.max(
+ config.viewportPadding,
+ viewport.height - config.viewportPadding - panelHeight,
+ );
+ const maxLeft = Math.max(
+ config.viewportPadding,
+ viewport.width - config.viewportPadding - panelWidth,
+ );
+
+ top = clamp(top, config.viewportPadding, maxTop);
+ left = clamp(left, config.viewportPadding, maxLeft);
+
+ if (config.strategy === "absolute") {
+ const offsetParent = safePanel.offsetParent || safePanel.parentElement || documentRef.body;
+ const parentRect = offsetParent?.getBoundingClientRect
+ ? offsetParent.getBoundingClientRect()
+ : { top: 0, left: 0 };
+ const scrollTop = offsetParent?.scrollTop || 0;
+ const scrollLeft = offsetParent?.scrollLeft || 0;
+ top = top - parentRect.top + scrollTop;
+ left = left - parentRect.left + scrollLeft;
+ }
+
+ applyPosition({ top, left, placement: chosenPlacement });
+ };
+
+ const handleScroll = () => update();
+ const handleResize = () => update();
+
+ if (typeof windowRef?.addEventListener === "function") {
+ windowRef.addEventListener("scroll", handleScroll, true);
+ windowRef.addEventListener("resize", handleResize);
+ state.cleanup.push(() => {
+ windowRef.removeEventListener("scroll", handleScroll, true);
+ windowRef.removeEventListener("resize", handleResize);
+ });
+ }
+
+ if (typeof windowRef?.ResizeObserver === "function") {
+ const resizeObserver = new windowRef.ResizeObserver(() => update());
+ try {
+ resizeObserver.observe(safeAnchor);
+ resizeObserver.observe(safePanel);
+ } catch (error) {
+ // Ignore observation errors for detached elements.
+ }
+ state.cleanup.push(() => {
+ resizeObserver.disconnect();
+ });
+ }
+
+ // Schedule an initial measurement on the next frame to avoid layout thrash
+ // when the caller opens the panel synchronously.
+ if (typeof windowRef?.requestAnimationFrame === "function") {
+ windowRef.requestAnimationFrame(() => update());
+ } else {
+ update();
+ }
+
+ return {
+ update,
+ destroy() {
+ while (state.cleanup.length) {
+ const fn = state.cleanup.pop();
+ try {
+ fn();
+ } catch (error) {
+ // Ignore cleanup errors to avoid breaking consumer teardown flows.
+ }
+ }
+ },
+ };
+}
+
+export function destroyFloatingPanel(positioner) {
+ if (positioner && typeof positioner.destroy === "function") {
+ positioner.destroy();
+ }
+}
+
+export function updateFloatingPanel(positioner) {
+ if (positioner && typeof positioner.update === "function") {
+ positioner.update();
+ }
+}
+
+export default positionFloatingPanel;
diff --git a/tests/position-floating-panel.test.mjs b/tests/position-floating-panel.test.mjs
new file mode 100644
index 00000000..60839bd7
--- /dev/null
+++ b/tests/position-floating-panel.test.mjs
@@ -0,0 +1,190 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { JSDOM } from "jsdom";
+import positionFloatingPanel from "../js/ui/utils/positionFloatingPanel.js";
+
+test("flips to top when bottom placement collides with viewport", () => {
+ const dom = new JSDOM(
+ `
+
+ `,
+ { pretendToBeVisual: true },
+ );
+ const { window } = dom;
+ const { document } = window;
+
+ window.innerWidth = 360;
+ window.innerHeight = 320;
+
+ const trigger = document.getElementById("trigger");
+ const panel = document.getElementById("panel");
+
+ trigger.getBoundingClientRect = () => ({
+ top: 260,
+ bottom: 300,
+ left: 100,
+ right: 140,
+ width: 40,
+ height: 40,
+ });
+
+ panel.getBoundingClientRect = () => ({
+ top: 0,
+ bottom: 120,
+ left: 0,
+ right: 200,
+ width: 200,
+ height: 120,
+ });
+ Object.defineProperties(panel, {
+ offsetWidth: { value: 200 },
+ offsetHeight: { value: 120 },
+ });
+
+ const positioner = positionFloatingPanel(trigger, panel, {
+ offset: 8,
+ viewportPadding: 12,
+ });
+
+ panel.hidden = false;
+ panel.dataset.state = "open";
+ positioner.update();
+
+ assert.equal(panel.dataset.placement, "top");
+ assert.equal(panel.style.top, "132px");
+ assert.equal(panel.style.left, "100px");
+
+ positioner.destroy();
+});
+
+test("respects RTL alignment and clamps within the viewport", () => {
+ const dom = new JSDOM(
+ `
+
+ `,
+ { pretendToBeVisual: true, url: "https://example.com" },
+ );
+ const { window } = dom;
+ const { document } = window;
+
+ window.innerWidth = 320;
+ window.innerHeight = 240;
+ document.documentElement.dir = "rtl";
+ document.body.dir = "rtl";
+
+ const trigger = document.getElementById("trigger");
+ const panel = document.getElementById("panel");
+
+ trigger.getBoundingClientRect = () => ({
+ top: 32,
+ bottom: 72,
+ left: 220,
+ right: 260,
+ width: 40,
+ height: 40,
+ });
+
+ panel.getBoundingClientRect = () => ({
+ top: 0,
+ bottom: 80,
+ left: 0,
+ right: 120,
+ width: 120,
+ height: 80,
+ });
+ Object.defineProperties(panel, {
+ offsetWidth: { value: 120 },
+ offsetHeight: { value: 80 },
+ });
+
+ const positioner = positionFloatingPanel(trigger, panel, {
+ alignment: "start",
+ viewportPadding: 16,
+ });
+
+ panel.hidden = false;
+ panel.dataset.state = "open";
+ positioner.update();
+
+ // In RTL mode, start alignment hugs the right edge of the trigger.
+ assert.equal(panel.style.left, "140px");
+ assert.equal(panel.dataset.placement, "bottom");
+
+ positioner.destroy();
+});
+
+test("updates when scroll containers move the trigger", async () => {
+ const dom = new JSDOM(
+ `
+
+ `,
+ { pretendToBeVisual: true },
+ );
+ const { window } = dom;
+ const { document } = window;
+
+ window.innerWidth = 480;
+ window.innerHeight = 360;
+
+ const trigger = document.getElementById("trigger");
+ const panel = document.getElementById("panel");
+
+ let anchorTop = 100;
+ trigger.getBoundingClientRect = () => ({
+ top: anchorTop,
+ bottom: anchorTop + 40,
+ left: 40,
+ right: 80,
+ width: 40,
+ height: 40,
+ });
+
+ panel.getBoundingClientRect = () => ({
+ top: 0,
+ bottom: 60,
+ left: 0,
+ right: 180,
+ width: 180,
+ height: 60,
+ });
+ Object.defineProperties(panel, {
+ offsetWidth: { value: 180 },
+ offsetHeight: { value: 60 },
+ });
+
+ let scrollHandler = null;
+ const originalAddEventListener = window.addEventListener;
+ window.addEventListener = (type, listener, options) => {
+ if (type === "scroll" && options === true) {
+ scrollHandler = listener;
+ }
+ return originalAddEventListener.call(window, type, listener, options);
+ };
+
+ const positioner = positionFloatingPanel(trigger, panel, {
+ offset: 12,
+ });
+
+ panel.hidden = false;
+ panel.dataset.state = "open";
+ positioner.update();
+
+ assert.equal(panel.style.top, "152px");
+ assert.ok(scrollHandler, "scroll listener should be registered");
+
+ anchorTop = 60;
+ scrollHandler?.({ type: "scroll" });
+ assert.equal(panel.style.top, "112px");
+
+ positioner.destroy();
+ window.addEventListener = originalAddEventListener;
+});