/** * DOM helpers for browser, iframe, text input, popup, and scrolling behavior. * @module DOM utilities */ import { hasOwnProperty } from "./misc.ts"; import type { AriaHasPopup, AriaRole } from "./types.ts"; /** * It's `true` if it is running in a browser environment or `false` if it is not * (SSR). * @example * const title = canUseDOM ? document.title : ""; */ export const canUseDOM = checkIsBrowser(); // Check if we can use the DOM. Useful for SSR purposes function checkIsBrowser() { return typeof window !== "undefined" && !!window.document?.createElement; } /** * Returns `element.ownerDocument || document`. */ export function getDocument(node?: Window | Document | Node | null): Document { if (!node) return document; if ("self" in node) return node.document; return node.ownerDocument || document; } /** * Returns `element.ownerDocument.defaultView || window`. */ export function getWindow(node?: Window | Document | Node | null): Window { if (!node) return self; if ("self" in node) return node.self; return getDocument(node).defaultView || window; } /** * Returns `element.ownerDocument.activeElement`. */ export function getActiveElement( node?: Node | null, activeDescendant = false, ): HTMLElement | null { const { activeElement } = getDocument(node); if (!activeElement?.nodeName) { // In IE11, activeElement might be an empty object if we're interacting // with elements inside of an iframe. return null; } if (isFrame(activeElement) && activeElement.contentDocument?.body) { return getActiveElement( activeElement.contentDocument.body, activeDescendant, ); } if (activeDescendant) { const id = activeElement.getAttribute("aria-activedescendant"); if (id) { const element = getDocument(activeElement).getElementById(id); if (element) { return element; } } } return activeElement as HTMLElement | null; } /** * Similar to `Element.prototype.contains`, but a little bit faster when * `element` is the same as `child`. * @example * contains( * document.getElementById("parent"), * document.getElementById("child") * ); */ export function contains(parent: Node, child: Node): boolean { return parent === child || parent.contains(child); } /** * Checks whether the given event target is an element. * * `event.target` and `event.relatedTarget` are `EventTarget`s, which aren't * necessarily elements — for example `window` or an `XMLHttpRequest` when an * event is dispatched programmatically. Calling `Element`-only methods such as * `hasAttribute` on those throws, so guard with this before treating them as * elements. When you only need a `Node` — for example to call `contains` — use * `isNode` instead. * * It tests `nodeType` rather than `instanceof Element` so that elements coming * from same-origin child frames (which `addGlobalEventListener` also listens * on) aren't wrongly rejected for belonging to a different realm. * @example * if (isElement(event.target)) { * event.target.hasAttribute("data-active"); * } */ export function isElement( target: EventTarget | null | undefined, ): target is Element { // Reading `nodeType` on a non-node target yields `undefined`. The numeric // literal (Node.ELEMENT_NODE === 1) avoids referencing the `Node` global, so // the guard stays safe even if it's ever evaluated during SSR. return (target as Node | null)?.nodeType === 1; } /** * Checks whether the given event target is a node. * * Like `isElement`, but only requires the target to be a `Node` rather than an * element — useful before calling `contains`, which accepts any node. It still * rejects non-node `EventTarget`s (such as `window` or an `XMLHttpRequest`) * that would make `contains` throw. * @example * if (isNode(event.target)) { * contains(element, event.target); * } */ export function isNode(target: EventTarget | null | undefined): target is Node { // Non-node targets don't have a numeric `nodeType`. return typeof (target as Node | null)?.nodeType === "number"; } /** * Checks whether `element` is a frame element. */ export function isFrame(element: Element): element is HTMLIFrameElement { return element.tagName === "IFRAME"; } /** * Checks whether `element` is a native HTML button element. * @example * isButton(document.querySelector("button")); // true * isButton(document.querySelector("input[type='button']")); // true * isButton(document.querySelector("div")); // false * isButton(document.querySelector("input[type='text']")); // false * isButton(document.querySelector("div[role='button']")); // false */ export function isButton(element: { tagName: string; type?: string }) { const tagName = element.tagName.toLowerCase(); if (tagName === "button") return true; if (tagName === "input" && element.type) { return buttonInputTypes.indexOf(element.type) !== -1; } return false; } const buttonInputTypes = [ "button", "color", "file", "image", "reset", "submit", ]; /** * Checks if the element is visible or not. */ export function isVisible(element: Element) { if (typeof element.checkVisibility === "function") { return element.checkVisibility(); } const htmlElement = element as HTMLElement; return ( htmlElement.offsetWidth > 0 || htmlElement.offsetHeight > 0 || element.getClientRects().length > 0 ); } /** * Check whether the given element is a text field, where text field is defined * by the ability to select within the input. * @example * isTextField(document.querySelector("div")); // false * isTextField(document.querySelector("input")); // true * isTextField(document.querySelector("input[type='button']")); // false * isTextField(document.querySelector("textarea")); // true */ export function isTextField( element: Element, ): element is HTMLInputElement | HTMLTextAreaElement { try { // Use tag names instead of realm-bound constructors so text fields from // same-origin child frames are recognized. if (element.tagName === "TEXTAREA") return true; if (element.tagName !== "INPUT") return false; return (element as HTMLInputElement).selectionStart !== null; } catch (_error) { // Safari throws an exception when trying to get `selectionStart` on // non-text elements (which, understandably, don't have the text // selection API). We catch this via a try/catch block, as opposed to a more // explicit check of the element's input types, because of Safari's // non-standard behavior. This also means we don't have to worry about the // list of input types that support `selectionStart` changing as the HTML // spec evolves over time. return false; } } /** * Check whether the given element is a text field or a content editable * element. */ export function isTextbox(element: HTMLElement) { return element.isContentEditable || isTextField(element); } /** * Returns the value of the text field or content editable element as a string. */ export function getTextboxValue(element: HTMLElement) { if (isTextField(element)) { return element.value; } if (element.isContentEditable) { const range = getDocument(element).createRange(); range.selectNodeContents(element); return range.toString(); } return ""; } /** * Returns the start and end offsets of the selection in the element. */ export function getTextboxSelection(element: HTMLElement) { let start = 0; let end = 0; if (isTextField(element)) { start = element.selectionStart || 0; end = element.selectionEnd || 0; } else if (element.isContentEditable) { const selection = getDocument(element).getSelection(); if ( selection?.rangeCount && selection.anchorNode && contains(element, selection.anchorNode) && selection.focusNode && contains(element, selection.focusNode) ) { const range = selection.getRangeAt(0); const nextRange = range.cloneRange(); nextRange.selectNodeContents(element); nextRange.setEnd(range.startContainer, range.startOffset); start = nextRange.toString().length; nextRange.setEnd(range.endContainer, range.endOffset); end = nextRange.toString().length; } } return { start, end }; } const allowedPopupRoles = ["dialog", "menu", "listbox", "tree", "grid"]; const itemRoleByPopupRole = { menu: "menuitem", listbox: "option", tree: "treeitem", }; /** * Returns the popup role from the element's role attribute, if it has one. */ export function getPopupRole( element?: Element | null, fallback?: AriaHasPopup, ) { const role = element?.getAttribute("role"); if (role && allowedPopupRoles.indexOf(role) !== -1) { return role as "dialog" | "menu" | "listbox" | "tree" | "grid"; } return fallback; } /** * Returns the item role based on the popup role. */ export function getItemRoleByPopupRole(popupRole?: string | null) { if (popupRole == null) return; if (!hasOwnProperty(itemRoleByPopupRole, popupRole)) return; return itemRoleByPopupRole[popupRole]; } /** * Returns the item role attribute based on the popup's role. */ export function getPopupItemRole( element?: Element | null, fallback?: AriaRole, ) { const popupRole = getPopupRole(element); if (typeof popupRole !== "string") return fallback; return getItemRoleByPopupRole(popupRole) ?? fallback; } /** * Calls `element.scrollIntoView()` if the element is hidden or partly hidden in * the viewport. */ export function scrollIntoViewIfNeeded( element: Element, arg?: boolean | ScrollIntoViewOptions, ) { if (isPartiallyHidden(element) && "scrollIntoView" in element) { element.scrollIntoView(arg); } } /** * Returns the scrolling container element of a given element. */ export function getScrollingElement( element?: Element | null, ): HTMLElement | Element | null { if (!element) return null; const isScrollableOverflow = (overflow: string) => { if (overflow === "auto") return true; if (overflow === "scroll") return true; return false; }; if (element.clientHeight && element.scrollHeight > element.clientHeight) { const { overflowY } = getComputedStyle(element); if (isScrollableOverflow(overflowY)) return element; } else if (element.clientWidth && element.scrollWidth > element.clientWidth) { const { overflowX } = getComputedStyle(element); if (isScrollableOverflow(overflowX)) return element; } // When no scrollable ancestor is found, fall back to the scrolling element of // the element's own document rather than the global one. For an element // inside an iframe, `parentElement` never crosses the frame boundary, so the // recursion bottoms out at the iframe's `` and would otherwise resolve // against the top-level page's scroller. `getDocument` returns the same global // `document` for top-level elements, so this leaves the common case unchanged. const doc = getDocument(element); return ( getScrollingElement(element.parentElement) || doc.scrollingElement || doc.body ); } /** * Determines whether an element is hidden or partially hidden in the viewport. */ export function isPartiallyHidden(element: Element) { const elementRect = element.getBoundingClientRect(); const scroller = getScrollingElement(element); if (!scroller) return false; const scrollerRect = scroller.getBoundingClientRect(); const isHTML = scroller.tagName === "HTML"; const scrollerTop = isHTML ? scrollerRect.top + scroller.scrollTop : scrollerRect.top; const scrollerBottom = isHTML ? scroller.clientHeight : scrollerRect.bottom; const scrollerLeft = isHTML ? scrollerRect.left + scroller.scrollLeft : scrollerRect.left; const scrollerRight = isHTML ? scroller.clientWidth : scrollerRect.right; const top = elementRect.top < scrollerTop; const left = elementRect.left < scrollerLeft; const bottom = elementRect.bottom > scrollerBottom; const right = elementRect.right > scrollerRight; return top || left || bottom || right; } /** * SelectionRange only works on a few types of input. Calling * `setSelectionRange` on an unsupported input type may throw an error on * certain browsers. To avoid it, we check if its type supports SelectionRange * first. It will be a noop to non-supported types until we find a workaround. * * @see * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange */ export function setSelectionRange( element: HTMLInputElement | HTMLTextAreaElement, ...args: Parameters ) { if (/text|search|password|tel|url/i.test(element.type)) { element.setSelectionRange(...args); } } /** * Sort the items based on their DOM position. */ export function sortBasedOnDOMPosition( items: T[], getElement: (item: T) => Element | null | undefined, ) { const pairs = items.map((item, index) => [index, item] as const); let isOrderDifferent = false; pairs.sort(([indexA, a], [indexB, b]) => { const elementA = getElement(a); const elementB = getElement(b); if (elementA === elementB) return 0; if (!elementA || !elementB) return 0; // a before b if (isElementPreceding(elementA, elementB)) { if (indexA > indexB) { isOrderDifferent = true; } return -1; } // a after b if (indexA < indexB) { isOrderDifferent = true; } return 1; }); if (isOrderDifferent) { return pairs.map(([_, item]) => item); } return items; } function isElementPreceding(a: Element, b: Element) { return Boolean( b.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_PRECEDING, ); }