@oddbird/popover-polyfill
Version:
Popover Attribute Polyfill
608 lines (571 loc) • 18.4 kB
text/typescript
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);
}
}
}