Files
rio/frontend/code/popupManager.ts
2024-07-25 20:46:59 +02:00

351 lines
10 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';
// Given the anchor and content, return CSS values to apply to the content
type PopupPositioner = (
anchor: HTMLElement,
content: HTMLElement
) => { [key: string]: string };
export function positionFullscreen(
anchor: HTMLElement,
content: HTMLElement
): { [key: string]: string } {
let margin = pixelsPerRem;
return {
left: `${margin}px`,
top: `${margin}px`,
width: `${window.innerWidth - 2 * margin}px`,
height: `${window.innerHeight - 2 * margin}px`,
};
}
// 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.)
export 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;
}): { [key: string]: string } {
// Where would we like the content to be?
let anchorRect = anchor.getBoundingClientRect();
let anchorPointX = anchorRect.left + anchorRect.width * anchorRelativeX;
let anchorPointY = anchorRect.top + anchorRect.height * anchorRelativeY;
console.debug(anchor, content);
console.debug(
anchorRelativeX,
anchorRelativeY,
contentRelativeX,
contentRelativeY,
fixedOffsetXRem,
fixedOffsetYRem
);
let contentPointX = content.scrollWidth * contentRelativeX;
let contentPointY = content.scrollHeight * contentRelativeY;
let contentLeft =
anchorPointX - contentPointX + fixedOffsetXRem * pixelsPerRem;
let contentTop =
anchorPointY - contentPointY + fixedOffsetYRem * pixelsPerRem;
// Calculate the position of the popup
// 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 limits
// contentLeft = Math.min(Math.max(contentLeft, minX), maxX);
// contentTop = Math.min(Math.max(contentTop, minY), maxY);
// Position & size the popup
return {
left: `${contentLeft}px`,
top: `${contentTop}px`,
};
}
export function makePositionLeft(
gap: number,
alignment: number
): (anchor: HTMLElement, content: HTMLElement) => { [key: string]: string } {
function result(
anchor: HTMLElement,
content: HTMLElement
): { [key: string]: string } {
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) => { [key: string]: string } {
function result(
anchor: HTMLElement,
content: HTMLElement
): { [key: string]: string } {
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) => { [key: string]: string } {
function result(
anchor: HTMLElement,
content: HTMLElement
): { [key: string]: string } {
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) => { [key: string]: string } {
function result(
anchor: HTMLElement,
content: HTMLElement
): { [key: string]: string } {
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
): { [key: string]: string } {
return 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) => { [key: string]: string } {
function result(
anchor: HTMLElement,
content: HTMLElement
): { [key: string]: string } {
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',
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;
}
throw new Error(`Invalid position: ${position}`);
}
/// Will always be on top of everything else.
export class PopupManager {
private anchor: HTMLElement;
private content: 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;
constructor(
anchor: HTMLElement,
content: HTMLElement,
positioner: PopupPositioner
) {
this.anchor = anchor;
this.content = content;
this.positioner = positioner;
// Prepare the content
this.content.classList.add('rio-popup-manager-content'); // `rio-popup` is taken by the `Popup` component
// We can't remove the element from the DOM when the popup is closed
// because we want to support custom animations and we don't know how
// long the animations are. So we'll simply leave the element in the DOM
// permanently.
document.body.appendChild(this.content);
}
destroy(): void {
this.content.remove();
}
get isOpen(): boolean {
return this.content.classList.contains('rio-popup-manager-open');
}
set isOpen(open: boolean) {
// Add or remove the CSS class. This can be used by users of the popup
// manager to trigger animations.
this.content.classList.toggle('rio-popup-manager-open', open);
// If just hiding the content, we're done.
if (!open) {
return;
}
// Determine the size of the screen
let screenWidth = window.innerWidth;
let screenHeight = window.innerHeight;
// Clear any previously assigned CSS attributes
this.content.style.cssText = '';
// Have the positioner place the popup
let cssAttributes = this.positioner(this.anchor, this.content);
Object.assign(this.content.style, cssAttributes);
}
}