mirror of
https://github.com/rio-labs/rio.git
synced 2026-01-07 13:49:51 -06:00
351 lines
10 KiB
TypeScript
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);
|
|
}
|
|
}
|