vira
Version:
A simple and highly versatile design system using element-vir.
232 lines (231 loc) • 8.83 kB
JavaScript
import { assert } from '@augment-vir/assert';
import { mapObjectValues } from '@augment-vir/common';
import { findOverflowAncestor } from '@augment-vir/web';
import { NavActivateEvent, NavDirection } from 'device-navigation';
import { listenToPageActivation } from 'page-active';
import { ListenTarget, defineTypedCustomEvent, defineTypedEvent, listenToGlobal, } from 'typed-event-target';
/**
* Used to prevent pop-ups from closing when a text input is active.
*
* @category Internal
*/
export function isInputLikeElement(element) {
return ((element instanceof HTMLInputElement &&
(element.type === 'text' ||
element.type === 'search' ||
element.type === 'email' ||
element.type === 'url' ||
element.type === 'tel' ||
element.type === 'password' ||
element.type === 'number')) ||
element instanceof HTMLTextAreaElement ||
(element instanceof HTMLElement && element.isContentEditable));
}
/**
* The default empty {@link PositionRect}, with all values set to 0.
*
* @category Internal
*/
export const emptyPositionRect = {
top: 0,
left: 0,
right: 0,
bottom: 0,
};
/**
* An event fired from {@link PopUpManager} when the pop up should be hidden.
*
* @category PopUp
*/
export class HidePopUpEvent extends defineTypedEvent('hide-pop-up') {
}
/**
* An event fired from {@link PopUpManager} when an individual item in the pop up has been selected
* by the user.
*
* @category PopUp
*/
export class NavSelectEvent extends defineTypedCustomEvent()('nav-select') {
}
/**
* A "pop up" manager for items that pop up from the HTML page, like dropdowns or menus.
*
* @category PopUp
*/
export class PopUpManager {
navController;
listenTarget = new ListenTarget();
options = {
minDownSpace: 200,
minRightSpace: 400,
verticalDiffThreshold: 20,
horizontalDiffThreshold: 100,
supportNavigation: true,
};
/** Callbacks that remove the global listeners attached while a pop up is shown. */
cleanupCallbacks = [];
lastRootElement;
constructor(navController, options) {
this.navController = navController;
this.options = {
...this.options,
...options,
};
}
/**
* Attaches the global listeners (page activation, navigation, mousedown, and keydown) that
* control the currently shown pop up.
*/
attachGlobalListeners() {
this.cleanupCallbacks = [
listenToPageActivation(false, (isPageActive) => {
if (!isPageActive) {
this.removePopUp();
}
}),
this.navController.listen(NavActivateEvent, (event) => {
const target = event.composedPath()[0];
if (target instanceof Element && isInputLikeElement(target)) {
return;
}
if (event.detail.success) {
this.listenTarget.dispatch(new NavSelectEvent({
detail: event.detail.coords,
}));
this.navController.currentNavEntry?.entry.focus(true);
event.stopImmediatePropagation();
event.preventDefault();
}
}),
listenToGlobal('mousedown', (event) => {
if (this.lastRootElement &&
event.composedPath().includes(this.lastRootElement)) {
/** Ignore clicks that came from the pop up host itself. */
return;
}
this.removePopUp();
}, {
passive: true,
}),
listenToGlobal('keydown', (event) => {
const keyCode = event.code;
if (keyCode === 'Escape') {
this.removePopUp();
}
else if (this.options.supportNavigation) {
const target = event.composedPath()[0];
if (target instanceof Element && isInputLikeElement(target)) {
return;
}
if (keyCode === 'ArrowDown') {
event.stopImmediatePropagation();
event.preventDefault();
this.navController.navigate({
direction: NavDirection.Down,
allowWrapping: false,
});
}
else if (keyCode === 'ArrowUp') {
event.stopImmediatePropagation();
event.preventDefault();
this.navController.navigate({
direction: NavDirection.Up,
allowWrapping: false,
});
}
else if (keyCode === 'ArrowLeft') {
event.stopImmediatePropagation();
event.preventDefault();
this.navController.navigate({
direction: NavDirection.Left,
allowWrapping: false,
});
}
else if (keyCode === 'ArrowRight') {
event.stopImmediatePropagation();
event.preventDefault();
this.navController.navigate({
direction: NavDirection.Right,
allowWrapping: false,
});
}
else if ((keyCode === 'Enter' || keyCode === 'Return' || keyCode === 'Space') &&
this.navController.enterInto({
fallbackToActivate: true,
}).success) {
event.stopImmediatePropagation();
event.preventDefault();
}
}
}),
];
}
/** Listen to events emitted from a {@link PopUpManager} instance. */
listen(event, listener, options) {
return this.listenTarget.listen(event, listener, options);
}
/** Trigger removal or hiding of the pop up. */
removePopUp() {
this.cleanupCallbacks.forEach((callback) => callback());
this.listenTarget.dispatch(new HidePopUpEvent());
}
/** Trigger showing the pop up. */
showPopUp(rootElement, options) {
this.lastRootElement = rootElement;
const currentOptions = {
...this.options,
...options,
};
const container = findOverflowAncestor(rootElement);
assert.instanceOf(container, HTMLElement);
const rootRect = rootElement.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const containerScrollbarWidth = container.offsetWidth - container.clientWidth;
const containerScrollbarHeight = container.offsetHeight - container.clientHeight;
const containerPosition = container === document.body
? {
top: 0,
left: 0,
right: containerRect.width,
bottom: containerRect.height,
}
: {
top: containerRect.top,
left: containerRect.left,
right: containerRect.right - containerScrollbarWidth,
bottom: containerRect.bottom - containerScrollbarHeight,
};
const rootPosition = mapObjectValues(emptyPositionRect, (key) => {
return rootRect[key];
});
const diff = mapObjectValues(emptyPositionRect, (key) => {
const containerDimension = containerPosition[key];
const hostDimension = rootPosition[key];
return Math.abs(containerDimension - hostDimension);
});
const useUp = diff.top > diff.bottom + currentOptions.verticalDiffThreshold &&
diff.bottom < currentOptions.minDownSpace;
const useLeft = diff.left > diff.right + currentOptions.horizontalDiffThreshold &&
diff.right < currentOptions.minRightSpace;
this.attachGlobalListeners();
return {
popDown: !useUp,
popRight: !useLeft,
positions: {
container: containerPosition,
root: rootPosition,
diff,
},
};
}
/**
* Cleanup and destroy the {@link PopUpManager} instance. This:
*
* - Removes the existing pop up
* - Cleans up all internal and external listeners
*/
destroy() {
this.removePopUp();
this.listenTarget.destroy();
}
}