diff --git a/README.md b/README.md index 9e048d4..f2a4145 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ with a `` tag: ``` +Note that default styles will not be applied to shadow roots. +Each root node will need to include the styles separately. + ### With npm For more advanced configuration, you can install with diff --git a/index.html b/index.html index ed91067..4747b25 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,7 @@ +

Popover Attribute Polyfill

@@ -22,6 +23,23 @@

Popover Attribute Polyfill

Popover 8 (auto)
Popover 9 (manual)
Popover 10 (manual)
+
+
diff --git a/src/observer.ts b/src/observer.ts new file mode 100644 index 0000000..25113a1 --- /dev/null +++ b/src/observer.ts @@ -0,0 +1,77 @@ +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)); + +const handleChildListMutation = (mutation: MutationRecord) => { + if (mutation.addedNodes.length > 0) { + mutation.addedNodes.forEach(nodeAddedCallback); + } + + if (mutation.removedNodes.length > 0) { + mutation.removedNodes.forEach(nodeRemovedCallback); + } +}; + +const handlePopoverAttributeMutation = (mutation: MutationRecord) => { + if (mutation.target instanceof HTMLElement) { + if (mutation.target.hasAttribute('popover')) { + popovers.add(mutation.target); + } else { + popovers.delete(mutation.target); + } + } +}; + +const handleMutation = (mutationList: MutationRecord[]) => { + mutationList.forEach((mutation) => { + switch (mutation.type) { + case 'attributes': + switch (mutation.attributeName) { + case 'popover': + handlePopoverAttributeMutation(mutation); + break; + } + break; + case 'childList': + handleChildListMutation(mutation); + break; + } + }); +}; + +const observer = new MutationObserver(handleMutation); + +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); + } + }); + } + + observer.observe(root, { + attributeFilter: ['popover'], + childList: true, + subtree: true, + }); +}; diff --git a/src/popover.ts b/src/popover.ts index 349b13a..2bc05ec 100644 --- a/src/popover.ts +++ b/src/popover.ts @@ -1,3 +1,5 @@ +import { observePopoversMutations, popovers } from './observer.js'; + export function isSupported() { return ( typeof HTMLElement !== 'undefined' && @@ -9,6 +11,34 @@ export function isSupported() { const notSupportedMessage = 'Not supported on element that does not have valid popover attribute'; +function patchAttachShadow(callback: (shadowRoot: ShadowRoot) => void) { + const originalAttachShadow = Element.prototype.attachShadow; + Element.prototype.attachShadow = function (init) { + const shadowRoot = originalAttachShadow.call(this, init); + callback(shadowRoot); + return shadowRoot; + }; +} + +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); +}; + export function apply() { const visibleElements = new WeakSet(); @@ -66,11 +96,17 @@ export function apply() { }, }); - document.addEventListener('click', (event: Event) => { + const onClick = (event: Event) => { const target = event.target; if (!(target instanceof Element)) return; - const doc = target.ownerDocument; - let effectedPopover: HTMLElement | null = target.closest('[popover]'); + const root = target.getRootNode(); + if (root instanceof ShadowRoot) { + event.stopPropagation(); + } else if (!(root instanceof Document)) return; + let effectedPopover = closestElement( + '[popover]', + target, + ) as HTMLElement | null; const button = target.closest( '[popovertoggletarget],[popoverhidetarget],[popovershowtarget]', ); @@ -78,7 +114,7 @@ export function apply() { // Handle Popover triggers if (isButton && button.hasAttribute('popovershowtarget')) { - effectedPopover = doc.getElementById( + effectedPopover = root.getElementById( button.getAttribute('popovershowtarget') || '', ); @@ -90,7 +126,7 @@ export function apply() { effectedPopover.showPopover(); } } else if (isButton && button.hasAttribute('popoverhidetarget')) { - effectedPopover = doc.getElementById( + effectedPopover = root.getElementById( button.getAttribute('popoverhidetarget') || '', ); @@ -102,7 +138,7 @@ export function apply() { effectedPopover.hidePopover(); } } else if (isButton && button.hasAttribute('popovertoggletarget')) { - effectedPopover = doc.getElementById( + effectedPopover = root.getElementById( button.getAttribute('popovertoggletarget') || '', ); @@ -116,11 +152,24 @@ export function apply() { } // Dismiss open Popovers - for (const popover of doc.querySelectorAll( - '[popover="" i].\\:open, [popover=auto i].\\:open', - )) { - if (popover instanceof HTMLElement && popover !== effectedPopover) + for (const popover of [...popovers]) { + if ( + popover.matches('[popover="" i].\\:open, [popover=auto i].\\:open') && + popover !== effectedPopover + ) popover.hidePopover(); } - }); + }; + + const addOnClickEventListener = ( + (callback: (event: Event) => void) => (root: Document | ShadowRoot) => { + root.addEventListener('click', callback); + } + )(onClick); + + observePopoversMutations(document); + addOnClickEventListener(document); + + patchAttachShadow(observePopoversMutations); + patchAttachShadow(addOnClickEventListener); } diff --git a/tests/dismiss.spec.ts b/tests/dismiss.spec.ts index 248b881..604181b 100644 --- a/tests/dismiss.spec.ts +++ b/tests/dismiss.spec.ts @@ -16,6 +16,12 @@ test('click dismisses all auto popovers', async ({ page }) => { await expect(popover9).toBeHidden(); const popover10 = (await page.locator('#popover10')).nth(0); await expect(popover10).toBeHidden(); + const shadowedPopover = (await page.locator('#shadowedPopover')).nth(0); + await expect(shadowedPopover).toBeHidden(); + const shadowedNestedPopover = ( + await page.locator('#shadowedNestedPopover') + ).nth(0); + await expect(shadowedNestedPopover).toBeHidden(); await page.click('h1'); await expect(popover7).toBeHidden(); diff --git a/tests/triggers.spec.ts b/tests/triggers.spec.ts index 25e7d3f..1e2ab67 100644 --- a/tests/triggers.spec.ts +++ b/tests/triggers.spec.ts @@ -81,3 +81,29 @@ test('clicking button[popovershowtarget=popover5] then button[popoverhidetarget= await page.click('button[popoverhidetarget=popover5]'); await expect(popover).toBeHidden(); }); + +test('clicking button[popovertoggletarget=shadowedPopover] should hide open popover in the same (shadow) tree scope', async ({ + page, +}) => { + const popover = (await page.locator('#shadowedPopover')).nth(0); + await expect(popover).toBeHidden(); + await expect( + await popover.evaluate((node) => node.showPopover()), + ).toBeUndefined(); + await expect(popover).toBeVisible(); + await page.click('button[popovertoggletarget=shadowedPopover]'); + await expect(popover).toBeHidden(); +}); + +test('clicking button[popovertoggletarget=shadowedNestedPopover] should hide open nested popover in the same (shadow) tree scope', async ({ + page, +}) => { + const popover = (await page.locator('#shadowedNestedPopover')).nth(0); + await expect(popover).toBeHidden(); + await expect( + await popover.evaluate((node) => node.showPopover()), + ).toBeUndefined(); + await expect(popover).toBeVisible(); + await page.click('button[popovertoggletarget=shadowedNestedPopover]'); + await expect(popover).toBeHidden(); +});