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

Showing an auto popover hides all other auto popovers #83

Merged
merged 4 commits into from
Mar 15, 2023
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -2,6 +2,8 @@

## Unreleased

- 🚀 NEW: Showing an `auto` popover closes other `auto` popovers --
[#83](https://github.com/oddbird/popover-polyfill/pull/83)
- 🚀 NEW: Add support for focus restoration on popover close --
[#81](https://github.com/oddbird/popover-polyfill/pull/81)

Expand Down
3 changes: 3 additions & 0 deletions src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ export const popoverInvokerSelector = popoverInvokerSupportedElements
return popoverInvokerAttributes.map((a) => `${s}[${a}]`);
})
.join(', ');

export const openPopoverSelector =
'[popover="" i].\\:open, [popover=auto i].\\:open';
53 changes: 52 additions & 1 deletion src/popover-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { invokers, popoverInvokerSelector, popovers } from './data.js';
import {
invokers,
openPopoverSelector,
popoverInvokerSelector,
popovers,
} from './data.js';
export const initialAriaExpandedValue = new WeakMap<
HTMLButtonElement | HTMLInputElement,
null | string
Expand All @@ -19,6 +24,52 @@ export function getPopoverFor(el: HTMLButtonElement | HTMLInputElement) {
);
}

export function* getOpenAutoPopovers() {
for (const popover of popovers) {
if (popover.matches(openPopoverSelector)) {
yield popover;
}
}
}

export function hideOpenAutoPopovers(except?: Element) {
for (const popover of getOpenAutoPopovers()) {
if (popover !== except) popover.hidePopover();
}
}

export function closestShadowPenetrating(
selector: string,
target: Element,
): Element | undefined {
const found = target.closest(selector);
if (found) {
return found;
}

const root = target.getRootNode();
if (root === document || !(root instanceof ShadowRoot)) {
return;
}

return closestShadowPenetrating(selector, root.host);
}

export function hasPopoverAncestor(
element: Element,
popover: Element,
): boolean {
let parent = element;
do {
const ancestor = closestShadowPenetrating('[popover]', parent);
if (!ancestor) return false;
if (ancestor === popover) return true;
parent =
ancestor.parentElement || (ancestor.getRootNode() as ShadowRoot)?.host;
} while (parent);
return false;
}
keithamus marked this conversation as resolved.
Show resolved Hide resolved
keithamus marked this conversation as resolved.
Show resolved Hide resolved

export function setInvokerAriaExpanded(
el: HTMLButtonElement | HTMLInputElement,
) {
Expand Down
116 changes: 42 additions & 74 deletions src/popover.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { popoverInvokerSelector, popovers } from './data.js';
import { openPopoverSelector, popoverInvokerSelector } from './data.js';
import { observePopoversMutations } from './observer.js';
import { getInvokersFor, setInvokerAriaExpanded } from './popover-helpers.js';
import {
closestShadowPenetrating,
getInvokersFor,
hideOpenAutoPopovers,
setInvokerAriaExpanded,
} from './popover-helpers.js';

export function isSupported() {
return (
Expand All @@ -19,38 +24,6 @@ function patchAttachShadow(callback: (shadowRoot: ShadowRoot) => void) {
};
}

const closestElement: (selector: string, target: Element) => Element | null = (
selector: string,
target: Element,
) => {
const found = target.closest(selector);

if (found) {
return found;
}

const root = target.getRootNode();

if (root === document || !(root instanceof ShadowRoot)) {
return null;
}

return closestElement(selector, root.host);
};

const queryAncestorAll = (
element: Element,
selector: string,
popovers: Element[] = [],
): Element[] => {
const ancestor = closestElement(selector, element);
const parent =
ancestor?.parentElement || (ancestor?.getRootNode() as ShadowRoot)?.host;
return ancestor && parent
? queryAncestorAll(parent, selector, [ancestor, ...popovers])
: popovers;
};

export function apply() {
const visibleElements = new WeakSet<HTMLElement>();
let lastFocusedElement: HTMLElement | null = null;
Expand Down Expand Up @@ -149,6 +122,7 @@ export function apply() {
for (const invoker of getInvokersFor(this)) {
setInvokerAriaExpanded(invoker);
}
hideOpenAutoPopovers(this);
}
},
},
Expand Down Expand Up @@ -246,33 +220,6 @@ export function apply() {
definePopoverTargetElementProperty('popoverShowTarget');
definePopoverTargetElementProperty('popoverHideTarget');

const handlePopoverTargetElementInvocation = (invoker: Element | null) => {
if (
!(invoker instanceof HTMLButtonElement) &&
!(invoker instanceof HTMLInputElement)
) {
return;
}
let popoverTargetElement: HTMLElement | null = null;
if (invoker.popoverToggleTargetElement) {
popoverTargetElement = invoker.popoverToggleTargetElement;
if (popoverTargetElement) {
if (visibleElements.has(popoverTargetElement)) {
popoverTargetElement.hidePopover();
} else {
popoverTargetElement.showPopover();
}
}
} else if (invoker.popoverShowTargetElement) {
popoverTargetElement = invoker.popoverShowTargetElement;
popoverTargetElement?.showPopover();
} else if (invoker.popoverHideTargetElement) {
popoverTargetElement = invoker.popoverHideTargetElement;
popoverTargetElement?.hidePopover();
}
return popoverTargetElement;
};

const onClick = (event: Event) => {
const target = event.target;
if (!(target instanceof Element) || target?.shadowRoot) {
Expand All @@ -283,23 +230,44 @@ export function apply() {
return;
}
const invoker = target.closest(popoverInvokerSelector);
const popoverTargetElement = handlePopoverTargetElementInvocation(invoker);
for (const popover of [...popovers]) {
if (
popover.matches('[popover="" i].\\:open, [popover=auto i].\\:open') &&
popover !== popoverTargetElement &&
!queryAncestorAll(target, '[popover]').includes(popover)
) {
popover.hidePopover();
let popoverTargetElement: HTMLElement | null = null;
if (
invoker instanceof HTMLButtonElement ||
invoker instanceof HTMLInputElement
) {
if (invoker.popoverToggleTargetElement) {
popoverTargetElement = invoker.popoverToggleTargetElement;
if (popoverTargetElement) {
if (visibleElements.has(popoverTargetElement)) {
popoverTargetElement.hidePopover();
} else {
popoverTargetElement.showPopover();
}
}
} else if (invoker.popoverShowTargetElement) {
popoverTargetElement = invoker.popoverShowTargetElement;
if (
popoverTargetElement &&
!visibleElements.has(popoverTargetElement)
) {
popoverTargetElement.showPopover();
}
} else if (invoker.popoverHideTargetElement) {
popoverTargetElement = invoker.popoverHideTargetElement;
if (popoverTargetElement && visibleElements.has(popoverTargetElement)) {
popoverTargetElement.hidePopover();
}
}
}
hideOpenAutoPopovers(
popoverTargetElement ||
closestShadowPenetrating(openPopoverSelector, target),
);
};

const addOnClickEventListener = (
(callback: (event: Event) => void) => (root: Document | ShadowRoot) => {
root.addEventListener('click', callback);
}
)(onClick);
const addOnClickEventListener = (root: Document | ShadowRoot) => {
root.addEventListener('click', onClick);
};

observePopoversMutations(document);
addOnClickEventListener(document);
Expand Down
17 changes: 17 additions & 0 deletions tests/dismiss.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,20 @@ test('click inside auto popover does not dismiss itself', async ({ page }) => {
await popover7.evaluate((node) => node.click());
await expect(popover7).toBeVisible();
});

test('showing an auto popover should close all other auto popovers', async ({
page,
}) => {
const popover3 = (await page.locator('#popover3')).nth(0);
const popover7 = (await page.locator('#popover7')).nth(0);
await expect(
await popover3.evaluate((node) => node.showPopover()),
).toBeUndefined();
await expect(popover3).toBeVisible();
await expect(popover7).toBeHidden();
await expect(
await popover7.evaluate((node) => node.showPopover()),
).toBeUndefined();
await expect(popover7).toBeVisible();
await expect(popover3).toBeHidden();
});