@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
JavaScript
;
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);
}
});
}