Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

alter aria-expanded attributes for popover invokers #77

Merged
merged 5 commits into from
Mar 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
22 changes: 22 additions & 0 deletions src/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export const popovers = new Set<HTMLElement>();
export const invokers = new Set<HTMLButtonElement | HTMLInputElement>();

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(', ');
80 changes: 45 additions & 35 deletions src/observer.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,50 @@
export const popovers = new Set<HTMLElement>();

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) {
Expand All @@ -47,6 +53,9 @@ const handleMutation = (mutationList: MutationRecord[]) => {
case 'popover':
handlePopoverAttributeMutation(mutation);
break;
default:
handleInvokerAttributeMutation(mutation);
break;
}
break;
case 'childList':
Expand All @@ -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,
});
Expand Down
86 changes: 86 additions & 0 deletions src/popover-helpers.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement | HTMLInputElement> {
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;
}
}
}
}
25 changes: 16 additions & 9 deletions src/popover.ts
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -138,6 +142,9 @@ export function apply() {
? this
: this.querySelector('[autofocus]');
focusEl?.focus();
for (const invoker of getInvokersFor(this)) {
setInvokerAriaExpanded(invoker);
}
}
},
},
Expand All @@ -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<Element, Element>();
const invokerDescriptor: PropertyDescriptor &
Expand Down Expand Up @@ -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 (
Expand Down
17 changes: 17 additions & 0 deletions tests/triggers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand Down