@esri/calcite-components
Version:
Web Components for Esri's Calcite Design System.
384 lines (383 loc) • 13.1 kB
JavaScript
/*!
* All material copyright ESRI, All Rights Reserved, unless otherwise specified.
* See https://github.com/Esri/calcite-components/blob/master/LICENSE.md for details.
* v1.5.0-next.4
*/
import { tabbable } from "tabbable";
import { guid } from "./guid";
import { CSS_UTILITY } from "./resources";
/**
* The default `focus-trap/tabbable` options.
*
* See https://github.com/focus-trap/tabbable#tabbable
*/
export const tabbableOptions = {
getShadowRoot: true
};
/**
* This helper will guarantee an ID on the provided element.
*
* If it already has an ID, it will be preserved, otherwise a unique one will be generated and assigned.
*
* @param {Element} el An element.
* @returns {string} The element's ID.
*/
export function ensureId(el) {
if (!el) {
return "";
}
return (el.id = el.id || `${el.tagName.toLowerCase()}-${guid()}`);
}
/**
* This helper returns an array from a NodeList.
*
* @param {NodeList} nodeList A NodeList.
* @returns {Element[]} An array of elements.
*/
export function nodeListToArray(nodeList) {
return Array.isArray(nodeList) ? nodeList : Array.from(nodeList);
}
/**
* This helper returns the Calcite "mode" of an element.
*
* @param {HTMLElement} el An element.
* @returns {"light"|"dark"} The Calcite mode.
*/
export function getModeName(el) {
const closestElWithMode = closestElementCrossShadowBoundary(el, `.${CSS_UTILITY.darkMode}, .${CSS_UTILITY.lightMode}`);
return closestElWithMode?.classList.contains("calcite-mode-dark") ? "dark" : "light";
}
/**
* This helper returns the direction of a HTML element.
*
* @param {HTMLElement} el An element.
* @returns {Direction} The direction.
*/
export function getElementDir(el) {
const prop = "dir";
const selector = `[${prop}]`;
const closest = closestElementCrossShadowBoundary(el, selector);
return closest ? closest.getAttribute(prop) : "ltr";
}
/**
* This helper returns the value of an attribute on an element.
*
* @param {HTMLElement} el An element.
* @param {string} attribute An attribute name.
* @param {any} fallbackValue A fallback value.
* @returns {any} The value.
* @deprecated
*/
export function getElementProp(el, attribute, fallbackValue) {
const selector = `[${attribute}]`;
const closest = el.closest(selector);
return closest ? closest.getAttribute(attribute) : fallbackValue;
}
/**
* This helper returns the rootNode of an element.
*
* @param {Element} el An element.
* @returns {Document|ShadowRoot} The element's root node.
*/
export function getRootNode(el) {
return el.getRootNode();
}
/**
* This helper returns the node's shadowRoot root node if it exists.
*
* @param {Element} el The element.
* @returns {ShadowRoot|null} The element's root node ShadowRoot.
*/
export function getShadowRootNode(el) {
const rootNode = getRootNode(el);
return "host" in rootNode ? rootNode : null;
}
/**
* This helper returns the host of a ShadowRoot.
*
* @param {Document | ShadowRoot} root A root element.
* @returns {Element | null} The host element.
*/
export function getHost(root) {
return root.host || null;
}
/**
* This helper queries an element's rootNode and any ancestor rootNodes.
*
* If both an 'id' and 'selector' are supplied, 'id' will take precedence over 'selector'.
*
* @param {Element} element An element.
* @param root0
* @param root0.selector
* @param root0.id
* @returns {Element} An element.
*/
export function queryElementRoots(element, { selector, id }) {
// Gets the rootNode and any ancestor rootNodes (shadowRoot or document) of an element and queries them for a selector.
// Based on: https://stackoverflow.com/q/54520554/194216
function queryFrom(el) {
if (!el) {
return null;
}
if (el.assignedSlot) {
el = el.assignedSlot;
}
const rootNode = getRootNode(el);
const found = id
? "getElementById" in rootNode
? /*
Check to make sure 'getElementById' exists in cases where element is no longer connected to the DOM and getRootNode() returns the element.
https://github.com/Esri/calcite-components/pull/4280
*/
rootNode.getElementById(id)
: null
: selector
? rootNode.querySelector(selector)
: null;
const host = getHost(rootNode);
return found ? found : host ? queryFrom(host) : null;
}
return queryFrom(element);
}
/**
* This helper returns the closest element matching the selector by crossing he shadow boundary if necessary.
*
* @param {Element} element The starting element.
* @param {string} selector The selector.
* @returns {Element} The targeted element.
*/
export function closestElementCrossShadowBoundary(element, selector) {
// based on https://stackoverflow.com/q/54520554/194216
function closestFrom(el) {
return el ? el.closest(selector) || closestFrom(getHost(getRootNode(el))) : null;
}
return closestFrom(element);
}
/**
* This utility helps invoke a callback as it traverses a node and its ancestors until reaching the root document.
*
* Returning early or undefined in `onVisit` will continue traversing up the DOM tree. Otherwise, traversal will halt with the returned value as the result of the function
*
* @param {Element} element An element.
* @param {(node: Node) => Element} onVisit The callback.
* @returns {Element} The result.
*/
export function walkUpAncestry(element, onVisit) {
return visit(element, onVisit);
}
function visit(node, onVisit) {
if (!node) {
return;
}
const result = onVisit(node);
if (result !== undefined) {
return result;
}
const { parentNode } = node;
return visit(parentNode instanceof ShadowRoot ? parentNode.host : parentNode, onVisit);
}
/**
* This helper returns true when an element has the descendant in question.
*
* @param {Element} element The starting element.
* @param {Element} maybeDescendant The descendant.
* @returns {boolean} The result.
*/
export function containsCrossShadowBoundary(element, maybeDescendant) {
return !!walkUpAncestry(maybeDescendant, (node) => (node === element ? true : undefined));
}
/**
* This helper returns true when an element has a setFocus method.
*
* @param {Element} el An element.
* @returns {boolean} The result.
*/
export function isCalciteFocusable(el) {
return typeof el?.setFocus === "function";
}
/**
* This helper focuses an element using the `setFocus` method if available and falls back to using the `focus` method if not available.
*
* @param {Element} el An element.
*/
export async function focusElement(el) {
if (!el) {
return;
}
return isCalciteFocusable(el) ? el.setFocus() : el.focus();
}
/**
* Helper to focus the first tabbable element.
*
* @param {HTMLElement} element The html element containing tabbable elements.
*/
export function focusFirstTabbable(element) {
if (!element) {
return;
}
(tabbable(element, tabbableOptions)[0] || element).focus();
}
const defaultSlotSelector = ":not([slot])";
export function getSlotted(element, slotName, options) {
if (slotName && !Array.isArray(slotName) && typeof slotName !== "string") {
options = slotName;
slotName = null;
}
const slotSelector = slotName
? Array.isArray(slotName)
? slotName.map((name) => `[slot="${name}"]`).join(",")
: `[slot="${slotName}"]`
: defaultSlotSelector;
if (options?.all) {
return queryMultiple(element, slotSelector, options);
}
return querySingle(element, slotSelector, options);
}
function getDirectChildren(el, selector) {
return el ? Array.from(el.children || []).filter((child) => child?.matches(selector)) : [];
}
function queryMultiple(element, slotSelector, options) {
let matches = slotSelector === defaultSlotSelector
? getDirectChildren(element, defaultSlotSelector)
: Array.from(element.querySelectorAll(slotSelector));
matches = options && options.direct === false ? matches : matches.filter((el) => el.parentElement === element);
matches = options?.matches ? matches.filter((el) => el?.matches(options.matches)) : matches;
const selector = options?.selector;
return selector
? matches
.map((item) => Array.from(item.querySelectorAll(selector)))
.reduce((previousValue, currentValue) => [...previousValue, ...currentValue], [])
.filter((match) => !!match)
: matches;
}
function querySingle(element, slotSelector, options) {
let match = slotSelector === defaultSlotSelector
? getDirectChildren(element, defaultSlotSelector)[0] || null
: element.querySelector(slotSelector);
match = options && options.direct === false ? match : match?.parentElement === element ? match : null;
match = options?.matches ? (match?.matches(options.matches) ? match : null) : match;
const selector = options?.selector;
return selector ? match?.querySelector(selector) : match;
}
/**
* Filters direct children.
*
* @param {Element} el An element.
* @param {string} selector The selector.
* @returns {Element[]} An array of elements.
*/
export function filterDirectChildren(el, selector) {
return Array.from(el.children).filter((child) => child.matches(selector));
}
/**
* Set a default icon from a defined set or allow an override with an icon name string
*
* @param {Record<string, string>} iconObject The icon object.
* @param {string | boolean} iconValue The icon value.
* @param {string} matchedValue The matched value.
* @returns {string|undefined} The resulting icon value.
*/
export function setRequestedIcon(iconObject, iconValue, matchedValue) {
if (typeof iconValue === "string" && iconValue !== "") {
return iconValue;
}
else if (iconValue === "") {
return iconObject[matchedValue];
}
}
/**
* This helper returns true when two rectangles intersect.
*
* @param {DOMRect} rect1 The first rectangle.
* @param {DOMRect} rect2 The second rectangle.
* @returns {boolean} The result.
*/
export function intersects(rect1, rect2) {
return !(rect2.left > rect1.right ||
rect2.right < rect1.left ||
rect2.top > rect1.bottom ||
rect2.bottom < rect1.top);
}
/**
* This helper makes sure that boolean aria attributes are properly converted to a string.
*
* It should only be used for aria attributes that require a string value of "true" or "false".
*
* @param {boolean} value The value.
* @returns {string} The string conversion of a boolean value ("true" | "false").
*/
export function toAriaBoolean(value) {
return Boolean(value).toString();
}
/**
* This helper returns `true` if the target `slot` element from the `onSlotchange` event has an assigned element.
*
* ```
* <slot onSlotchange={(event) => this.mySlotHasElement = slotChangeHasAssignedElement(event)} />}
* ```
*
* @param {Event} event The event.
* @returns {boolean} Whether the slot has any assigned elements.
*/
export function slotChangeHasAssignedElement(event) {
return !!slotChangeGetAssignedElements(event).length;
}
/**
* This helper returns the assigned elements on a `slot` element from the `onSlotchange` event.
*
* ```
* <slot onSlotchange={(event) => this.mySlotElements = slotChangeGetAssignedElements(event)} />}
* ```
*
* @param {Event} event The event.
* @returns {boolean} Whether the slot has any assigned elements.
*/
export function slotChangeGetAssignedElements(event) {
return event.target.assignedElements({
flatten: true
});
}
/**
* This helper returns true if the pointer event fired from the primary button of the device.
*
* See https://www.w3.org/TR/pointerevents/#the-button-property.
*
* @param {PointerEvent} event The pointer event.
* @returns {boolean} The value.
*/
export function isPrimaryPointerButton(event) {
return !!(event.isPrimary && event.button === 0);
}
/**
* This helper sets focus on and returns a destination element from within a group of provided elements.
*
* @param {Element[]} elements An array of elements.
* @param {Element} currentElement The current element.
* @param {FocusElementInGroupDestination} destination The target destination element to focus.
* @param {boolean} cycle Should navigation cycle through elements or stop at extent - defaults to true.
* @returns {Element} The focused element
*/
export const focusElementInGroup = (elements, currentElement, destination, cycle = true) => {
const currentIndex = elements.indexOf(currentElement);
const isFirstItem = currentIndex === 0;
const isLastItem = currentIndex === elements.length - 1;
if (cycle) {
destination =
destination === "previous" && isFirstItem ? "last" : destination === "next" && isLastItem ? "first" : destination;
}
let focusTarget;
if (destination === "previous") {
focusTarget = elements[currentIndex - 1] || elements[cycle ? elements.length - 1 : currentIndex];
}
else if (destination === "next") {
focusTarget = elements[currentIndex + 1] || elements[cycle ? 0 : currentIndex];
}
else if (destination === "last") {
focusTarget = elements[elements.length - 1];
}
else {
focusTarget = elements[0];
}
focusElement(focusTarget);
return focusTarget;
};