dialogs now trap keyboard focus

This commit is contained in:
Aran-Fey
2025-03-30 00:32:19 +01:00
parent 28d192bc67
commit ffdd7015cb
10 changed files with 821 additions and 777 deletions

View File

@@ -3,7 +3,8 @@ import {
recursivelyDeleteComponent,
} from "../componentManagement";
import { ComponentId } from "../dataModels";
import { FullscreenPositioner, PopupManager } from "../popupManager";
import { PopupManager } from "../popupManager";
import { FullscreenPositioner } from "../popupPositioners";
import { callRemoteMethodDiscardResponse } from "../rpc";
import { ComponentBase, ComponentState, DeltaState } from "./componentBase";
@@ -41,6 +42,7 @@ export class DialogContainerComponent extends ComponentBase<DialogContainerState
positioner: new FullscreenPositioner(),
modal: true,
userClosable: true,
dialog: true,
onUserClose: this.onUserClose.bind(this),
});

View File

@@ -2,11 +2,12 @@ import { ComponentBase, DeltaState } from "./componentBase";
import { applyIcon } from "../designApplication";
import { InputBox, InputBoxStyle } from "../inputBox";
import { markEventAsHandled } from "../eventHandling";
import { DropdownPositioner, PopupManager } from "../popupManager";
import { PopupManager } from "../popupManager";
import {
KeyboardFocusableComponent,
KeyboardFocusableComponentState,
} from "./keyboardFocusableComponent";
import { DropdownPositioner } from "../popupPositioners";
export type DropdownState = KeyboardFocusableComponentState & {
_type_: "Dropdown-builtin";

View File

@@ -27,11 +27,15 @@ export abstract class KeyboardFocusableComponent<
let element = this.getElementForKeyboardFocus();
element.autofocus = true;
// `autofocus` doesn't work in dialogs (probably because they open
// with a delay), so we'll add our own delay.
// `autofocus` only works if the element is newly inserted into the
// document, so as an extra precaution, we'll also try to focus it
// with JS.
setTimeout(() => {
this.grabKeyboardFocus();
}, 100);
}, 50);
// Note about dialogs/popups: The `PopupManager` takes care of
// moving the keyboard focus to the dialog when it is opened.
}
}

View File

@@ -1,5 +1,6 @@
import { ComponentId } from "../dataModels";
import { FullscreenPositioner, PopupManager } from "../popupManager";
import { PopupManager } from "../popupManager";
import { FullscreenPositioner } from "../popupPositioners";
import { ComponentBase, ComponentState, DeltaState } from "./componentBase";
export type OverlayState = ComponentState & {
@@ -25,6 +26,7 @@ export class OverlayComponent extends ComponentBase<OverlayState> {
positioner: new FullscreenPositioner(),
modal: false,
userClosable: false,
moveKeyboardFocusInside: false,
});
requestAnimationFrame(() => {

View File

@@ -1,13 +1,13 @@
import { applySwitcheroo } from "../designApplication";
import { ColorSet, ComponentId } from "../dataModels";
import { ComponentBase, ComponentState, DeltaState } from "./componentBase";
import { PopupManager } from "../popupManager";
import { stopPropagation } from "../eventHandling";
import { componentsById } from "../componentManagement";
import {
DesktopDropdownPositioner,
getPositionerByName,
PopupManager,
} from "../popupManager";
import { stopPropagation } from "../eventHandling";
import { componentsById } from "../componentManagement";
} from "../popupPositioners";
export type PopupState = ComponentState & {
_type_: "Popup-builtin";

View File

@@ -1,6 +1,7 @@
import { ComponentId } from "../dataModels";
import { ComponentBase, ComponentState, DeltaState } from "./componentBase";
import { getPositionerByName, PopupManager } from "../popupManager";
import { PopupManager } from "../popupManager";
import { getPositionerByName } from "../popupPositioners";
export type TooltipState = ComponentState & {
_type_: "Tooltip-builtin";

View File

@@ -15,15 +15,11 @@
/// 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.
///
/// Note: I have experimented with using <dialog> elements, but the last opened
/// dialog is always on top, which is a deal breaker for us.
import {
RioAnimation,
RioAnimationGroup,
RioAnimationPlayback,
RioKeyframe,
RioKeyframeAnimation,
} from "./animations";
import { pixelsPerRem } from "./app";
import { RioAnimation, RioAnimationPlayback } from "./animations";
import {
componentsById,
getComponentByElement,
@@ -31,6 +27,7 @@ import {
} from "./componentManagement";
import { DialogContainerComponent } from "./components/dialogContainer";
import { markEventAsHandled } from "./eventHandling";
import { PopupPositioner } from "./popupPositioners";
import {
camelToKebab,
commitCss,
@@ -46,743 +43,13 @@ const enableSafariScrollingWorkaround = /^((?!chrome|android).)*safari/i.test(
navigator.userAgent
);
/// `PopupPositioner`s are responsible for setting the size and position of a
/// popup. They can also decide which animation should be played when the popup
/// is opened or closed.
export abstract class PopupPositioner {
/// If a popup is quickly opened and closed (or the other way round), the
/// animation may be interrupted partway. If that happens, we don't want the
/// popup to "snap" to its final state and then start playing the new
/// animation, we want to seamlessly switch from one animation to the other.
/// That's why animations should not hard-code a specific starting keyframe,
/// but rather start from the element's current state. The `initialCss` is
/// stored separately and is only applied to the element once, when the
/// PopupManager is created.
abstract getInitialCss(): RioKeyframe;
/// Positions the `content` in the `overlaysContainer` by updating its CSS
/// *AND* returns the animation for opening the popup
abstract positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation;
abstract getCloseAnimation(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation;
/// Called when the positioner is replaced with a different one
cleanup(content: HTMLElement): void {}
}
/// Most `PopupPositioner`s always use the same open/close animation, so this
/// class exists to make that more convenient.
abstract class PopupPositionerWithStaticAnimation extends PopupPositioner {
private initialCss: RioKeyframe;
private openAnimation: RioAnimation;
private closeAnimation: RioAnimation;
constructor(
initialCss: RioKeyframe,
finalCss: RioKeyframe,
options: KeyframeAnimationOptions
) {
super();
this.initialCss = initialCss;
this.openAnimation = new RioKeyframeAnimation([finalCss], options);
this.closeAnimation = new RioKeyframeAnimation([initialCss], options);
}
getInitialCss(): RioKeyframe {
return this.initialCss;
}
positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
this._positionContent(anchor, content, overlaysContainer);
return this.openAnimation;
}
abstract _positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): void;
getCloseAnimation(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
return this.closeAnimation;
}
}
export class FullscreenPositioner extends PopupPositionerWithStaticAnimation {
constructor() {
super(
{ transform: "translateY(-1rem)", opacity: "0" },
{ transform: "translateY(0)", opacity: "1" },
{
duration: 200,
easing: "ease-in-out",
}
);
}
_positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): void {
content.style.minWidth = "100%";
content.style.minHeight = "100%";
}
}
export class DropdownPositioner extends PopupPositioner {
private readonly positioner: PopupPositioner;
// I have absolutely no clue why this is standard, but on mobile devices
// dropdowns open up centered on the screen. I guess we'll decide based
// on whether it's a touchscreen device?
public static useMobileMode(): boolean {
if (!window.matchMedia("(pointer: coarse)").matches) {
return false;
}
// Laptops with a touchscreen will also reach this point, but mobile
// dropdowns look silly on a laptop. So we'll additionally check the
// screen size.
let screenSize =
Math.min(window.screen.width, window.screen.height) / pixelsPerRem;
return screenSize < 40;
}
constructor() {
super();
// Since mobile mode and desktop mode use completely different
// animations and CSS attributes, things would definitely go wrong if a
// popup were to switch from one mode to the other. So we'll select the
// mode *once* and stick to it.
if (DropdownPositioner.useMobileMode()) {
this.positioner = new MobileDropdownPositioner();
} else {
this.positioner = new DesktopDropdownPositioner();
}
}
getInitialCss(): RioKeyframe {
return this.positioner.getInitialCss();
}
positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
return this.positioner.positionContent(
anchor,
content,
overlaysContainer
);
}
getCloseAnimation(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
return this.positioner.getCloseAnimation(
anchor,
content,
overlaysContainer
);
}
}
export class MobileDropdownPositioner extends PopupPositioner {
private static OPEN_ANIMATION = new RioAnimationGroup([
new RioKeyframeAnimation(
[
{
transform: "scale(1)",
},
],
{
duration: 200,
easing: "ease-in-out",
}
),
new RioKeyframeAnimation(
[
{
opacity: "1",
},
],
{
duration: 100,
easing: "cubic-bezier(0.5, 0.5, 0.2, 1.14)",
}
),
]);
private static CLOSE_ANIMATION = new RioAnimationGroup([
new RioKeyframeAnimation(
[
{
transform: "scale(0)",
},
],
{
duration: 200,
easing: "ease-in-out",
}
),
new RioKeyframeAnimation(
[
{
opacity: "0",
},
],
{
duration: 200,
easing: "cubic-bezier(0.5, 0.5, 0.2, 1.14)",
}
),
]);
getInitialCss(): RioKeyframe {
return {
transform: "scale(0)",
opacity: "0",
};
}
positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
content.classList.add("rio-dropdown-popup-mobile-fullscreen");
let availableWidth = getAllocatedWidthInPx(overlaysContainer);
let availableHeight = getAllocatedHeightInPx(overlaysContainer);
let contentWidth = getAllocatedWidthInPx(content);
let contentHeight = getAllocatedHeightInPx(content);
let left = (availableWidth - contentWidth) / 2;
left = Math.max(left, 0);
let top = (availableHeight - contentHeight) / 2;
top = Math.max(top, 0);
content.style.left = `${left}px`;
content.style.top = `${top}px`;
// Assign a minimum width, otherwise it's easy to misclick on a
// touchscreen
content.style.minWidth = "10rem";
return MobileDropdownPositioner.OPEN_ANIMATION;
}
getCloseAnimation(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
return MobileDropdownPositioner.CLOSE_ANIMATION;
}
}
export class DesktopDropdownPositioner extends PopupPositioner {
positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
const WINDOW_MARGIN = 0.5 * pixelsPerRem;
const GAP_IF_ENTIRELY_ABOVE = 0.5 * pixelsPerRem;
// Get some information about achor & content
let anchorRect = getAnchorRectInContainer(anchor, overlaysContainer);
let availableHeight =
getAllocatedHeightInPx(overlaysContainer) - 2 * WINDOW_MARGIN;
// Remove the previously assigned dimensions before querying the content
// size. But since we need the `max-height` for our animation, we need
// to remember the current `max-height`.
let startHeight = content.style.maxHeight;
content.style.minWidth = "unset";
content.style.maxHeight = `${availableHeight}px`;
content.style.height = "unset";
let contentWidth = getAllocatedWidthInPx(content);
let contentHeight = getAllocatedHeightInPx(content);
// CSS classes are used to communicate which of the different layouts is
// used. Remove them all first.
this.removeCssClasses(content);
// Make sure the popup is at least as wide as the anchor, while still
// being able to resize itself in case its content changes
if (contentWidth < anchorRect.width) {
content.style.minWidth = `${anchorRect.width}px`;
}
let popupWidth = Math.max(contentWidth, anchorRect.width);
let left = anchorRect.left - (popupWidth - anchorRect.width) / 2;
content.style.left = `${left}px`;
// Popup is larger than the window. Give it all the space that's available.
if (contentHeight >= availableHeight - 2 * WINDOW_MARGIN) {
content.style.top = `${WINDOW_MARGIN}px`;
content.classList.add(
"rio-dropdown-popup-above-and-below",
"rio-dropdown-popup-scroll-y"
);
return this.makeOpenAnimation(startHeight, availableHeight);
}
// Popup fits below the dropdown
if (
anchorRect.bottom + contentHeight + WINDOW_MARGIN <=
availableHeight
) {
content.style.top = `${anchorRect.bottom}px`;
content.classList.add("rio-dropdown-popup-below");
}
// Popup fits above the dropdown
else if (
anchorRect.top - contentHeight >=
GAP_IF_ENTIRELY_ABOVE + WINDOW_MARGIN
) {
content.style.top = `${
anchorRect.top - contentHeight - GAP_IF_ENTIRELY_ABOVE
}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 < WINDOW_MARGIN) {
top = WINDOW_MARGIN;
} else if (top + contentHeight + WINDOW_MARGIN > availableHeight) {
top = availableHeight - contentHeight - WINDOW_MARGIN;
}
content.style.top = `${top}px`;
content.classList.add("rio-dropdown-popup-above-and-below");
}
return this.makeOpenAnimation(startHeight, contentHeight);
}
getInitialCss(): RioKeyframe {
return {
maxHeight: "0",
overflow: "hidden",
};
}
private makeOpenAnimation(
startHeight: string,
endHeight: number
): RioAnimation {
return new RioKeyframeAnimation(
[
{
maxHeight: startHeight,
},
// A fixed max-height would prevent the content from resizing
// itself, so we must remove the max-height at the end.
{
offset: 0.99999,
maxHeight: `${endHeight}px`,
},
{
maxHeight: "unset",
},
],
{ duration: 400, easing: "ease-in-out" }
);
}
getCloseAnimation(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
let currentHeight = getAllocatedHeightInPx(content);
return new RioKeyframeAnimation(
[{ maxHeight: `${currentHeight}px` }, { maxHeight: "0" }],
{
duration: 400,
easing: "ease-in-out",
}
);
}
cleanup(content: HTMLElement): void {
this.removeCssClasses(content);
}
private removeCssClasses(content: HTMLElement): void {
content.classList.remove(
"rio-dropdown-popup-above",
"rio-dropdown-popup-below",
"rio-dropdown-popup-above-and-below",
"rio-dropdown-popup-scroll-y"
);
}
}
class SidePositioner extends PopupPositioner {
public gap: number;
public alignment: number;
public anchorRelativeX: number;
public anchorRelativeY: number;
public contentRelativeX: number;
public contentRelativeY: number;
public fixedOffsetXRem: number;
public fixedOffsetYRem: number;
private static OPEN_ANIMATION = new RioAnimationGroup([
new RioKeyframeAnimation(
[
{
transform: "scale(1)",
},
],
{
duration: 200,
easing: "ease-in-out",
}
),
new RioKeyframeAnimation(
[
{
opacity: "1",
},
],
{
duration: 100,
easing: "cubic-bezier(0.5, 0.5, 0.2, 1.14)",
}
),
]);
private static CLOSE_ANIMATION = new RioAnimationGroup([
new RioKeyframeAnimation(
[
{
transform: "scale(0)",
},
],
{
duration: 200,
easing: "ease-in-out",
}
),
new RioKeyframeAnimation(
[
{
opacity: "0",
},
],
{
duration: 200,
easing: "cubic-bezier(0.5, 0.5, 0.2, 1.14)",
}
),
]);
// 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 be positioned such that the popup point is placed exactly
// at the anchor point. (But never off the screen.)
protected constructor({
anchorRelativeX,
anchorRelativeY,
contentRelativeX,
contentRelativeY,
fixedOffsetXRem,
fixedOffsetYRem,
}: {
anchorRelativeX: number;
anchorRelativeY: number;
contentRelativeX: number;
contentRelativeY: number;
fixedOffsetXRem: number;
fixedOffsetYRem: number;
}) {
super();
this.anchorRelativeX = anchorRelativeX;
this.anchorRelativeY = anchorRelativeY;
this.contentRelativeX = contentRelativeX;
this.contentRelativeY = contentRelativeY;
this.fixedOffsetXRem = fixedOffsetXRem;
this.fixedOffsetYRem = fixedOffsetYRem;
}
getInitialCss(): RioKeyframe {
return { transform: "scale(0)", opacity: "0" };
}
positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
let margin = 0.5 * pixelsPerRem;
let availableWidth =
getAllocatedWidthInPx(overlaysContainer) - 2 * margin;
let availableHeight =
getAllocatedHeightInPx(overlaysContainer) - 2 * margin;
// Where would we like the content to be?
let anchorRect = getAnchorRectInContainer(anchor, overlaysContainer);
let contentWidth = getAllocatedWidthInPx(content);
let contentHeight = getAllocatedHeightInPx(content);
let anchorPointX =
anchorRect.left + anchorRect.width * this.anchorRelativeX;
let anchorPointY =
anchorRect.top + anchorRect.height * this.anchorRelativeY;
let popupPointX = contentWidth * this.contentRelativeX;
let popupPointY = contentHeight * this.contentRelativeY;
let popupLeft: number, popupTop: number;
if (contentWidth >= availableWidth) {
popupLeft = margin;
} else {
popupLeft =
anchorPointX -
popupPointX +
this.fixedOffsetXRem * pixelsPerRem;
let minX = margin;
let maxX = minX + availableWidth - contentWidth;
popupLeft = Math.min(Math.max(popupLeft, minX), maxX);
}
if (contentHeight >= availableHeight) {
popupTop = margin;
} else {
popupTop =
anchorPointY -
popupPointY +
this.fixedOffsetYRem * pixelsPerRem;
let minY = margin;
let maxY = minY + availableHeight - contentHeight;
popupTop = Math.min(Math.max(popupTop, minY), maxY);
}
// Position & size the popup
content.style.left = `${popupLeft}px`;
content.style.top = `${popupTop}px`;
return SidePositioner.OPEN_ANIMATION;
}
getCloseAnimation(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
return SidePositioner.CLOSE_ANIMATION;
}
}
export class LeftPositioner extends SidePositioner {
constructor(gap: number, alignment: number) {
super({
anchorRelativeX: 0,
anchorRelativeY: alignment,
contentRelativeX: 1,
contentRelativeY: 1 - alignment,
fixedOffsetXRem: -gap,
fixedOffsetYRem: 0,
});
}
}
export class RightPositioner extends SidePositioner {
constructor(gap: number, alignment: number) {
super({
anchorRelativeX: 1,
anchorRelativeY: alignment,
contentRelativeX: 0,
contentRelativeY: 1 - alignment,
fixedOffsetXRem: gap,
fixedOffsetYRem: 0,
});
}
}
export class TopPositioner extends SidePositioner {
constructor(gap: number, alignment: number) {
super({
anchorRelativeX: alignment,
anchorRelativeY: 0,
contentRelativeX: 1 - alignment,
contentRelativeY: 1,
fixedOffsetXRem: 0,
fixedOffsetYRem: -gap,
});
}
}
export class BottomPositioner extends SidePositioner {
constructor(gap: number, alignment: number) {
super({
anchorRelativeX: alignment,
anchorRelativeY: 1,
contentRelativeX: 1 - alignment,
contentRelativeY: 0,
fixedOffsetXRem: 0,
fixedOffsetYRem: gap,
});
}
}
export class CenterPositioner extends SidePositioner {
constructor() {
super({
anchorRelativeX: 0.5,
anchorRelativeY: 0.5,
contentRelativeX: 0.5,
contentRelativeY: 0.5,
fixedOffsetXRem: 0,
fixedOffsetYRem: 0,
});
}
}
export class AutoSidePositioner extends PopupPositioner {
gap: number;
alignment: number;
constructor(gap: number, alignment: number) {
super();
this.gap = gap;
this.alignment = alignment;
}
getInitialCss(): RioKeyframe {
return new TopPositioner(0, 0).getInitialCss();
}
positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
let availableWidth = getAllocatedWidthInPx(overlaysContainer);
let availableHeight = getAllocatedHeightInPx(overlaysContainer);
let anchorRect = getAnchorRectInContainer(anchor, overlaysContainer);
let relX = (anchorRect.left + anchor.scrollWidth) / 2 / availableWidth;
let relY = (anchorRect.top + anchor.scrollHeight) / 2 / availableHeight;
let positioner: SidePositioner;
if (relX < 0.2) {
positioner = new RightPositioner(this.gap, this.alignment);
} else if (relX > 0.8) {
positioner = new LeftPositioner(this.gap, this.alignment);
} else if (relY < 0.2) {
positioner = new BottomPositioner(this.gap, this.alignment);
} else {
positioner = new TopPositioner(this.gap, this.alignment);
}
return positioner.positionContent(anchor, content, overlaysContainer);
}
getCloseAnimation(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
return new TopPositioner(0, 0).getCloseAnimation(
anchor,
content,
overlaysContainer
);
}
}
export function getPositionerByName(
position:
| "left"
| "top"
| "right"
| "bottom"
| "center"
| "auto"
| "fullscreen"
| "dropdown",
gap: number,
alignment: number
): PopupPositioner {
switch (position) {
case "left":
return new LeftPositioner(gap, alignment);
case "top":
return new TopPositioner(gap, alignment);
case "right":
return new RightPositioner(gap, alignment);
case "bottom":
return new BottomPositioner(gap, alignment);
case "center":
return new CenterPositioner();
case "auto":
return new AutoSidePositioner(gap, alignment);
case "fullscreen":
return new FullscreenPositioner();
case "dropdown":
return new DropdownPositioner();
}
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;
public dialog: boolean;
public moveKeyboardFocusInside: boolean;
/// Inform the outside world when the popup was closed by the user, rather
/// than programmatically.
@@ -813,7 +80,7 @@ export class PopupManager {
private currentAnimationPlayback: RioAnimationPlayback | null = null;
private focusTrap: ally.FocusTrap | null = null;
private focusTrap: any | null = null;
/// Listen for interactions with the outside world, so they can close the
/// popup if user-closable.
@@ -832,6 +99,8 @@ export class PopupManager {
positioner,
modal,
userClosable,
dialog,
moveKeyboardFocusInside,
onUserClose,
}: {
anchor: HTMLElement;
@@ -839,6 +108,8 @@ export class PopupManager {
positioner: PopupPositioner;
modal: boolean;
userClosable: boolean;
dialog?: boolean;
moveKeyboardFocusInside?: boolean;
onUserClose?: () => void;
}) {
// Configure the popup
@@ -869,6 +140,8 @@ export class PopupManager {
// initialized
this.modal = modal;
this.userClosable = userClosable;
this.dialog = dialog ?? false;
this.moveKeyboardFocusInside = moveKeyboardFocusInside ?? true;
}
public get anchor(): HTMLElement {
@@ -1123,19 +396,33 @@ export class PopupManager {
// Position the popup
let animation = this._positionContent();
// Fullscreen popups get special treatment in terms of keyboard focus
if (this.positioner instanceof FullscreenPositioner) {
ensureFocusIsInside(this.content);
// TODO: Trap the keyboard focus in the popup. Ideally by using a
// <dialog> element.
}
// Cancel the close animation, if it's still playing
if (this.currentAnimationPlayback !== null) {
this.currentAnimationPlayback.cancel();
}
// Set dialog metadata
if (this.dialog) {
this.content.role = "dialog";
this.content.ariaModal = this.modal ? "true" : "false";
} else {
this.content.removeAttribute("role");
this.content.removeAttribute("aria-modal");
}
// Move the keyboard focus inside the popup, if desired
if (this.moveKeyboardFocusInside) {
ensureFocusIsInside(this.content);
}
// If modal, trap the keyboard focus inside
console.log("modal?", this.modal, this.shadeElement);
if (this.modal) {
this.focusTrap = ally.maintain.tabFocus({
context: this.shadeElement,
});
}
// Start playing the popup animation. This may temporarily override the
// position/size of the popup.
//
@@ -1184,6 +471,12 @@ export class PopupManager {
private _closePopup(): void {
this.removeEventHandlers();
// Disengage the focus trap, if any
if (this.focusTrap !== null) {
this.focusTrap.disengage();
this.focusTrap = null;
}
// Fade out the shade
this.shadeElement.style.backgroundColor = "transparent";
@@ -1211,6 +504,10 @@ export class PopupManager {
this.shadeElement.classList.toggle("rio-popup-manager-modal", modal);
}
get modal(): boolean {
return this.shadeElement.classList.contains("rio-popup-manager-modal");
}
get userClosable(): boolean {
return this._userClosable;
}
@@ -1285,17 +582,6 @@ function findOverlaysContainer(anchor: HTMLElement): HTMLElement {
);
}
function getAnchorRectInContainer(
anchor: HTMLElement,
overlaysContainer: HTMLElement
): DOMRect {
// Assumptions made here:
// 1. The overlaysContainer is positioned at (0, 0) in the viewport
// 2. Neither element is affected by `filter: scale` (which would distort
// the relation between "CSS pixels" and "visible pixels")
return anchor.getBoundingClientRect();
}
function ensureFocusIsInside(element: HTMLElement): void {
// If the focus is already inside, do nothing
let focusedElement = document.activeElement;
@@ -1304,11 +590,7 @@ function ensureFocusIsInside(element: HTMLElement): void {
}
// Find something to focus
let focusableElement =
element.querySelector("[autofocus]") ??
element.querySelector(
'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
);
let focusableElement = element.querySelector("[autofocus]");
if (focusableElement instanceof HTMLElement) {
focusableElement.focus();

View File

@@ -0,0 +1,750 @@
/// `PopupPositioner`s are responsible for setting the size and position of a
/// popup. They can also decide which animation should be played when the popup
import {
RioAnimation,
RioAnimationGroup,
RioKeyframe,
RioKeyframeAnimation,
} from "./animations";
import { pixelsPerRem } from "./app";
import { getAllocatedHeightInPx, getAllocatedWidthInPx } from "./utils";
/// is opened or closed.
export abstract class PopupPositioner {
/// If a popup is quickly opened and closed (or the other way round), the
/// animation may be interrupted partway. If that happens, we don't want the
/// popup to "snap" to its final state and then start playing the new
/// animation, we want to seamlessly switch from one animation to the other.
/// That's why animations should not hard-code a specific starting keyframe,
/// but rather start from the element's current state. The `initialCss` is
/// stored separately and is only applied to the element once, when the
/// PopupManager is created.
abstract getInitialCss(): RioKeyframe;
/// Positions the `content` in the `overlaysContainer` by updating its CSS
/// *AND* returns the animation for opening the popup
abstract positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation;
abstract getCloseAnimation(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation;
/// Called when the positioner is replaced with a different one
cleanup(content: HTMLElement): void {}
}
/// Most `PopupPositioner`s always use the same open/close animation, so this
/// class exists to make that more convenient.
abstract class PopupPositionerWithStaticAnimation extends PopupPositioner {
private initialCss: RioKeyframe;
private openAnimation: RioAnimation;
private closeAnimation: RioAnimation;
constructor(
initialCss: RioKeyframe,
finalCss: RioKeyframe,
options: KeyframeAnimationOptions
) {
super();
this.initialCss = initialCss;
this.openAnimation = new RioKeyframeAnimation([finalCss], options);
this.closeAnimation = new RioKeyframeAnimation([initialCss], options);
}
getInitialCss(): RioKeyframe {
return this.initialCss;
}
positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
this._positionContent(anchor, content, overlaysContainer);
return this.openAnimation;
}
abstract _positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): void;
getCloseAnimation(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
return this.closeAnimation;
}
}
export class FullscreenPositioner extends PopupPositionerWithStaticAnimation {
constructor() {
super(
{ transform: "translateY(-1rem)", opacity: "0" },
{ transform: "translateY(0)", opacity: "1" },
{
duration: 200,
easing: "ease-in-out",
}
);
}
_positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): void {
content.style.minWidth = "100%";
content.style.minHeight = "100%";
}
}
export class DropdownPositioner extends PopupPositioner {
private readonly positioner: PopupPositioner;
// I have absolutely no clue why this is standard, but on mobile devices
// dropdowns open up centered on the screen. I guess we'll decide based
// on whether it's a touchscreen device?
public static useMobileMode(): boolean {
if (!window.matchMedia("(pointer: coarse)").matches) {
return false;
}
// Laptops with a touchscreen will also reach this point, but mobile
// dropdowns look silly on a laptop. So we'll additionally check the
// screen size.
let screenSize =
Math.min(window.screen.width, window.screen.height) / pixelsPerRem;
return screenSize < 40;
}
constructor() {
super();
// Since mobile mode and desktop mode use completely different
// animations and CSS attributes, things would definitely go wrong if a
// popup were to switch from one mode to the other. So we'll select the
// mode *once* and stick to it.
if (DropdownPositioner.useMobileMode()) {
this.positioner = new MobileDropdownPositioner();
} else {
this.positioner = new DesktopDropdownPositioner();
}
}
getInitialCss(): RioKeyframe {
return this.positioner.getInitialCss();
}
positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
return this.positioner.positionContent(
anchor,
content,
overlaysContainer
);
}
getCloseAnimation(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
return this.positioner.getCloseAnimation(
anchor,
content,
overlaysContainer
);
}
}
export class MobileDropdownPositioner extends PopupPositioner {
private static OPEN_ANIMATION = new RioAnimationGroup([
new RioKeyframeAnimation(
[
{
transform: "scale(1)",
},
],
{
duration: 200,
easing: "ease-in-out",
}
),
new RioKeyframeAnimation(
[
{
opacity: "1",
},
],
{
duration: 100,
easing: "cubic-bezier(0.5, 0.5, 0.2, 1.14)",
}
),
]);
private static CLOSE_ANIMATION = new RioAnimationGroup([
new RioKeyframeAnimation(
[
{
transform: "scale(0)",
},
],
{
duration: 200,
easing: "ease-in-out",
}
),
new RioKeyframeAnimation(
[
{
opacity: "0",
},
],
{
duration: 200,
easing: "cubic-bezier(0.5, 0.5, 0.2, 1.14)",
}
),
]);
getInitialCss(): RioKeyframe {
return {
transform: "scale(0)",
opacity: "0",
};
}
positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
content.classList.add("rio-dropdown-popup-mobile-fullscreen");
let availableWidth = getAllocatedWidthInPx(overlaysContainer);
let availableHeight = getAllocatedHeightInPx(overlaysContainer);
let contentWidth = getAllocatedWidthInPx(content);
let contentHeight = getAllocatedHeightInPx(content);
let left = (availableWidth - contentWidth) / 2;
left = Math.max(left, 0);
let top = (availableHeight - contentHeight) / 2;
top = Math.max(top, 0);
content.style.left = `${left}px`;
content.style.top = `${top}px`;
// Assign a minimum width, otherwise it's easy to misclick on a
// touchscreen
content.style.minWidth = "10rem";
return MobileDropdownPositioner.OPEN_ANIMATION;
}
getCloseAnimation(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
return MobileDropdownPositioner.CLOSE_ANIMATION;
}
}
export class DesktopDropdownPositioner extends PopupPositioner {
positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
const WINDOW_MARGIN = 0.5 * pixelsPerRem;
const GAP_IF_ENTIRELY_ABOVE = 0.5 * pixelsPerRem;
// Get some information about achor & content
let anchorRect = getAnchorRectInContainer(anchor, overlaysContainer);
let availableHeight =
getAllocatedHeightInPx(overlaysContainer) - 2 * WINDOW_MARGIN;
// Remove the previously assigned dimensions before querying the content
// size. But since we need the `max-height` for our animation, we need
// to remember the current `max-height`.
let startHeight = content.style.maxHeight;
content.style.minWidth = "unset";
content.style.maxHeight = `${availableHeight}px`;
content.style.height = "unset";
let contentWidth = getAllocatedWidthInPx(content);
let contentHeight = getAllocatedHeightInPx(content);
// CSS classes are used to communicate which of the different layouts is
// used. Remove them all first.
this.removeCssClasses(content);
// Make sure the popup is at least as wide as the anchor, while still
// being able to resize itself in case its content changes
if (contentWidth < anchorRect.width) {
content.style.minWidth = `${anchorRect.width}px`;
}
let popupWidth = Math.max(contentWidth, anchorRect.width);
let left = anchorRect.left - (popupWidth - anchorRect.width) / 2;
content.style.left = `${left}px`;
// Popup is larger than the window. Give it all the space that's available.
if (contentHeight >= availableHeight - 2 * WINDOW_MARGIN) {
content.style.top = `${WINDOW_MARGIN}px`;
content.classList.add(
"rio-dropdown-popup-above-and-below",
"rio-dropdown-popup-scroll-y"
);
return this.makeOpenAnimation(startHeight, availableHeight);
}
// Popup fits below the dropdown
if (
anchorRect.bottom + contentHeight + WINDOW_MARGIN <=
availableHeight
) {
content.style.top = `${anchorRect.bottom}px`;
content.classList.add("rio-dropdown-popup-below");
}
// Popup fits above the dropdown
else if (
anchorRect.top - contentHeight >=
GAP_IF_ENTIRELY_ABOVE + WINDOW_MARGIN
) {
content.style.top = `${
anchorRect.top - contentHeight - GAP_IF_ENTIRELY_ABOVE
}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 < WINDOW_MARGIN) {
top = WINDOW_MARGIN;
} else if (top + contentHeight + WINDOW_MARGIN > availableHeight) {
top = availableHeight - contentHeight - WINDOW_MARGIN;
}
content.style.top = `${top}px`;
content.classList.add("rio-dropdown-popup-above-and-below");
}
return this.makeOpenAnimation(startHeight, contentHeight);
}
getInitialCss(): RioKeyframe {
return {
maxHeight: "0",
overflow: "hidden",
};
}
private makeOpenAnimation(
startHeight: string,
endHeight: number
): RioAnimation {
return new RioKeyframeAnimation(
[
{
maxHeight: startHeight,
},
// A fixed max-height would prevent the content from resizing
// itself, so we must remove the max-height at the end.
{
offset: 0.99999,
maxHeight: `${endHeight}px`,
},
{
maxHeight: "unset",
},
],
{ duration: 400, easing: "ease-in-out" }
);
}
getCloseAnimation(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
let currentHeight = getAllocatedHeightInPx(content);
return new RioKeyframeAnimation(
[{ maxHeight: `${currentHeight}px` }, { maxHeight: "0" }],
{
duration: 400,
easing: "ease-in-out",
}
);
}
cleanup(content: HTMLElement): void {
this.removeCssClasses(content);
}
private removeCssClasses(content: HTMLElement): void {
content.classList.remove(
"rio-dropdown-popup-above",
"rio-dropdown-popup-below",
"rio-dropdown-popup-above-and-below",
"rio-dropdown-popup-scroll-y"
);
}
}
class SidePositioner extends PopupPositioner {
public gap: number;
public alignment: number;
public anchorRelativeX: number;
public anchorRelativeY: number;
public contentRelativeX: number;
public contentRelativeY: number;
public fixedOffsetXRem: number;
public fixedOffsetYRem: number;
private static OPEN_ANIMATION = new RioAnimationGroup([
new RioKeyframeAnimation(
[
{
transform: "scale(1)",
},
],
{
duration: 200,
easing: "ease-in-out",
}
),
new RioKeyframeAnimation(
[
{
opacity: "1",
},
],
{
duration: 100,
easing: "cubic-bezier(0.5, 0.5, 0.2, 1.14)",
}
),
]);
private static CLOSE_ANIMATION = new RioAnimationGroup([
new RioKeyframeAnimation(
[
{
transform: "scale(0)",
},
],
{
duration: 200,
easing: "ease-in-out",
}
),
new RioKeyframeAnimation(
[
{
opacity: "0",
},
],
{
duration: 200,
easing: "cubic-bezier(0.5, 0.5, 0.2, 1.14)",
}
),
]);
// 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 be positioned such that the popup point is placed exactly
// at the anchor point. (But never off the screen.)
protected constructor({
anchorRelativeX,
anchorRelativeY,
contentRelativeX,
contentRelativeY,
fixedOffsetXRem,
fixedOffsetYRem,
}: {
anchorRelativeX: number;
anchorRelativeY: number;
contentRelativeX: number;
contentRelativeY: number;
fixedOffsetXRem: number;
fixedOffsetYRem: number;
}) {
super();
this.anchorRelativeX = anchorRelativeX;
this.anchorRelativeY = anchorRelativeY;
this.contentRelativeX = contentRelativeX;
this.contentRelativeY = contentRelativeY;
this.fixedOffsetXRem = fixedOffsetXRem;
this.fixedOffsetYRem = fixedOffsetYRem;
}
getInitialCss(): RioKeyframe {
return { transform: "scale(0)", opacity: "0" };
}
positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
let margin = 0.5 * pixelsPerRem;
let availableWidth =
getAllocatedWidthInPx(overlaysContainer) - 2 * margin;
let availableHeight =
getAllocatedHeightInPx(overlaysContainer) - 2 * margin;
// Where would we like the content to be?
let anchorRect = getAnchorRectInContainer(anchor, overlaysContainer);
let contentWidth = getAllocatedWidthInPx(content);
let contentHeight = getAllocatedHeightInPx(content);
let anchorPointX =
anchorRect.left + anchorRect.width * this.anchorRelativeX;
let anchorPointY =
anchorRect.top + anchorRect.height * this.anchorRelativeY;
let popupPointX = contentWidth * this.contentRelativeX;
let popupPointY = contentHeight * this.contentRelativeY;
let popupLeft: number, popupTop: number;
if (contentWidth >= availableWidth) {
popupLeft = margin;
} else {
popupLeft =
anchorPointX -
popupPointX +
this.fixedOffsetXRem * pixelsPerRem;
let minX = margin;
let maxX = minX + availableWidth - contentWidth;
popupLeft = Math.min(Math.max(popupLeft, minX), maxX);
}
if (contentHeight >= availableHeight) {
popupTop = margin;
} else {
popupTop =
anchorPointY -
popupPointY +
this.fixedOffsetYRem * pixelsPerRem;
let minY = margin;
let maxY = minY + availableHeight - contentHeight;
popupTop = Math.min(Math.max(popupTop, minY), maxY);
}
// Position & size the popup
content.style.left = `${popupLeft}px`;
content.style.top = `${popupTop}px`;
return SidePositioner.OPEN_ANIMATION;
}
getCloseAnimation(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
return SidePositioner.CLOSE_ANIMATION;
}
}
export class LeftPositioner extends SidePositioner {
constructor(gap: number, alignment: number) {
super({
anchorRelativeX: 0,
anchorRelativeY: alignment,
contentRelativeX: 1,
contentRelativeY: 1 - alignment,
fixedOffsetXRem: -gap,
fixedOffsetYRem: 0,
});
}
}
export class RightPositioner extends SidePositioner {
constructor(gap: number, alignment: number) {
super({
anchorRelativeX: 1,
anchorRelativeY: alignment,
contentRelativeX: 0,
contentRelativeY: 1 - alignment,
fixedOffsetXRem: gap,
fixedOffsetYRem: 0,
});
}
}
export class TopPositioner extends SidePositioner {
constructor(gap: number, alignment: number) {
super({
anchorRelativeX: alignment,
anchorRelativeY: 0,
contentRelativeX: 1 - alignment,
contentRelativeY: 1,
fixedOffsetXRem: 0,
fixedOffsetYRem: -gap,
});
}
}
export class BottomPositioner extends SidePositioner {
constructor(gap: number, alignment: number) {
super({
anchorRelativeX: alignment,
anchorRelativeY: 1,
contentRelativeX: 1 - alignment,
contentRelativeY: 0,
fixedOffsetXRem: 0,
fixedOffsetYRem: gap,
});
}
}
export class CenterPositioner extends SidePositioner {
constructor() {
super({
anchorRelativeX: 0.5,
anchorRelativeY: 0.5,
contentRelativeX: 0.5,
contentRelativeY: 0.5,
fixedOffsetXRem: 0,
fixedOffsetYRem: 0,
});
}
}
export class AutoSidePositioner extends PopupPositioner {
gap: number;
alignment: number;
constructor(gap: number, alignment: number) {
super();
this.gap = gap;
this.alignment = alignment;
}
getInitialCss(): RioKeyframe {
return new TopPositioner(0, 0).getInitialCss();
}
positionContent(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
let availableWidth = getAllocatedWidthInPx(overlaysContainer);
let availableHeight = getAllocatedHeightInPx(overlaysContainer);
let anchorRect = getAnchorRectInContainer(anchor, overlaysContainer);
let relX = (anchorRect.left + anchor.scrollWidth) / 2 / availableWidth;
let relY = (anchorRect.top + anchor.scrollHeight) / 2 / availableHeight;
let positioner: SidePositioner;
if (relX < 0.2) {
positioner = new RightPositioner(this.gap, this.alignment);
} else if (relX > 0.8) {
positioner = new LeftPositioner(this.gap, this.alignment);
} else if (relY < 0.2) {
positioner = new BottomPositioner(this.gap, this.alignment);
} else {
positioner = new TopPositioner(this.gap, this.alignment);
}
return positioner.positionContent(anchor, content, overlaysContainer);
}
getCloseAnimation(
anchor: HTMLElement,
content: HTMLElement,
overlaysContainer: HTMLElement
): RioAnimation {
return new TopPositioner(0, 0).getCloseAnimation(
anchor,
content,
overlaysContainer
);
}
}
export function getPositionerByName(
position:
| "left"
| "top"
| "right"
| "bottom"
| "center"
| "auto"
| "fullscreen"
| "dropdown",
gap: number,
alignment: number
): PopupPositioner {
switch (position) {
case "left":
return new LeftPositioner(gap, alignment);
case "top":
return new TopPositioner(gap, alignment);
case "right":
return new RightPositioner(gap, alignment);
case "bottom":
return new BottomPositioner(gap, alignment);
case "center":
return new CenterPositioner();
case "auto":
return new AutoSidePositioner(gap, alignment);
case "fullscreen":
return new FullscreenPositioner();
case "dropdown":
return new DropdownPositioner();
}
}
function getAnchorRectInContainer(
anchor: HTMLElement,
overlaysContainer: HTMLElement
): DOMRect {
// Assumptions made here:
// 1. The overlaysContainer is positioned at (0, 0) in the viewport
// 2. Neither element is affected by `filter: scale` (which would distort
// the relation between "CSS pixels" and "visible pixels")
return anchor.getBoundingClientRect();
}

View File

@@ -18,8 +18,9 @@ import {
buildUploadFormData,
createBrowseButton,
} from "./components/filePickerArea";
import { FullscreenPositioner, PopupManager } from "./popupManager";
import { PopupManager } from "./popupManager";
import { setConnectionLostPopupVisibleUnlessGoingAway } from "./rpc";
import { FullscreenPositioner } from "./popupPositioners";
export async function registerFont(
name: string,

View File

@@ -2,6 +2,7 @@
"private": true,
"browserslist": "> 0.5%, last 2 versions, not dead",
"dependencies": {
"ally.js": "^1.4.1",
"highlight.js": "^11.9.0",
"math-expression-evaluator": "^2.0.6",
"micromark": "^4.0.0"