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; +});