mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-28 06:10:01 -05:00
335 lines
10 KiB
TypeScript
335 lines
10 KiB
TypeScript
import * as React from "react";
|
|
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
|
|
|
|
const VIEWPORT_PADDING = 12;
|
|
const POSITION_PROPERTIES = [
|
|
"top",
|
|
"right",
|
|
"bottom",
|
|
"left",
|
|
"max-height",
|
|
"width",
|
|
];
|
|
|
|
/** Mirrors the `position-area` values the popovers declare in their CSS. */
|
|
export type AnchorPlacement =
|
|
| "top"
|
|
| "bottom"
|
|
| "right"
|
|
| "bottom start"
|
|
| "bottom end";
|
|
|
|
/** The `position-area` of the side each placement asks for, and of the opposite one. */
|
|
const POSITION_AREAS: Record<
|
|
AnchorPlacement,
|
|
{ preferred: string; flipped: string }
|
|
> = {
|
|
top: { preferred: "block-start", flipped: "block-end" },
|
|
bottom: { preferred: "block-end", flipped: "block-start" },
|
|
"bottom start": {
|
|
preferred: "block-end span-inline-end",
|
|
flipped: "block-start span-inline-end",
|
|
},
|
|
"bottom end": {
|
|
preferred: "block-end span-inline-start",
|
|
flipped: "block-start span-inline-start",
|
|
},
|
|
right: { preferred: "inline-end", flipped: "inline-start" },
|
|
};
|
|
|
|
/**
|
|
* Opens a popover on the side of its anchor that fits its content, keeping it
|
|
* there for as long as it stays open.
|
|
*
|
|
* Where CSS anchor positioning is supported it only pins the `position-area`
|
|
* and, with `constrainHeight`, caps the height in pixels (WebKit resolves a
|
|
* percentage `max-height` of an anchor-positioned box against nothing, so a
|
|
* long list would run past the viewport with nothing to scroll). The CSS
|
|
* handles the rest. `position-try-fallbacks` is deliberately not used:
|
|
* it flips only when a side overflows, so a popover capped to the space it has
|
|
* never flips, and on iOS 26 a popover carrying it locks up the page for good
|
|
* when it leaves the top layer during a navigation. Presumably an iOS 26 WebKit
|
|
* bug, so a pure CSS solution is worth retrying once it is fixed upstream, but
|
|
* verify it in the iOS simulator before deploying. Browsers without anchor
|
|
* positioning (Chrome < 125, Safari < 26, Firefox < 147), where the popover
|
|
* would land in the top left corner of the viewport, get positioned here in full.
|
|
*/
|
|
export function useAnchorPositioning({
|
|
isOpen,
|
|
popoverRef,
|
|
getAnchor,
|
|
placement = "bottom",
|
|
matchAnchorWidth = false,
|
|
constrainHeight = false,
|
|
}: {
|
|
isOpen: boolean;
|
|
popoverRef: React.RefObject<HTMLElement | null>;
|
|
getAnchor: () => Element | null;
|
|
placement?: AnchorPlacement;
|
|
/** Take the anchor's width, like the CSS `width: anchor-size(width)` does. */
|
|
matchAnchorWidth?: boolean;
|
|
/** Cap the height to the space on the chosen side. Only for popovers that scroll their content. */
|
|
constrainHeight?: boolean;
|
|
}) {
|
|
const getAnchorRef = React.useRef(getAnchor);
|
|
getAnchorRef.current = getAnchor;
|
|
|
|
useIsomorphicLayoutEffect(() => {
|
|
const popover = popoverRef.current;
|
|
if (!isOpen || !popover) return;
|
|
|
|
const anchorPositioned = CSS.supports("anchor-name: --a");
|
|
|
|
/** Picked on the first measurement, so growing or shrinking content cannot move the popover. */
|
|
let fitsPreferred: boolean | null = null;
|
|
|
|
const position = () => {
|
|
const anchor = getAnchorRef.current();
|
|
// a popover shown after this effect (a controlled one) measures as hidden
|
|
if (!anchor || !popover.matches(":popover-open")) return;
|
|
|
|
fitsPreferred ??= preferredSideFits(popover, anchor, placement);
|
|
const below = placement === "top" ? !fitsPreferred : fitsPreferred;
|
|
if (placement !== "right") {
|
|
popover.dataset.side = below ? "below" : "above";
|
|
}
|
|
|
|
if (anchorPositioned) {
|
|
const area = POSITION_AREAS[placement];
|
|
popover.style.setProperty(
|
|
"position-area",
|
|
fitsPreferred ? area.preferred : area.flipped,
|
|
);
|
|
if (constrainHeight) {
|
|
popover.style.setProperty(
|
|
"max-height",
|
|
px(availableHeight(popover, anchor, placement, below)),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
applyStyles(
|
|
popover,
|
|
positionStyles(popover, anchor, {
|
|
below,
|
|
placement,
|
|
matchAnchorWidth,
|
|
constrainHeight,
|
|
}),
|
|
);
|
|
};
|
|
position();
|
|
|
|
popover.addEventListener("toggle", position);
|
|
let contentObserver: ResizeObserver | undefined;
|
|
if (!anchorPositioned) {
|
|
// the popover is fixed, so it has to follow an anchor moved by scrolling
|
|
window.addEventListener("scroll", position, {
|
|
capture: true,
|
|
passive: true,
|
|
});
|
|
window.addEventListener("resize", position);
|
|
contentObserver = new ResizeObserver(position);
|
|
contentObserver.observe(popover);
|
|
}
|
|
|
|
return () => {
|
|
popover.removeEventListener("toggle", position);
|
|
window.removeEventListener("scroll", position, { capture: true });
|
|
window.removeEventListener("resize", position);
|
|
contentObserver?.disconnect();
|
|
delete popover.dataset.side;
|
|
popover.style.removeProperty("position-area");
|
|
applyStyles(popover, {});
|
|
};
|
|
}, [isOpen, popoverRef, placement, matchAnchorWidth, constrainHeight]);
|
|
}
|
|
|
|
/** Whether to keep to the side the placement asks for; the roomier one is taken when the content does not fit there. */
|
|
function preferredSideFits(
|
|
popover: HTMLElement,
|
|
anchor: Element,
|
|
placement: AnchorPlacement,
|
|
) {
|
|
const anchorRect = anchor.getBoundingClientRect();
|
|
const computed = getComputedStyle(popover);
|
|
|
|
if (placement === "right") {
|
|
const spaceInlineStart = anchorRect.left - VIEWPORT_PADDING;
|
|
const spaceInlineEnd =
|
|
window.innerWidth - anchorRect.right - VIEWPORT_PADDING;
|
|
const [preferred, other] =
|
|
computed.direction === "rtl"
|
|
? [spaceInlineStart, spaceInlineEnd]
|
|
: [spaceInlineEnd, spaceInlineStart];
|
|
const width = popover.getBoundingClientRect().width;
|
|
return width <= preferred || preferred >= other;
|
|
}
|
|
|
|
const { above, below } = spaceAroundAnchor(anchorRect, computed);
|
|
const [preferred, other] =
|
|
placement === "top" ? [above, below] : [below, above];
|
|
return naturalHeight(popover) <= preferred || preferred >= other;
|
|
}
|
|
|
|
/** The height the popover may take on the side it opened to. */
|
|
function availableHeight(
|
|
popover: HTMLElement,
|
|
anchor: Element,
|
|
placement: AnchorPlacement,
|
|
below: boolean,
|
|
) {
|
|
if (placement === "right") {
|
|
return Math.max(0, window.innerHeight - 2 * VIEWPORT_PADDING);
|
|
}
|
|
const space = spaceAroundAnchor(
|
|
anchor.getBoundingClientRect(),
|
|
getComputedStyle(popover),
|
|
);
|
|
return Math.max(0, below ? space.below : space.above);
|
|
}
|
|
|
|
/**
|
|
* Height each side of the anchor has for the popover, its margin and the
|
|
* viewport padding taken out. Above the anchor the sticky header
|
|
* (`--popover-boundary-top`) is the ceiling, not the top of the viewport.
|
|
*/
|
|
function spaceAroundAnchor(anchorRect: DOMRect, computed: CSSStyleDeclaration) {
|
|
return {
|
|
above:
|
|
anchorRect.top -
|
|
(Number.parseFloat(computed.getPropertyValue("--popover-boundary-top")) ||
|
|
0) -
|
|
VIEWPORT_PADDING -
|
|
Number.parseFloat(computed.marginBottom),
|
|
below:
|
|
window.innerHeight -
|
|
anchorRect.bottom -
|
|
VIEWPORT_PADDING -
|
|
Number.parseFloat(computed.marginTop),
|
|
};
|
|
}
|
|
|
|
/** The height the content wants, which the `max-height` capping it to one side's space hides. */
|
|
function naturalHeight(popover: HTMLElement) {
|
|
const capped = popover.style.maxHeight;
|
|
popover.style.setProperty("max-height", "none");
|
|
const height = popover.getBoundingClientRect().height;
|
|
if (capped) {
|
|
popover.style.setProperty("max-height", capped);
|
|
} else {
|
|
popover.style.removeProperty("max-height");
|
|
}
|
|
return height;
|
|
}
|
|
|
|
function positionStyles(
|
|
popover: HTMLElement,
|
|
anchor: Element,
|
|
{
|
|
below,
|
|
placement,
|
|
matchAnchorWidth,
|
|
constrainHeight,
|
|
}: {
|
|
below: boolean;
|
|
placement: AnchorPlacement;
|
|
matchAnchorWidth: boolean;
|
|
constrainHeight: boolean;
|
|
},
|
|
) {
|
|
const anchorRect = anchor.getBoundingClientRect();
|
|
const popoverRect = popover.getBoundingClientRect();
|
|
const computed = getComputedStyle(popover);
|
|
const isRtl = computed.direction === "rtl";
|
|
// the margins of the popover offset it from the inset it is given, which is
|
|
// the gap to the anchor in the block axis and drift to undo everywhere else
|
|
const marginTop = Number.parseFloat(computed.marginTop);
|
|
const marginLeft = Number.parseFloat(computed.marginLeft);
|
|
|
|
const width = matchAnchorWidth ? anchorRect.width : popoverRect.width;
|
|
|
|
const styles: Record<string, string> = matchAnchorWidth
|
|
? { width: px(anchorRect.width) }
|
|
: {};
|
|
|
|
if (placement === "right") {
|
|
// inline-end of the anchor, flipping over it like `flip-inline` does
|
|
const height = Math.max(popoverRect.height, popover.scrollHeight);
|
|
const spaceInlineStart = anchorRect.left - VIEWPORT_PADDING;
|
|
const spaceInlineEnd =
|
|
window.innerWidth - anchorRect.right - VIEWPORT_PADDING;
|
|
const [preferred, other] = isRtl
|
|
? [spaceInlineStart, spaceInlineEnd]
|
|
: [spaceInlineEnd, spaceInlineStart];
|
|
const towardsInlineEnd = width <= preferred || preferred >= other;
|
|
|
|
return {
|
|
...styles,
|
|
top: px(anchorRect.top + anchorRect.height / 2 - height / 2 - marginTop),
|
|
bottom: "auto",
|
|
...horizontalPlacement(
|
|
towardsInlineEnd !== isRtl ? anchorRect.right : anchorRect.left - width,
|
|
width,
|
|
marginLeft,
|
|
),
|
|
};
|
|
}
|
|
|
|
const alignedToAnchorLeft =
|
|
placement === "bottom start"
|
|
? !isRtl
|
|
: placement === "bottom end"
|
|
? isRtl
|
|
: null;
|
|
const left =
|
|
alignedToAnchorLeft === null
|
|
? anchorRect.left + anchorRect.width / 2 - width / 2
|
|
: alignedToAnchorLeft
|
|
? anchorRect.left
|
|
: anchorRect.right - width;
|
|
|
|
return {
|
|
...styles,
|
|
...(below
|
|
? { top: px(anchorRect.bottom), bottom: "auto" }
|
|
: { top: "auto", bottom: px(window.innerHeight - anchorRect.top) }),
|
|
...(constrainHeight
|
|
? { "max-height": px(availableHeight(popover, anchor, placement, below)) }
|
|
: {}),
|
|
...horizontalPlacement(left, width, marginLeft),
|
|
};
|
|
}
|
|
|
|
function horizontalPlacement(left: number, width: number, marginLeft: number) {
|
|
const rightmost = Math.max(
|
|
VIEWPORT_PADDING,
|
|
window.innerWidth - VIEWPORT_PADDING - width,
|
|
);
|
|
|
|
return {
|
|
left: px(
|
|
Math.min(Math.max(left, VIEWPORT_PADDING), rightmost) - marginLeft,
|
|
),
|
|
right: "auto",
|
|
};
|
|
}
|
|
|
|
/** Writes the positioning properties, clearing the ones the placement leaves out. */
|
|
function applyStyles(popover: HTMLElement, styles: Record<string, string>) {
|
|
for (const property of POSITION_PROPERTIES) {
|
|
const value = styles[property];
|
|
|
|
if (value === undefined) {
|
|
popover.style.removeProperty(property);
|
|
} else if (popover.style.getPropertyValue(property) !== value) {
|
|
popover.style.setProperty(property, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
function px(value: number) {
|
|
return `${Math.round(value)}px`;
|
|
}
|