UNPKG

@esri/calcite-components

Version:

Web Components for Esri's Calcite Design System.

384 lines (383 loc) • 13.1 kB
/*! * 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; };