diff --git a/CHANGELOG.md b/CHANGELOG.md index e19faf1..873a2c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## UNRELEASED - 🏠 INTERNAL: Upgrade dependencies +- 🚀 NEW: Add support for `aria-expanded` on invokers -- + [#77](https://github.com/oddbird/popover-polyfill/pull/77) ## 0.0.9: 2023-02-03 diff --git a/README.md b/README.md index 6a653d9..da57774 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,39 @@ performance and robustness. After installation the polyfill will automatically add the correct methods and attributes to the HTMLElement class. +## Caveats + +This polyfill is not a perfect replacement for the native behavior, there are +some caveats which will need accommodations: + +- Native `popover` has an `:open` and `:closed` pseudo selector state. This is + not possible to polyfill, so instead this adds the `.\:open` CSS class to any + open popover. + + - `:closed` is not implemented due to difficulty in finding popover elements + during page load. As such, you'll need to style them using `:not(.\:open)`. + + - Using native `:open` in CSS that does not support native `popover` results + in an invalid selector, and so the entire declaration is thrown away. This + is important because if you intend to style a popover using `.\:open` it + will need to be a separate declaration. e.g. + `[popover]:open, [popover].\:open` will not work. + +- Native `popover` elements use the `:top-layer` pseudo element which gets + placed above all other elements on the page, regardless of overflow or + z-index. This is not possible to polyfill, and so this library simply sets a + really high `z-index`. This means if a popover is within an element that has + `overflow:` or `position:` CSS, then there will be visual differences between + the polyfill and the native behavior. + +- Native _invokers_ (that is: buttons or inputs using the `popoverHideTarget`, + `popoverShowTarget`, or `popoverToggleTarget` attributes) on `popover=auto` + will render in the accessibility tree as elements with `expanded`. The only + way to do this in the polyfill is setting the `aria-expanded` attribute on + those elements. This _may_ impact mutation observers or frameworks which do + DOM diffing, or it may interfere with other code which sets `aria-expanded` on + elements. + ## Contributing Visit our [contribution guidelines](https://github.com/oddbird/popover-polyfill/blob/main/CONTRIBUTING.md). diff --git a/src/data.ts b/src/data.ts new file mode 100644 index 0000000..f527e86 --- /dev/null +++ b/src/data.ts @@ -0,0 +1,22 @@ +export const popovers = new Set(); +export const invokers = new Set(); + +export const popoverInvokerSupportedElements = [ + 'button', + 'input[type="button"]', + 'input[type="submit"]', + 'input[type="image"]', + 'input[type="reset"]', +] as const; + +export const popoverInvokerAttributes = [ + 'popoverToggleTarget', + 'popoverShowTarget', + 'popoverHideTarget', +] as const; + +export const popoverInvokerSelector = popoverInvokerSupportedElements + .flatMap((s) => { + return popoverInvokerAttributes.map((a) => `${s}[${a}]`); + }) + .join(', '); diff --git a/src/observer.ts b/src/observer.ts index 25113a1..e05f48d 100644 --- a/src/observer.ts +++ b/src/observer.ts @@ -1,44 +1,50 @@ -export const popovers = new Set(); - -const popoversSyncFactory = - (method: (value: HTMLElement) => void) => (node: Node) => { - if (node instanceof HTMLElement) { - if (node.hasAttribute('popover')) { - method(node); - } - - node.querySelectorAll('[popover]').forEach((popover) => { - if (popover instanceof HTMLElement) { - method(popover); - } - }); - } - }; - -const nodeAddedCallback = popoversSyncFactory(popovers.add.bind(popovers)); - -const nodeRemovedCallback = popoversSyncFactory(popovers.delete.bind(popovers)); +import { invokers, popoverInvokerAttributes, popovers } from './data.js'; +import { + getInvokersFromNode, + getPopoversFromNode, + setInvokerAriaExpanded, +} from './popover-helpers.js'; const handleChildListMutation = (mutation: MutationRecord) => { - if (mutation.addedNodes.length > 0) { - mutation.addedNodes.forEach(nodeAddedCallback); + for (const node of mutation.addedNodes) { + for (const invoker of getInvokersFromNode(node)) { + setInvokerAriaExpanded(invoker); + } + for (const popover of getPopoversFromNode(node)) { + popovers.add(popover); + } } - - if (mutation.removedNodes.length > 0) { - mutation.removedNodes.forEach(nodeRemovedCallback); + for (const node of mutation.removedNodes) { + for (const invoker of getInvokersFromNode(node)) { + invokers.delete(invoker); + } + for (const popover of getPopoversFromNode(node)) { + popovers.delete(popover); + } } }; const handlePopoverAttributeMutation = (mutation: MutationRecord) => { - if (mutation.target instanceof HTMLElement) { - if (mutation.target.hasAttribute('popover')) { - popovers.add(mutation.target); + const target = mutation.target; + if (target instanceof HTMLElement) { + if (target.hasAttribute('popover')) { + popovers.add(target); } else { - popovers.delete(mutation.target); + popovers.delete(target); } } }; +const handleInvokerAttributeMutation = (mutation: MutationRecord) => { + const target = mutation.target; + if ( + target instanceof HTMLButtonElement || + target instanceof HTMLInputElement + ) { + setInvokerAriaExpanded(target); + } +}; + const handleMutation = (mutationList: MutationRecord[]) => { mutationList.forEach((mutation) => { switch (mutation.type) { @@ -47,6 +53,9 @@ const handleMutation = (mutationList: MutationRecord[]) => { case 'popover': handlePopoverAttributeMutation(mutation); break; + default: + handleInvokerAttributeMutation(mutation); + break; } break; case 'childList': @@ -62,15 +71,16 @@ export const observePopoversMutations = (root: Document | ShadowRoot) => { // Documents don't initially trigger childList mutations as opposed // to shadow roots, so we need to manually add the popovers to the set if (root === document) { - root.querySelectorAll('[popover]').forEach((popover) => { - if (popover instanceof HTMLElement) { - popovers.add(popover); - } - }); + for (const popover of getPopoversFromNode(root)) { + popovers.add(popover); + } + for (const invoker of getInvokersFromNode(root)) { + setInvokerAriaExpanded(invoker); + } } observer.observe(root, { - attributeFilter: ['popover'], + attributeFilter: ['popover', ...popoverInvokerAttributes], childList: true, subtree: true, }); diff --git a/src/popover-helpers.ts b/src/popover-helpers.ts new file mode 100644 index 0000000..a33f028 --- /dev/null +++ b/src/popover-helpers.ts @@ -0,0 +1,86 @@ +import { invokers, popoverInvokerSelector, popovers } from './data.js'; +export const initialAriaExpandedValue = new WeakMap< + HTMLButtonElement | HTMLInputElement, + null | string +>(); + +export function* getInvokersFor(el: HTMLElement) { + if (!popovers.has(el)) return; + for (const invoker of invokers) { + if (getPopoverFor(invoker) === el) yield invoker; + } +} + +export function getPopoverFor(el: HTMLButtonElement | HTMLInputElement) { + return ( + el.popoverToggleTargetElement || + el.popoverShowTargetElement || + el.popoverHideTargetElement + ); +} + +export function setInvokerAriaExpanded( + el: HTMLButtonElement | HTMLInputElement, +) { + if (!initialAriaExpandedValue.has(el)) { + initialAriaExpandedValue.set(el, el.getAttribute('aria-expanded')); + } + const popover = getPopoverFor(el); + if (popover) { + invokers.add(el); + } else { + invokers.delete(el); + } + if (popover && popover.popover === 'auto') { + el.setAttribute( + 'aria-expanded', + String(popover.classList.contains(':open')), + ); + } else { + const initialValue = initialAriaExpandedValue.get(el); + if (!initialValue) { + el.removeAttribute('aria-expanded'); + } else { + el.setAttribute('aria-expanded', initialValue); + } + } +} + +export function* getPopoversFromNode(node: Node) { + if (node instanceof HTMLElement && node.hasAttribute('popover')) { + yield node; + } + if ( + node instanceof Document || + node instanceof ShadowRoot || + node instanceof HTMLElement + ) { + for (const el of node.querySelectorAll('[popover]')) { + if (el instanceof HTMLElement) { + yield el; + } + } + } +} + +export function* getInvokersFromNode( + node: Node, +): Generator { + if ( + (node instanceof HTMLInputElement || node instanceof HTMLButtonElement) && + node.matches(popoverInvokerSelector) + ) { + yield node; + } + if ( + node instanceof Document || + node instanceof ShadowRoot || + node instanceof HTMLElement + ) { + for (const el of node.querySelectorAll(popoverInvokerSelector)) { + if (el instanceof HTMLInputElement || el instanceof HTMLButtonElement) { + yield el; + } + } + } +} diff --git a/src/popover.ts b/src/popover.ts index d45cc39..e04f4ba 100644 --- a/src/popover.ts +++ b/src/popover.ts @@ -1,4 +1,6 @@ -import { observePopoversMutations, popovers } from './observer.js'; +import { popoverInvokerSelector, popovers } from './data.js'; +import { observePopoversMutations } from './observer.js'; +import { getInvokersFor, setInvokerAriaExpanded } from './popover-helpers.js'; export function isSupported() { return ( @@ -57,11 +59,13 @@ export function apply() { element: HTMLElement, expectedToBeShowing: boolean, ) { - if (element.popover !== 'auto' && element.popover !== 'manual') + if (element.popover !== 'auto' && element.popover !== 'manual') { return false; + } if (!element.isConnected) return false; - if (element instanceof HTMLDialogElement && element.hasAttribute('open')) + if (element instanceof HTMLDialogElement && element.hasAttribute('open')) { return false; + } if (expectedToBeShowing && !visibleElements.has(element)) return false; if (!expectedToBeShowing && visibleElements.has(element)) return false; if (document.fullscreenElement === element) return false; @@ -138,6 +142,9 @@ export function apply() { ? this : this.querySelector('[autofocus]'); focusEl?.focus(); + for (const invoker of getInvokersFor(this)) { + setInvokerAriaExpanded(invoker); + } } }, }, @@ -158,13 +165,15 @@ export function apply() { assertPopoverValidity(this, true); this.classList.remove(':open'); visibleElements.delete(this); + if (this.popover === 'auto') { + for (const invoker of getInvokersFor(this)) { + setInvokerAriaExpanded(invoker); + } + } }, }, }); - const popoverTargetAttributesSupportedElementsSelector = - 'button, input[type="button"], input[type="submit"], input[type="image"], input[type="reset"]'; - const definePopoverTargetElementProperty = (name: string) => { const invokersMap = new WeakMap(); const invokerDescriptor: PropertyDescriptor & @@ -265,9 +274,7 @@ export function apply() { if (!(root instanceof ShadowRoot || root instanceof Document)) { return; } - const invoker = target.closest( - popoverTargetAttributesSupportedElementsSelector, - ); + const invoker = target.closest(popoverInvokerSelector); const popoverTargetElement = handlePopoverTargetElementInvocation(invoker); for (const popover of [...popovers]) { if ( diff --git a/tests/triggers.spec.ts b/tests/triggers.spec.ts index 4880bdd..3bd5bd6 100644 --- a/tests/triggers.spec.ts +++ b/tests/triggers.spec.ts @@ -38,6 +38,23 @@ test('clicking button[popovertoggletarget=popover1] should show then hide open p await expect(popover).toBeHidden(); }); +test('clicking button[popovertoggletarget=popover1] should set button aria-expanded attribute appropriately', async ({ + page, +}) => { + const popover = (await page.locator('#popover1')).nth(0); + const button = ( + await page.locator('button[popovertoggletarget="popover1"]') + ).nth(0); + await expect(popover).toBeHidden(); + await expect(button).toHaveAttribute('aria-expanded', 'false'); + await button.click(); + await expect(popover).toBeVisible(); + await expect(button).toHaveAttribute('aria-expanded', 'true'); + await button.click(); + await expect(popover).toBeHidden(); + await expect(button).toHaveAttribute('aria-expanded', 'false'); +}); + test('clicking button[popovershowtarget=popover3] should show open popover', async ({ page, }) => {