Files
rio/frontend/code/popupManager.ts
T
2024-12-10 22:37:28 +01:00

596 lines
19 KiB
TypeScript

/// Helper class for creating pop-up elements.
///
/// Many components need to only display an element on occasion, and have it
/// hover over the rest of the page. This is surprisingly difficult to do,
/// because adding elements right in the HTML tree can cause them to be cut off
/// by `overflow: hidden`, or other elements with a higher index. A simple
/// `z-index` doesn't fix this either.
///
/// This class instead functions by adding the content close to the HTML root,
/// and programmatically moves it to the right place.
///
/// While open, the content is assigned the CSS class `rio-popup-manager-open`.
///
/// The popup manager may assign classes or CSS to both `content` and `anchor`.
/// This means you can't pass in a rio component directly (since they could move
/// outside of the manager in the future, leaving them tainted). If you need to
/// pass a rio component, wrap it in a div.
import { pixelsPerRem } from "./app";
import { commitCss } from "./utils";
// Given the anchor and content, return where to position the content.
type PopupPositioner = (anchor: HTMLElement, content: HTMLElement) => void;
export function positionFullscreen(
anchor: HTMLElement,
content: HTMLElement
): void {
content.style.left = "0";
content.style.top = "0";
content.style.width = "100%";
content.style.height = "100%";
content.style.borderRadius = "0";
}
export function positionDropdown(
anchor: HTMLElement,
content: HTMLElement
): void {
// Get some information about achor & content
let anchorRect = anchor.getBoundingClientRect();
console.log("position", content.scrollHeight, content.offsetHeight);
console.log(content.style.cssText);
console.log(this.shadeElement.classList);
console.log(content.classList);
let contentHeight = content.scrollHeight;
let windowWidth = window.innerWidth - 1; // innerWidth is rounded
let windowHeight = window.innerHeight - 1; // innerHeight is rounded
const DESKTOP_WINDOW_MARGIN = 0.5 * pixelsPerRem;
const GAP_IF_ENTIRELY_ABOVE = 0.5 * pixelsPerRem;
// CSS classes are used to communicate which of the different layouts is
// used. Remove them all first.
content.classList.remove(
"rio-dropdown-popup-mobile-fullscreen",
"rio-dropdown-popup-above",
"rio-dropdown-popup-below",
"rio-dropdown-popup-above-and-below"
);
commitCss(content);
// On small screens, such as phones, go fullscreen
//
// TODO: Adjust these thresholds. Maybe have a global variable which
// keeps track of whether we're on mobile?
if (windowWidth < 60 * pixelsPerRem || windowHeight < 40 * pixelsPerRem) {
content.style.left = "0";
content.style.top = "0";
content.style.right = "0";
content.style.bottom = "0";
content.classList.add("rio-dropdown-popup-mobile-fullscreen");
return;
}
// Popup is larger than the window. Give it all the space that's available.
if (contentHeight >= windowHeight - 2 * DESKTOP_WINDOW_MARGIN) {
let heightCss = `calc(100vh - ${2 * DESKTOP_WINDOW_MARGIN}px)`;
content.style.left = `${anchorRect.left}px`;
content.style.top = `${DESKTOP_WINDOW_MARGIN}px`;
content.style.width = `${anchorRect.width}px`;
content.style.height = heightCss;
content.style.maxHeight = heightCss;
content.style.overflowY = "auto";
content.classList.add("rio-dropdown-popup-above-and-below");
return;
}
// Popup fits below the dropdown
if (
anchorRect.bottom + contentHeight + DESKTOP_WINDOW_MARGIN <=
windowHeight
) {
content.style.left = `${anchorRect.left}px`;
content.style.top = `${anchorRect.bottom}px`;
content.style.width = `${anchorRect.width}px`;
content.style.height = `min-content`;
content.style.maxHeight = `${contentHeight}px`;
content.style.borderTopLeftRadius = "0";
content.style.borderTopRightRadius = "0";
content.classList.add("rio-dropdown-popup-below");
}
// Popup fits above the dropdown
else if (
anchorRect.top - contentHeight >=
GAP_IF_ENTIRELY_ABOVE + DESKTOP_WINDOW_MARGIN
) {
content.style.left = `${anchorRect.left}px`;
content.style.top = `${
anchorRect.top - contentHeight - GAP_IF_ENTIRELY_ABOVE
}px`;
content.style.width = `${anchorRect.width}px`;
content.style.height = `${contentHeight}px`;
content.style.maxHeight = `${contentHeight}px`;
content.classList.add("rio-dropdown-popup-above");
}
// Popup doesn't fit above or below the dropdown. Center it as much
// as possible
else {
let top = anchorRect.top + anchorRect.height / 2 - contentHeight / 2;
// It looks ugly if the dropdown touches the border of the window, so
// enforce a small margin on the top and the bottom
if (top < DESKTOP_WINDOW_MARGIN) {
top = DESKTOP_WINDOW_MARGIN;
} else if (top + contentHeight + DESKTOP_WINDOW_MARGIN > windowHeight) {
top = windowHeight - contentHeight - DESKTOP_WINDOW_MARGIN;
}
content.style.left = `${anchorRect.left}px`;
content.style.top = `${top}px`;
content.style.width = `${anchorRect.width}px`;
content.style.height = `${contentHeight}px`;
content.style.maxHeight = `${contentHeight}px`;
content.classList.add("rio-dropdown-popup-above-and-below");
}
}
// The popup location is defined in developer-friendly this function takes
// a couple floats instead:
//
// - Anchor point X & Y (relative)
// - Content point X & Y (relative)
// - Offset X & Y (absolute)
//
// The popup will appear, uch that the popup point is placed exactly at the
// anchor point. (But never off the screen.)
function positionOnSide({
anchor,
content,
anchorRelativeX,
anchorRelativeY,
contentRelativeX,
contentRelativeY,
fixedOffsetXRem,
fixedOffsetYRem,
}: {
anchor: HTMLElement;
content: HTMLElement;
anchorRelativeX: number;
anchorRelativeY: number;
contentRelativeX: number;
contentRelativeY: number;
fixedOffsetXRem: number;
fixedOffsetYRem: number;
}): void {
// Where would we like the content to be?
let anchorRect = anchor.getBoundingClientRect();
let contentWidth = content.scrollWidth;
let contentHeight = content.scrollHeight;
let anchorPointX = anchorRect.left + anchorRect.width * anchorRelativeX;
let anchorPointY = anchorRect.top + anchorRect.height * anchorRelativeY;
let contentPointX = contentWidth * contentRelativeX;
let contentPointY = contentHeight * contentRelativeY;
let contentLeft =
anchorPointX - contentPointX + fixedOffsetXRem * pixelsPerRem;
let contentTop =
anchorPointY - contentPointY + fixedOffsetYRem * pixelsPerRem;
// Establish limits, so the popup doesn't go off the screen. This is
// relative to the popup's top left corner.
let screenWidth = window.innerWidth;
let screenHeight = window.innerHeight;
let margin = 1 * pixelsPerRem;
let minX = margin;
let maxX = screenWidth - contentWidth - margin;
let minY = margin;
let maxY = screenHeight - contentHeight - margin;
// Enforce the limits
contentLeft = Math.min(Math.max(contentLeft, minX), maxX);
contentTop = Math.min(Math.max(contentTop, minY), maxY);
// Debug display
// let div = document.createElement("div");
// document.body.appendChild(div);
// div.style.backgroundColor = "red";
// div.style.position = "fixed";
// div.style.left = `${anchorRect.left}px`;
// div.style.top = `${anchorRect.top}px`;
// div.style.width = `${anchorRect.width}px`;
// div.style.height = `${anchorRect.height}px`;
// div.style.left = `${contentLeft}px`;
// div.style.top = `${contentTop}px`;
// div.style.width = `${contentWidth}px`;
// div.style.height = `${contentHeight}px`;
// Position & size the popup
content.style.left = `${contentLeft}px`;
content.style.top = `${contentTop}px`;
// content.style.width = `${contentWidth}px`;
// content.style.height = `${contentHeight}px`;
}
export function makePositionLeft(
gap: number,
alignment: number
): (anchor: HTMLElement, content: HTMLElement) => void {
function result(anchor: HTMLElement, content: HTMLElement): void {
return positionOnSide({
anchor: anchor,
content: content,
anchorRelativeX: 0,
anchorRelativeY: alignment,
contentRelativeX: 1,
contentRelativeY: 1 - alignment,
fixedOffsetXRem: -gap,
fixedOffsetYRem: 0,
});
}
return result;
}
export function makePositionTop(
gap: number,
alignment: number
): (anchor: HTMLElement, content: HTMLElement) => void {
function result(anchor: HTMLElement, content: HTMLElement): void {
return positionOnSide({
anchor: anchor,
content: content,
anchorRelativeX: alignment,
anchorRelativeY: 0,
contentRelativeX: 1 - alignment,
contentRelativeY: 1,
fixedOffsetXRem: 0,
fixedOffsetYRem: -gap,
});
}
return result;
}
export function makePositionRight(
gap: number,
alignment: number
): (anchor: HTMLElement, content: HTMLElement) => void {
function result(anchor: HTMLElement, content: HTMLElement): void {
return positionOnSide({
anchor: anchor,
content: content,
anchorRelativeX: 1,
anchorRelativeY: alignment,
contentRelativeX: 0,
contentRelativeY: 1 - alignment,
fixedOffsetXRem: gap,
fixedOffsetYRem: 0,
});
}
return result;
}
export function makePositionBottom(
gap: number,
alignment: number
): (anchor: HTMLElement, content: HTMLElement) => void {
function result(anchor: HTMLElement, content: HTMLElement): void {
return positionOnSide({
anchor: anchor,
content: content,
anchorRelativeX: alignment,
anchorRelativeY: 1,
contentRelativeX: 1 - alignment,
contentRelativeY: 0,
fixedOffsetXRem: 0,
fixedOffsetYRem: gap,
});
}
return result;
}
export function positionCenter(
anchor: HTMLElement,
content: HTMLElement
): void {
positionOnSide({
anchor: anchor,
content: content,
anchorRelativeX: 0.5,
anchorRelativeY: 0.5,
contentRelativeX: 0.5,
contentRelativeY: 0.5,
fixedOffsetXRem: 0,
fixedOffsetYRem: 0,
});
}
export function makePositionerAuto(
gap: number,
alignment: number
): (anchor: HTMLElement, content: HTMLElement) => void {
function result(anchor: HTMLElement, content: HTMLElement): void {
let screenWidth = window.innerWidth;
let screenHeight = window.innerHeight;
let anchorRect = anchor.getBoundingClientRect();
let relX = (anchorRect.left + anchor.scrollWidth) / 2 / screenWidth;
let relY = (anchorRect.top + anchor.scrollHeight) / 2 / screenHeight;
let positioner;
if (relX < 0.2) {
positioner = makePositionRight(gap, alignment);
} else if (relX > 0.8) {
positioner = makePositionLeft(gap, alignment);
} else if (relY < 0.2) {
positioner = makePositionBottom(gap, alignment);
} else {
positioner = makePositionTop(gap, alignment);
}
return positioner(anchor, content);
}
return result;
}
export function getPositionerByName(
position:
| "left"
| "top"
| "right"
| "bottom"
| "center"
| "auto"
| "fullscreen"
| "dropdown",
gap: number,
alignment: number
): PopupPositioner {
switch (position) {
case "left":
return makePositionLeft(gap, alignment);
case "top":
return makePositionTop(gap, alignment);
case "right":
return makePositionRight(gap, alignment);
case "bottom":
return makePositionBottom(gap, alignment);
case "center":
return positionCenter;
case "auto":
return makePositionerAuto(gap, alignment);
case "fullscreen":
return positionFullscreen;
case "dropdown":
return positionDropdown;
}
throw new Error(`Invalid position: ${position}`);
}
/// Will always be on top of everything else.
export class PopupManager {
private anchor: HTMLElement;
private content: HTMLElement;
private _userClosable: boolean;
/// Inform the outside world when the popup was closed by the user, rather
/// than programmatically.
public onUserClose?: () => void;
/// Used to darken the screen if the popup is modal.
private shadeElement: HTMLElement;
/// Where the pop-up should be positioned relative to the anchor.
///
/// This is taken as a hint, but can be ignored if there isn't enough space
/// to fit the pop-up at that location.
public positioner: PopupPositioner;
/// Listen for interactions with the outside world, so they can close the
/// popup if user-closable.
private clickHandler: ((event: MouseEvent) => void) | null = null;
private scrollHandler: ((event: Event) => void) | null = null;
constructor({
anchor,
content,
positioner,
modal,
userClosable,
onUserClose,
}: {
anchor: HTMLElement;
content: HTMLElement;
positioner: PopupPositioner;
modal: boolean;
userClosable: boolean;
onUserClose?: () => void;
}) {
// Configure the popup
this.anchor = anchor;
this.content = content;
this.positioner = positioner;
this.onUserClose = onUserClose;
// Prepare the HTML
this.shadeElement = document.createElement("div");
this.shadeElement.classList.add("rio-popup-manager-shade");
this.shadeElement.appendChild(this.content);
// Call the setters last, as they might expect the instance to be
// initialized
this.modal = modal;
this.userClosable = userClosable;
}
private removeEventHandlers(): void {
if (this.clickHandler !== null) {
window.removeEventListener("click", this.clickHandler, true);
}
if (this.scrollHandler !== null) {
window.removeEventListener("scroll", this.scrollHandler, true);
}
}
destroy(): void {
this.removeEventHandlers();
this.shadeElement.remove();
}
private _positionContent(): void {
// Clear any previously assigned CSS attributes
this.content.style.cssText = "";
// Run the positioner
this.positioner(this.anchor, this.content);
}
private _onPointerDown(event: MouseEvent): void {
// If the popup isn't user-closable or not even open, there's nothing
// to do
if (!this.userClosable || !this.isOpen) {
return;
}
// Check if the interaction was with the anchor or its children. This
// allows the anchor to decide its own behavior.
if (this.anchor.contains(event.target as Node)) {
return;
}
// Check if the interaction was with the popup or its children
if (this.content.contains(event.target as Node)) {
return;
}
// Otherwise, close the popup
this.isOpen = false;
// Tell the outside world
if (this.onUserClose !== undefined) {
this.onUserClose();
}
// Don't consider the event to be handled. Any clicks should still do
// whatever they were going to do. The exception here are modal popups,
// but the modal shade already takes care of that.
}
private _onScroll(event: Event): void {
// Re-position the content
this._positionContent();
}
get isOpen(): boolean {
return this.shadeElement.classList.contains("rio-popup-manager-open");
}
set isOpen(open: boolean) {
// Do nothing if the state hasn't changed. This can avoid some
// unexpected CSS behavior when opening/closing a popup multiple times
// in a row.
//
// For example, say the animation sets a property to `0` when the popup
// is closed. Opening commits CSS and sets the value larger, thus
// initiating an animation. If however opening happens twice in a row,
// the larger value is already set **and committed again**, bypassing
// the animation.
if (this.isOpen === open) {
return;
}
// Make sure the popup is in the DOM. We can't rely on it being here or
// not, because the popup manager could've been rapidly reopened, before
// the element was removed. Be defensive.
if (this.shadeElement.parentElement === null) {
let overlaysContainer = document.querySelector(
".rio-overlays-container"
)!;
overlaysContainer.appendChild(this.shadeElement);
commitCss(this.shadeElement);
}
// Add or remove the CSS class. This can be used by users of the popup
// manager to trigger animations.
this.shadeElement.classList.toggle("rio-popup-manager-open", open);
// Closing the popup can skip most of the code
if (!open) {
this.removeEventHandlers();
// ... but needs to remove the element after the animation has
// completed. This makes sure it can't be clicked after it's gone.
//
// Since the animation may take any amount of time, go somewhat over
// the top with the wait time.
setTimeout(() => this.delayedRemove(), 1000);
return;
}
// Register event handlers, if needed
if (this.userClosable) {
let clickHandler = this._onPointerDown.bind(this);
this.clickHandler = clickHandler; // Shuts up the type checker
window.addEventListener("pointerdown", clickHandler, true);
}
let scrollHandler = this._onScroll.bind(this);
this.scrollHandler = scrollHandler; // Shuts up the type checker
window.addEventListener("scroll", scrollHandler, true);
// Position the content
this._positionContent();
}
/// Entirely removes the element from the DOM. This prevents it from being
/// clicked after animated out and is intended to be called after some
/// delay.
delayedRemove(): void {
// Make sure the manager hasn't been re-opened while waiting
if (this.isOpen) {
return;
}
// The door's over there. Don't forget your hat.
this.shadeElement.remove();
}
set modal(modal: boolean) {
this.shadeElement.classList.toggle("rio-popup-manager-modal", modal);
}
get userClosable(): boolean {
return this._userClosable;
}
set userClosable(userClosable: boolean) {
this._userClosable = userClosable;
}
}