diff --git a/index.html b/index.html index 4747b25..9ddc36d 100644 --- a/index.html +++ b/index.html @@ -39,6 +39,8 @@

Popover Attribute Polyfill

Shadowed Nested Popover (auto)
`; shadowRoot.adoptedStyleSheets = [sheet]; + document.getElementById('crossTreeToggle').popoverToggleTargetElement = + shadowRoot.getElementById('shadowedPopover'); @@ -56,6 +58,8 @@

Popover Attribute Polyfill

+ + diff --git a/src/index.ts b/src/index.ts index abc11d9..a35fb1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,11 @@ import { apply, isSupported } from './popover.js'; +interface PopoverToggleTargetElementInvoker { + popoverToggleTargetElement: HTMLElement | null; + popoverShowTargetElement: HTMLElement | null; + popoverHideTargetElement: HTMLElement | null; +} + declare global { interface BeforeToggleEvent extends Event { currentState: string; @@ -10,6 +16,10 @@ declare global { showPopover(): void; hidePopover(): void; } + /* eslint-disable @typescript-eslint/no-empty-interface */ + interface HTMLButtonElement extends PopoverToggleTargetElementInvoker {} + interface HTMLInputElement extends PopoverToggleTargetElementInvoker {} + /* eslint-enable @typescript-eslint/no-empty-interface */ interface Window { BeforeToggleEvent: BeforeToggleEvent; } diff --git a/src/popover.ts b/src/popover.ts index e026cc8..d45cc39 100644 --- a/src/popover.ts +++ b/src/popover.ts @@ -36,6 +36,19 @@ const closestElement: (selector: string, target: Element) => Element | 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(); @@ -149,6 +162,100 @@ export function apply() { }, }); + 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 & + ThisType = { + set(targetElement: unknown) { + if (targetElement === null) { + this.removeAttribute(name.toLowerCase()); + invokersMap.delete(this); + } else if (!(targetElement instanceof Element)) { + throw new TypeError(`${name}Element must be an element or null`); + } else { + this.setAttribute(name.toLowerCase(), ''); + invokersMap.set(this, targetElement); + } + }, + get() { + if (this.localName !== 'button' && this.localName !== 'input') { + return null; + } + if ( + this.localName === 'input' && + this.type !== 'reset' && + this.type !== 'image' && + this.type !== 'button' + ) { + return null; + } + if (this.disabled) { + return null; + } + if (this.form && this.type === 'submit') { + return null; + } + const targetElement = invokersMap.get(this); + if (!targetElement?.isConnected) { + invokersMap.delete(this); + } + if (targetElement) { + return targetElement; + } + const root = this.getRootNode(); + const idref = this.getAttribute(name.toLowerCase()); + if ((root instanceof Document || root instanceof ShadowRoot) && idref) { + return root.getElementById(idref) || null; + } + return null; + }, + }; + Object.defineProperty( + HTMLButtonElement.prototype, + `${name}Element`, + invokerDescriptor, + ); + Object.defineProperty( + HTMLInputElement.prototype, + `${name}Element`, + invokerDescriptor, + ); + }; + + definePopoverTargetElementProperty('popoverToggleTarget'); + 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) { @@ -158,61 +265,18 @@ export function apply() { if (!(root instanceof ShadowRoot || root instanceof Document)) { return; } - let effectedPopover = closestElement( - '[popover]', - target, - ) as HTMLElement | null; - const button = target.closest( - '[popovertoggletarget],[popoverhidetarget],[popovershowtarget]', + const invoker = target.closest( + popoverTargetAttributesSupportedElementsSelector, ); - const isButton = button instanceof HTMLButtonElement; - - // Handle Popover triggers - if (isButton && button.hasAttribute('popovershowtarget')) { - effectedPopover = root.getElementById( - button.getAttribute('popovershowtarget') || '', - ); - - if ( - effectedPopover && - effectedPopover.popover && - !visibleElements.has(effectedPopover) - ) { - effectedPopover.showPopover(); - } - } else if (isButton && button.hasAttribute('popoverhidetarget')) { - effectedPopover = root.getElementById( - button.getAttribute('popoverhidetarget') || '', - ); - - if ( - effectedPopover && - effectedPopover.popover && - visibleElements.has(effectedPopover) - ) { - effectedPopover.hidePopover(); - } - } else if (isButton && button.hasAttribute('popovertoggletarget')) { - effectedPopover = root.getElementById( - button.getAttribute('popovertoggletarget') || '', - ); - - if (effectedPopover && effectedPopover.popover) { - if (visibleElements.has(effectedPopover)) { - effectedPopover.hidePopover(); - } else { - effectedPopover.showPopover(); - } - } - } - - // Dismiss open Popovers + const popoverTargetElement = handlePopoverTargetElementInvocation(invoker); for (const popover of [...popovers]) { if ( popover.matches('[popover="" i].\\:open, [popover=auto i].\\:open') && - popover !== effectedPopover - ) + popover !== popoverTargetElement && + !queryAncestorAll(target, '[popover]').includes(popover) + ) { popover.hidePopover(); + } } }; diff --git a/tests/triggers.spec.ts b/tests/triggers.spec.ts index 1e2ab67..4880bdd 100644 --- a/tests/triggers.spec.ts +++ b/tests/triggers.spec.ts @@ -107,3 +107,78 @@ test('clicking button[popovertoggletarget=shadowedNestedPopover] should hide ope await page.click('button[popovertoggletarget=shadowedNestedPopover]'); await expect(popover).toBeHidden(); }); + +test("button 'popoverToggleTargetElement' property should return target element", async ({ + page, +}) => { + const popover = (await page.locator('#popover1')).nth(0); + await expect( + await popover.evaluate((node) => { + const button = document.querySelector('[popovertoggletarget="popover1"]'); + return button?.popoverToggleTargetElement === node; + }), + ).toBe(true); +}); + +test("button's 'popoverToggleTargetElement' property should return target element across different tree scope", async ({ + page, +}) => { + const popover = (await page.locator('#shadowedPopover')).nth(0); + await expect( + await popover.evaluate((node) => { + const button = document.getElementById('crossTreeToggle'); + return button.popoverToggleTargetElement === node; + }), + ).toBe(true); +}); + +test("assign invalid value to button's 'popoverToggleTargetElement' property should fail returning its target element", async ({ + page, +}) => { + const button = (await page.locator('[popovertoggletarget="notExist"]')).nth( + 0, + ); + await expect( + await button.evaluate((node) => node.popoverToggleTargetElement), + ).toBeNull(); +}); + +test("assign an HTML element to button's 'popoverToggleTargetElement' property should assign empty string ('') to its 'popovertoggletarget' attribute", async ({ + page, +}) => { + const button = ( + await page.locator('button[popovertoggletarget=popover1]') + ).nth(0); + + await expect( + await button.evaluate((node) => { + const popover = document.querySelector('#popover1'); + node.popoverToggleTargetElement = popover; + return node.getAttribute('popovertoggletarget'); + }), + ).toBe(''); +}); + +test("assign null to invoker's 'popoverToggleTargetElement' property should remove its 'popovertoggletarget' attribute", async ({ + page, +}) => { + const button = (await page.locator('[popovertoggletarget="popover1"]')).nth( + 0, + ); + await expect( + await button.evaluate((node) => { + node.popoverToggleTargetElement = null; + return node.getAttribute('popovertoggletarget'); + }), + ).toBeNull(); +}); + +test('clicking button#crossTreeToggle then button#crossTreeToggle twice should show and hide popover in a different tree scope', async ({ + page, +}) => { + const popover = (await page.locator('#shadowedPopover')).nth(0); + await page.click('button#crossTreeToggle'); + await expect(popover).toBeVisible(); + await page.click('button#crossTreeToggle'); + await expect(popover).toBeHidden(); +});