UNPKG

@oddbird/popover-polyfill

Version:
608 lines (571 loc) 18.4 kB
import { queuePopoverToggleEventTask, ToggleEvent } from './events.js'; // eslint-disable-next-line @typescript-eslint/no-empty-function const ShadowRoot = globalThis.ShadowRoot || function () {}; // eslint-disable-next-line @typescript-eslint/no-empty-function const HTMLDialogElement = globalThis.HTMLDialogElement || function () {}; const topLayerElements = new WeakMap<Document, Set<HTMLElement>>(); const autoPopoverList = new WeakMap<Document, Set<HTMLElement>>(); const hintPopoverList = new WeakMap<Document, Set<HTMLElement>>(); export const visibilityState = new WeakMap<HTMLElement, 'hidden' | 'showing'>(); function getPopoverVisibilityState(popover: HTMLElement): 'hidden' | 'showing' { return visibilityState.get(popover) || 'hidden'; } const popoverInvoker = new WeakMap< HTMLElement, HTMLButtonElement | HTMLInputElement >(); function lastSetElement(set: Set<HTMLElement>) { return [...set].pop() as HTMLElement; } // https://html.spec.whatwg.org/#popover-target-attribute-activation-behavior export function popoverTargetAttributeActivationBehavior( element: HTMLButtonElement | HTMLInputElement, ) { const popover = element.popoverTargetElement; if (!(popover instanceof HTMLElement)) { return; } const visibility = getPopoverVisibilityState(popover); if (element.popoverTargetAction === 'show' && visibility === 'showing') { return; } if (element.popoverTargetAction === 'hide' && visibility === 'hidden') return; if (visibility === 'showing') { hidePopover(popover, true, true); } else if (checkPopoverValidity(popover, false)) { popoverInvoker.set(popover, element); showPopover(popover); } } // https://html.spec.whatwg.org/#check-popover-validity function checkPopoverValidity( element: HTMLElement, expectedToBeShowing: boolean, ) { if ( element.popover !== 'auto' && element.popover !== 'manual' && element.popover !== 'hint' ) { return false; } if (!element.isConnected) return false; if (expectedToBeShowing && getPopoverVisibilityState(element) !== 'showing') { return false; } if (!expectedToBeShowing && getPopoverVisibilityState(element) !== 'hidden') { return false; } if (element instanceof HTMLDialogElement && element.hasAttribute('open')) { return false; } if (document.fullscreenElement === element) return false; return true; } // https://html.spec.whatwg.org/#get-the-popover-stack-position function getStackPosition(popover?: HTMLElement) { if (!popover) return 0; const autoPopovers = autoPopoverList.get(document) || new Set(); const hintPopovers = hintPopoverList.get(document) || new Set(); if (hintPopovers.has(popover)) { return [...hintPopovers].indexOf(popover) + autoPopovers.size + 1; } if (autoPopovers.has(popover)) { return [...autoPopovers].indexOf(popover) + 1; } return 0; } // https://html.spec.whatwg.org/#topmost-clicked-popover function topMostClickedPopover(target: HTMLElement) { const clickedPopover = nearestInclusiveOpenPopover(target); const invokerPopover = nearestInclusiveTargetPopoverForInvoker(target); if (getStackPosition(clickedPopover) > getStackPosition(invokerPopover)) { return clickedPopover; } return invokerPopover; } // https://html.spec.whatwg.org/#topmost-auto-popover function topmostAutoOrHintPopover(document: Document): HTMLElement | null { let topmostPopover: HTMLElement; const hintPopovers = hintPopoverList.get(document) || new Set(); const autoPopovers = autoPopoverList.get(document) || new Set(); const usedStack = hintPopovers.size > 0 ? hintPopovers : autoPopovers.size > 0 ? autoPopovers : null; if (usedStack) { topmostPopover = lastSetElement(usedStack); // This deviates from the spec to verify that the popover is still connected if (!topmostPopover.isConnected) { usedStack.delete(topmostPopover); return topmostAutoOrHintPopover(document); } return topmostPopover; } return null; } function topMostPopoverInList(list: Set<HTMLElement>): HTMLElement | null { for (const popover of list || []) { if (!popover.isConnected) { list!.delete(popover); } else { return popover; } } return null; } export function getRootNode(node: Node): Node { if (typeof node.getRootNode === 'function') { return node.getRootNode(); } if (node.parentNode) return getRootNode(node.parentNode); return node; } // https://html.spec.whatwg.org/#nearest-inclusive-open-popover function nearestInclusiveOpenPopover( node: Node | null, ): HTMLElement | undefined { while (node) { if ( node instanceof HTMLElement && node.popover === 'auto' && visibilityState.get(node) === 'showing' ) { return node; } node = (node instanceof Element && node.assignedSlot) || node.parentElement || getRootNode(node); if (node instanceof ShadowRoot) node = node.host; if (node instanceof Document) return; } } // https://html.spec.whatwg.org/#popover-light-dismiss:nearest-inclusive-target-popover-for-invoker function nearestInclusiveTargetPopoverForInvoker( node: Node | null, ): HTMLElement | undefined { while (node) { const nodePopover = (node as HTMLButtonElement).popoverTargetElement; if (nodePopover instanceof HTMLElement) return nodePopover; node = node.parentElement || getRootNode(node); if (node instanceof ShadowRoot) node = node.host; if (node instanceof Document) return; } } // https://html.spec.whatwg.org/#topmost-popover-ancestor function topMostPopoverAncestor( newPopover: HTMLElement, list: Set<HTMLElement>, ): HTMLElement | null { const popoverPositions = new Map(); let i = 0; for (const popover of list || []) { popoverPositions.set(popover, i); i += 1; } popoverPositions.set(newPopover, i); // eslint-disable-next-line no-useless-assignment i += 1; let topMostPopoverAncestor: HTMLElement | null = null; function checkAncestor(candidate: Node | null) { if (!candidate) return; let okNesting = false; let candidateAncestor: HTMLElement | null = null; while (!okNesting) { candidateAncestor = nearestInclusiveOpenPopover(candidate) || null; if (candidateAncestor === null) return; if (!popoverPositions.has(candidateAncestor)) return; if ( newPopover.popover === 'hint' || candidateAncestor.popover === 'auto' ) { okNesting = true; } if (!okNesting) { candidate = candidateAncestor.parentElement; } } const candidatePosition = popoverPositions.get(candidateAncestor) ?? null; if ( topMostPopoverAncestor === null || popoverPositions.get(topMostPopoverAncestor) < candidatePosition ) { topMostPopoverAncestor = candidateAncestor!; } } checkAncestor(newPopover.parentElement || getRootNode(newPopover)); return topMostPopoverAncestor; } function isFocusable(focusTarget: HTMLElement) { if (focusTarget.hidden || focusTarget instanceof ShadowRoot) return false; if ( focusTarget instanceof HTMLButtonElement || focusTarget instanceof HTMLInputElement || focusTarget instanceof HTMLSelectElement || focusTarget instanceof HTMLTextAreaElement || focusTarget instanceof HTMLOptGroupElement || focusTarget instanceof HTMLOptionElement || focusTarget instanceof HTMLFieldSetElement ) { if (focusTarget.disabled) return false; } if ( focusTarget instanceof HTMLInputElement && focusTarget.type === 'hidden' ) { return false; } if (focusTarget instanceof HTMLAnchorElement && focusTarget.href === '') { return false; } return ( typeof focusTarget.tabIndex === 'number' && focusTarget.tabIndex !== -1 ); } // This method is not spec compliant, as it also looks in slotted content // for autofocus elements. // See: https://github.com/oddbird/popover-polyfill/issues/149 // Spec: https://html.spec.whatwg.org/#focus-delegate function focusDelegate(focusTarget: HTMLElement) { if ( focusTarget.shadowRoot && focusTarget.shadowRoot.delegatesFocus !== true ) { return null; } let whereToLook: DocumentFragment | HTMLElement = focusTarget; if (whereToLook.shadowRoot) { whereToLook = whereToLook.shadowRoot; } let autoFocusDelegate = whereToLook.querySelector('[autofocus]'); if (autoFocusDelegate) { return autoFocusDelegate; } else { const slots = whereToLook.querySelectorAll('slot'); for (const slot of slots) { const assignedElements = slot.assignedElements({ flatten: true }); for (const el of assignedElements) { if (el.hasAttribute('autofocus')) { return el; } else { autoFocusDelegate = el.querySelector('[autofocus]'); if (autoFocusDelegate) { return autoFocusDelegate; } } } } } const walker = focusTarget.ownerDocument.createTreeWalker( whereToLook, NodeFilter.SHOW_ELEMENT, ); let descendant: Node | null = walker.currentNode; while (descendant) { // this is not spec compliant if (isFocusable(descendant as HTMLElement)) { return descendant; } descendant = walker.nextNode(); } } // https://html.spec.whatwg.org/#popover-focusing-steps function popoverFocusingSteps(subject: HTMLElement) { (focusDelegate(subject) as HTMLElement)?.focus(); } const previouslyFocusedElements = new WeakMap<HTMLElement, HTMLElement>(); // https://html.spec.whatwg.org/#show-popover export function showPopover(element: HTMLElement) { if (!checkPopoverValidity(element, false)) { return; } const document = element.ownerDocument; if ( !element.dispatchEvent( new ToggleEvent('beforetoggle', { cancelable: true, oldState: 'closed', newState: 'open', }), ) ) { return; } if (!checkPopoverValidity(element, false)) { return; } let shouldRestoreFocus = false; const originalType = element.popover; let stackToAppendTo = null; const autoAncestor = topMostPopoverAncestor( element, autoPopoverList.get(document) || new Set(), ); const hintAncestor = topMostPopoverAncestor( element, hintPopoverList.get(document) || new Set(), ); if (originalType === 'auto') { // Close all hint popovers closeAllOpenPopoversInList( hintPopoverList.get(document) || new Set(), shouldRestoreFocus, true, ); const ancestor = autoAncestor || document; hideAllPopoversUntil(ancestor, shouldRestoreFocus, true); stackToAppendTo = 'auto'; } if (originalType === 'hint') { if (hintAncestor) { hideAllPopoversUntil(hintAncestor, shouldRestoreFocus, true); stackToAppendTo = 'hint'; } else { // Close all hint popovers closeAllOpenPopoversInList( hintPopoverList.get(document) || new Set(), shouldRestoreFocus, true, ); if (autoAncestor) { hideAllPopoversUntil(autoAncestor, shouldRestoreFocus, true); stackToAppendTo = 'auto'; } else { stackToAppendTo = 'hint'; } } } if (originalType === 'auto' || originalType === 'hint') { if ( originalType !== element.popover || !checkPopoverValidity(element, false) ) { return; } if (!topmostAutoOrHintPopover(document)) { shouldRestoreFocus = true; } if (stackToAppendTo === 'auto') { if (!autoPopoverList.has(document)) { autoPopoverList.set(document, new Set()); } autoPopoverList.get(document)!.add(element); } else if (stackToAppendTo === 'hint') { if (!hintPopoverList.has(document)) { hintPopoverList.set(document, new Set()); } hintPopoverList.get(document)!.add(element); } } previouslyFocusedElements.delete(element); const originallyFocusedElement = document.activeElement as HTMLElement; element.classList.add(':popover-open'); visibilityState.set(element, 'showing'); if (!topLayerElements.has(document)) { topLayerElements.set(document, new Set()); } topLayerElements.get(document)!.add(element); setInvokerAriaExpanded(popoverInvoker.get(element), true); popoverFocusingSteps(element); if ( shouldRestoreFocus && originallyFocusedElement && element.popover === 'auto' ) { previouslyFocusedElements.set(element, originallyFocusedElement); } queuePopoverToggleEventTask(element, 'closed', 'open'); } // https://html.spec.whatwg.org/#hide-popover-algorithm export function hidePopover( element: HTMLElement, focusPreviousElement = false, fireEvents = false, ) { if (!checkPopoverValidity(element, true)) { return; } const document = element.ownerDocument; if (['auto', 'hint'].includes(element.popover as string)) { hideAllPopoversUntil(element, focusPreviousElement, fireEvents); if (!checkPopoverValidity(element, true)) { return; } } const autoList = autoPopoverList.get(document) || new Set(); const autoPopoverListContainsElement = autoList.has(element) && lastSetElement(autoList) === element; setInvokerAriaExpanded(popoverInvoker.get(element), false); popoverInvoker.delete(element); if (fireEvents) { element.dispatchEvent( new ToggleEvent('beforetoggle', { oldState: 'open', newState: 'closed', }), ); if ( autoPopoverListContainsElement && lastSetElement(autoList) !== element ) { hideAllPopoversUntil(element, focusPreviousElement, fireEvents); } if (!checkPopoverValidity(element, true)) { return; } } topLayerElements.get(document)?.delete(element); autoList.delete(element); hintPopoverList.get(document)?.delete(element); element.classList.remove(':popover-open'); visibilityState.set(element, 'hidden'); if (fireEvents) { queuePopoverToggleEventTask(element, 'open', 'closed'); } const previouslyFocusedElement = previouslyFocusedElements.get(element); if (previouslyFocusedElement) { previouslyFocusedElements.delete(element); if (focusPreviousElement) { previouslyFocusedElement.focus(); } } } function closeAllOpenPopovers( document: Document, focusPreviousElement = false, fireEvents = false, ) { let popover = topmostAutoOrHintPopover(document); while (popover) { hidePopover(popover, focusPreviousElement, fireEvents); popover = topmostAutoOrHintPopover(document); } } function closeAllOpenPopoversInList( list: Set<HTMLElement>, focusPreviousElement = false, fireEvents = false, ) { let popover = topMostPopoverInList(list); while (popover) { hidePopover(popover, focusPreviousElement, fireEvents); popover = topMostPopoverInList(list); } } // https://html.spec.whatwg.org/#hide-popover-stack-until function hidePopoverStackUntil( endpoint: Element | Document, set: Set<HTMLElement>, focusPreviousElement: boolean, fireEvents: boolean, ) { let repeatingHide = false; let hasRunOnce = false; // This deviates from the latest version of the spec, which has a bug: // https://github.com/whatwg/html/issues/11007 while (repeatingHide || !hasRunOnce) { hasRunOnce = true; let lastToHide = null; let foundEndpoint = false; for (const popover of set) { if (popover === endpoint) { foundEndpoint = true; } else if (foundEndpoint) { lastToHide = popover; break; } } if (!lastToHide) return; while (getPopoverVisibilityState(lastToHide) === 'showing' && set.size) { hidePopover(lastSetElement(set), focusPreviousElement, fireEvents); } if (set.has(endpoint as HTMLElement) && lastSetElement(set) !== endpoint) { repeatingHide = true; } if (repeatingHide) { fireEvents = false; } } } // https://html.spec.whatwg.org/#hide-all-popovers-until export function hideAllPopoversUntil( endpoint: Element | Document, focusPreviousElement: boolean, fireEvents: boolean, ) { const document = endpoint.ownerDocument || endpoint; if (endpoint instanceof Document) { return closeAllOpenPopovers(document, focusPreviousElement, fireEvents); } if (hintPopoverList.get(document)?.has(endpoint as HTMLElement)) { hidePopoverStackUntil( endpoint, hintPopoverList.get(document)!, focusPreviousElement, fireEvents, ); return; } closeAllOpenPopoversInList( hintPopoverList.get(document) || new Set(), focusPreviousElement, fireEvents, ); if (!autoPopoverList.get(document)?.has(endpoint as HTMLElement)) { return; } hidePopoverStackUntil( endpoint, autoPopoverList.get(document)!, focusPreviousElement, fireEvents, ); } const popoverPointerDownTargets = new WeakMap<Document, HTMLElement>(); // https://html.spec.whatwg.org/#topmost-clicked-popover export function lightDismissOpenPopovers(event: Event) { if (!event.isTrusted) return; // Composed path allows us to find the target within shadowroots const target = event.composedPath()[0] as HTMLElement; if (!target) return; const document = target.ownerDocument; const topMostPopover = topmostAutoOrHintPopover(document); if (!topMostPopover) return; const ancestor = topMostClickedPopover(target); if (ancestor && event.type === 'pointerdown') { popoverPointerDownTargets.set(document, ancestor); } else if (event.type === 'pointerup') { const sameTarget = popoverPointerDownTargets.get(document) === ancestor; popoverPointerDownTargets.delete(document); if (sameTarget) { hideAllPopoversUntil(ancestor || document, false, true); } } } const initialAriaExpandedValue = new WeakMap< HTMLButtonElement | HTMLInputElement, null | string >(); function setInvokerAriaExpanded( el?: HTMLButtonElement | HTMLInputElement, force = false, ) { if (!el) return; if (!initialAriaExpandedValue.has(el)) { initialAriaExpandedValue.set(el, el.getAttribute('aria-expanded')); } const popover = el.popoverTargetElement; if (popover instanceof HTMLElement && popover.popover === 'auto') { el.setAttribute('aria-expanded', String(force)); } else { const initialValue = initialAriaExpandedValue.get(el); if (!initialValue) { el.removeAttribute('aria-expanded'); } else { el.setAttribute('aria-expanded', initialValue); } } }