vira
Version:
A simple and highly versatile design system using element-vir.
198 lines (197 loc) • 7.56 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';
/**
* 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,
verticalDiffThreshold: 20,
supportNavigation: true,
};
cleanupCallbacks = [];
lastRootElement;
constructor(navController, options) {
this.navController = navController;
this.options = { ...this.options, ...options };
}
attachGlobalListeners(container) {
let firstFired = false;
const resizeObserver = new ResizeObserver(() => {
if (firstFired) {
this.removePopUp();
}
else {
firstFired = true;
}
});
resizeObserver.observe(container);
this.cleanupCallbacks = [
() => {
resizeObserver.disconnect();
},
listenToPageActivation(false, (isPageActive) => {
if (!isPageActive) {
this.removePopUp();
}
}),
this.navController.listen(NavActivateEvent, (event) => {
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) {
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;
this.attachGlobalListeners(container);
return {
popDown: !useUp,
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();
}
}