UNPKG

@workday/canvas-kit-popup-stack

Version:

Stack for managing popup UIs to coordinate global concerns like escape key handling and rendering order

458 lines (457 loc) • 20 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.createAdapter = exports.resetStack = exports.PopupStack = exports.getValue = void 0; const screenfull_1 = __importDefault(require("screenfull")); function getLast(items) { if (items.length) { return items[items.length - 1]; } return null; } /** * Calculate the zIndex value of a given index in the stack. The range is 20 where 30 is the minimum * and 50 is the maximum. If there are more than 20 items in the stack, we'll have multiple zIndexes * of 30 at the bottom of the stack since the user probably can't tell the difference with that many * popups. */ function getValue(index, length) { const { min, max } = stack.zIndex; if (length <= max - min) { return index + min; } return Math.max(min, max - (length - index) + 1); } exports.getValue = getValue; /** * Sets the z-index value of all elements in the stack according to the `getValue` function. This * will be run any time the stack changes. */ function setZIndexOfElements(elements) { const length = elements.length; elements.forEach((element, index) => { element.style.zIndex = String(getValue(index, length)); }); } /** * Return the owning popup element reference given an owner reference passed when a Popup was added * to the stack. */ function getOwnerPopup(element, items) { let parentEl = element; do { const owner = items.find(el => el.element === parentEl); if (owner) { return owner.element; } } while ((parentEl = parentEl.parentElement)); return; } /** * Get all child popups associated with an item in the stack. This is used by * `PopupStack.bringToTop` logic to bring child popups along with their parent, moving the whole * hierarchy. */ function getChildPopups(item, items) { const owners = items .filter(i => i.owner) .map(i => ({ element: i.element, parent: getOwnerPopup(i.owner, items) })) .filter(i => i.parent === item.element); return owners; } /** * Get a deeply nested dot-notation path from an arbitrary object safely. Will return `undefined` if * path does not exist. This function is not meant to be type-safe. Use with caution. * @param obj Any object * @param path dot-notation path of a deep property */ function get(obj, path) { const parts = path.split('.'); const first = parts.splice(0, 1)[0]; if (parts.length && obj[first]) { return get(obj[first], parts.join('.')); } else { return obj[first]; } } /** * Set a deeply nested dot-notation path for an arbitrary object safely. * @param obj Any object * @param path dot-notation path of a deep property * @param value Any value */ function set(obj, path, value) { const parts = path.split('.'); const first = parts.splice(0, 1)[0]; if (parts.length) { if (obj[first] === undefined) { obj[first] = {}; } set(obj[first], parts.join('.'), value); } else { obj[first] = value; } return value; } if (typeof window !== 'undefined') { window.workday = window.workday || {}; } /** * Safely get a value from window. Return the value or `undefined` if the path does not exist. This * function is not meant to be type-safe. Use with caution. Will silently return `undefined` in * environments without a `window` object, so it is safe to use in server-side rendering. * @param path Any dot-notation path */ const getFromWindow = (path) => { if (typeof window !== 'undefined') { return get(window, path); } return undefined; }; /** * Set a deeply nested dot-notation path for an arbitrary path on `window` safely. Will silently do * nothing in environments without a `window` object, so it is safe to run in server-side rendering. * @param path dot-notation path of a deep property * @param value Any value */ const setToWindow = (path, value) => { if (typeof window !== 'undefined') { set(window, path, value); } }; /** * TODO: Remove this after v12 and use `stack.container(el)` directly. This is temporary to make * sure Popups can open in new windows while supporting older versions of Canvas Kit AND full screen * mode. */ const getContainer = (stack, element) => { var _a; let stackContainer = (_a = stack.container) === null || _a === void 0 ? void 0 : _a.call(stack); if (stackContainer === document.body) { // Here's the transitory code stackContainer = element === null || element === void 0 ? void 0 : element.ownerDocument.body; } return stackContainer || document.body; }; // We need to make sure only one stack is ever in use on the page - ever. If a stack is already // defined on the page, we need to use that one. Never, ever, ever change this variable name on // window const stack = getFromWindow('workday.__popupStack') || { description: 'Global popup stack from @workday/canvas-kit/popup-stack', container: el => (el === null || el === void 0 ? void 0 : el.ownerDocument.body) || document.body, items: [], zIndex: { min: 30, max: 50, getValue: getValue }, _adapter: {}, }; setToWindow('workday.__popupStack', stack); const stacks = getFromWindow('workday.__popupStackOfStacks') || [stack]; stacks.description = 'Global stack of popup stacks from @workday/canvas-kit/popup-stack'; setToWindow('workday.__popupStackOfStacks', stacks); function getTopStack() { return stacks[stacks.length - 1]; } /** * The `PopupStack` is a framework agnostic first-in-last-out (FILO) stack that tracks all popups * ("floating UI" or any UI that renders on top of other content). It contains methods that interact * with the stack to support all coordinating behaviors of all popups on the page. The `PopupStack` * helps: * * - Render popups in the right order on the page * - Helps accessibility with the Escape key (topmost popup is closed) * - Handles transition to [Full * Screen](https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API) * * The `PopupStack` supports adapters to work with existing popup systems. {@link createAdapter} is * exported to accept an adapter. Only a single adapter should be used per page. * * The `PopupStack` is designed to handle multiple versions of `PopupStack` on the page at once * while the internal FILO stack is shared between instances. You should not attempt to use the * internal FILO stack. If an adapter is used, the internal FILO stack may be empty. */ exports.PopupStack = { /** * Create a HTMLElement as the container for the popup stack item. The returned element reference * will be the reference to be passed to all other methods. The Popup Stack will control when this * element is added and removed from the DOM as well as the `z-index` style property. Your content * should be added to this element. */ createContainer() { var _a; const stack = getTopStack(); if ((_a = stack._adapter) === null || _a === void 0 ? void 0 : _a.createContainer) { return stack._adapter.createContainer(); } const div = document.createElement('div'); div.style.position = 'relative'; // z-index only works on _positioned_ elements return div; }, /** * Adds a PopupStackItem to the stack. This should only be called when the item is rendered to the * page. Z-indexes are set when the item is added to the stack. If your application requires * popups to be registered initially, but rendered when the user triggers some event, call this * method when the event triggers. */ add(item) { var _a; const stack = getTopStack(); if ((_a = stack._adapter) === null || _a === void 0 ? void 0 : _a.add) { stack._adapter.add(item); return; } stack.items.push(item); getContainer(stack, item.owner).appendChild(item.element); setZIndexOfElements(exports.PopupStack.getElements()); }, /** * Removes an item from a stack by its `HTMLElement` reference. This should be called when a popup * is "closed" or when the element is removed from the page entirely to ensure proper memory * cleanup. A popup will be removed from the stack it is a part of. This will not automatically be * called when the element is removed from the DOM. This method will reset z-index values of the * stack. */ remove(element) { var _a; // Find the stack the popup belongs to. const stack = stacks.find(stack => !!exports.PopupStack.getElements(stack).find(el => el === element)); if (stack) { if ((_a = stack._adapter) === null || _a === void 0 ? void 0 : _a.remove) { stack._adapter.remove(element); return; } const item = stack.items.find(item => item.element === element); stack.items = stack.items.filter(item => item.element !== element); getContainer(stack, item === null || item === void 0 ? void 0 : item.owner).removeChild(element); setZIndexOfElements(exports.PopupStack.getElements(stack)); } }, /** * Returns true when the provided `element` is at the top of the stack. It will return false if it * is not the top of the stack or is not found in the stack. The `element` should be the same * reference that was passed to `add` */ isTopmost(element) { var _a; const stack = getTopStack(); if ((_a = stack._adapter) === null || _a === void 0 ? void 0 : _a.isTopmost) { return stack._adapter.isTopmost(element); } const last = getLast(stack.items); if (last) { return last.element === element; } return false; }, /** * Returns an array of elements defined by the `element` passed to `add`. This method return * elements in the order of lowest z-index to highest z-index. Some popup behaviors will need to * make decisions based on z-index order. */ getElements(stackOverride) { var _a; const stack = stackOverride || getTopStack(); if ((_a = stack._adapter) === null || _a === void 0 ? void 0 : _a.getElements) { return stack._adapter.getElements(); } return stack.items.map(i => i.element); }, /** * Bring the element to the top of the stack. This is useful for persistent popups to place them * on top of the stack when clicked. If an `owner` was provided to an item when it was added and * that owner is a DOM child of another item in the stack, that item will be considered a "parent" * to this item. If the previous are true, all "children" stack items will be brought to top as * well and will be on top of the element passed to `bringToTop`. This maintains stack item * "hierarchy" so that stack items like Popups and Tooltips don't get pushed behind elements they * are supposed to be on top of. * * This does not need to be called when a popup is added since added popups are already place on * the top of the stack. */ bringToTop(element) { var _a; const stack = getTopStack(); if ((_a = stack._adapter) === null || _a === void 0 ? void 0 : _a.bringToTop) { stack._adapter.bringToTop(element); return; } const item = stack.items.find(i => i.element === element); if (item) { stack.items = [...stack.items.filter(i => i !== item), item]; // Also bring children to top. There are a few cases where stacking might break otherwise: // - Clicking a Popup calls `bringToTop`, but mouse is over a Tooltip so that Tooltip is now // under the Popup // - Clicking a button opens a new Popup, but that click bubbles up to a `bringToTop` call // putting the new popup under an existing one // Example: https://user-images.githubusercontent.com/338257/83924476-031af580-a742-11ea-8f68-0edabdf0fd6a.gif getChildPopups(item, stack.items).forEach(popup => { exports.PopupStack.bringToTop(popup.element); }); setZIndexOfElements(exports.PopupStack.getElements()); } else { // not found const e = new Error(); console.warn('Could not find item', e.stack); } }, /** * Compares a Popup by its element reference against the event target and the stack. An event * target is considered to be "contained" by an element under the following conditions: * - The `eventTarget` is a DOM child of the popup element * - The `eventTarget` is the `owner` element passed when it was added to the stack * - The `eventTarget` is a DOM child of the `owner` element * * This method should be used instead of `element.contains` so that clicking a popup target can * opt-in to toggling. Otherwise there is no way to opt-out of toggle behavior (because the target * is not inside `element`). */ contains(element, eventTarget) { var _a; const stack = getTopStack(); if ((_a = stack._adapter) === null || _a === void 0 ? void 0 : _a.contains) { return stack._adapter.contains(element, eventTarget); } // find the stack item that contains the event target const item = stack.items.find(i => i.element === element); if (item) { return ownsElement(item, eventTarget); } return false; function ownsElement(item, eventTarget, depth = 0) { var _a; if (depth > 30) { // Prevent infinite loop return false; } // See if the event target is inside the popup element or the owner element if (item.element === eventTarget || item.owner === eventTarget || item.element.contains(eventTarget) || ((_a = item.owner) === null || _a === void 0 ? void 0 : _a.contains(eventTarget))) { return true; } // Find the popup that has an owner element inside this popup const owningPopup = stack.items.find(i => i.owner && item.element.contains(i.owner)); if (owningPopup) { // Check if the event target is inside the owning popup return ownsElement(owningPopup, eventTarget, depth + 1); } return false; } }, /** * Add a new stack context for popups. This method could be called with the same element multiple * times, but should only push a new stack context once. The most common use-case for calling * `pushStackContext` is when entering fullscreen, but multiple fullscreen listeners could be * pushing the same element which is very difficult to ensure only one stack is used. To mitigate, * this method filters out multiple calls to push the same element as a new stack context. */ pushStackContext(container) { var _a, _b; const stack = getTopStack(); if ((_a = stack._adapter) === null || _a === void 0 ? void 0 : _a.pushStackContext) { return stack._adapter.pushStackContext(container); } // Don't push if the container already exists. This removes duplicates if (((_b = stack.container) === null || _b === void 0 ? void 0 : _b.call(stack)) === container) { return; } const newStack = { items: [], zIndex: stack.zIndex, container: () => container, _adapter: {}, }; stacks.push(newStack); }, /** * Remove the topmost stack context. The stack context will only be removed if the top stack * context container element matches to guard against accidental remove of other stack contexts * you don't own. */ popStackContext(container) { var _a, _b; const stack = getTopStack(); if ((_a = stack._adapter) === null || _a === void 0 ? void 0 : _a.popStackContext) { return stack._adapter.popStackContext(container); } if (((_b = stack.container) === null || _b === void 0 ? void 0 : _b.call(stack)) === container && stacks.length > 1) { stacks.pop(); } }, /** * Transfer the popup stack item into the current popup stack context. * * An example might be a popup * that is opened and an element goes into fullscreen. The default popup stack context is * `document.body`, but the [Fullscreen * API](https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API) will only render elements * that are children of the fullscreen element. If the popup isn't transferred to the current * popup stack context, the popup will remain open, but will no longer be rendered. This method * will transfer that popup to the fullscreen element so that it will render. Popups created while * in a fullscreen context that need to be transferred back when fullscreen is exited should also * call this method. While popups may still render when fullscreen is exited, popups will be * members of different popup stack contexts which will cause unspecified results (like the escape * key will choose the wrong popup as the "topmost"). */ transferToCurrentContext(item) { var _a; const stack = getTopStack(); if ((_a = stack._adapter) === null || _a === void 0 ? void 0 : _a.transferToCurrentContext) { return stack._adapter.transferToCurrentContext(item); } if (stack.items.find(i => i.element === item.element)) { // The element is already in the stack, don't do anything return; } // Try to find the element in existing stacks. If it exists, we need to first remove from that // stack context const oldStack = stacks.find(stack => !!stack.items.find(i => i.element === item.element)); if (oldStack) { exports.PopupStack.remove(item.element); } exports.PopupStack.add(item); }, }; /** * Reset all the items in the stack. This should only be used for testing or if the page doesn't * properly tear down each item in the stack when switching views. */ function resetStack() { stack.items = []; } exports.resetStack = resetStack; /** * An adapter is a custom implementation of the {@link PopupStack}. There is only ever a single * instance of an adapter on the page. It allows an adapter to intercept any `PopupStack` method. * This could bypass the internal FILO stack of the `PopupStack` and allows the FILO stack to be * handled by something else. * * @param adapter The parts of the PopupStack that we want to override */ const createAdapter = (adapter) => { stack._adapter = adapter; }; exports.createAdapter = createAdapter; // keep track of the element ourselves to avoid accidentally popping off someone else's stack // context let element = null; // Where should this go? Each version of `PopupStack` on a page will add a listener. The // `PopupStack` should guard against multiple handlers like this simultaneously and there is no // lifecycle here. if (screenfull_1.default.isEnabled) { screenfull_1.default.on('change', () => { if (screenfull_1.default.isFullscreen) { if (screenfull_1.default.element) { element = screenfull_1.default.element; exports.PopupStack.pushStackContext(element); } } else if (element) { exports.PopupStack.popStackContext(element); } }); }