UNPKG

vira

Version:

A simple and highly versatile design system using element-vir.

198 lines (197 loc) 7.56 kB
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(); } }