mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 09:52:24 +00:00
Merge pull request #451 from PR0M3TH3AN/codex/create-shared-positioning-helper-with-animations
Add floating panel positioner and update popover consumers
This commit is contained in:
8 files changed
+747
-2
No files matched your search
@@ -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;
|
||||
|
||||
+25
-2
@@ -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
|
||||
<div class="popover">
|
||||
@@ -166,7 +166,30 @@ Use `.popover` as the relative anchor for floating content. Nest `.popover__pane
|
||||
</div>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
`
|
||||
<div class="popover">
|
||||
<button id="trigger">Open</button>
|
||||
<div id="panel" class="popover__panel" data-state="closed" hidden></div>
|
||||
</div>
|
||||
`,
|
||||
{ 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(
|
||||
`
|
||||
<div dir="rtl" class="popover">
|
||||
<button id="trigger">Open</button>
|
||||
<div id="panel" class="popover__panel" data-state="closed" hidden></div>
|
||||
</div>
|
||||
`,
|
||||
{ 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(
|
||||
`
|
||||
<div class="popover">
|
||||
<button id="trigger">Open</button>
|
||||
<div id="panel" class="popover__panel" data-state="closed" hidden></div>
|
||||
</div>
|
||||
`,
|
||||
{ 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;
|
||||
});
|
||||
Reference in new issue
Block a user