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

feat(popover): add support for popover target element #70

Merged
merged 35 commits into from
Feb 3, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
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
8 changes: 8 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ <h1>Popover Attribute Polyfill</h1>
<div id="popover8" popover="auto">Popover 8 (auto)</div>
<div id="popover9" popover="manual">Popover 9 (manual)</div>
<div id="popover10" popover="manual">Popover 10 (manual)</div>
<div id="popover11" popover="auto">Popover 11 (auto)</div>
yinonov marked this conversation as resolved.
Show resolved Hide resolved
<div id="host"></div>
<script type="module">
import sheet from './dist/popover.css' assert { type: 'css' };
Expand All @@ -39,6 +40,9 @@ <h1>Popover Attribute Polyfill</h1>
<div id="shadowedNestedPopover" popover="auto">Shadowed Nested Popover (auto)</div>
</div>`;
shadowRoot.adoptedStyleSheets = [sheet];

document.getElementById('crossTreeToggle').popoverToggleTargetElement =
shadowRoot.getElementById('shadowedPopover');
</script>
</div>

Expand All @@ -56,6 +60,10 @@ <h1>Popover Attribute Polyfill</h1>
<button popovertoggletarget="popover10">
Click to toggle Popover 10
</button>
<button id="invalidTarget" popovertoggletarget="notExist">
Click to toggle nothing
</button>
<button id="crossTreeToggle">Click to toggle shadowed Popover</button>
</div>
</body>
</html>
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ declare global {
}
interface HTMLElement {
popover: 'auto' | 'manual' | null;
popoverToggleTargetElement: HTMLElement | null;
popoverShowTargetElement: HTMLElement | null;
popoverHideTargetElement: HTMLElement | null;
showPopover(): void;
hidePopover(): void;
}
yinonov marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
158 changes: 108 additions & 50 deletions src/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ const closestElement: (selector: string, target: Element) => Element | null = (
return closestElement(selector, root.host);
};

const queryAncestorAll = (
element: Element,
selector: string,
popovers: Element[] = [],
): Element[] => {
// there could be multiple popovers nested inside each other
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>();

Expand Down Expand Up @@ -149,6 +163,90 @@ export function apply() {
},
});

const popoverTargetAttributesSupportedElementsSelector =
':is(button, input[type="button"], input[type="submit"], input[type="image"], input[type="reset"])';
yinonov marked this conversation as resolved.
Show resolved Hide resolved

const definePopoverTargetElementProperty = (name: string) => {
const invokersMap = new WeakMap<Element, Element>();
Object.defineProperty(HTMLElement.prototype, `${name}Element`, {
yinonov marked this conversation as resolved.
Show resolved Hide resolved
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 node is not supported, then return null.
if (!this.matches(popoverTargetAttributesSupportedElementsSelector)) {
return null;
}
yinonov marked this conversation as resolved.
Show resolved Hide resolved
// If node is disabled, then return null.
if (this.disabled) {
return null;
}
// If node has a form owner and node is a submit button, then return null.
if (this.form && this.type === 'submit') {
return null;
}
// workaround weakref not being available in all browsers by only returning connected elements, explicitly dropping the reference if the element isn't connected
const targetElement = invokersMap.get(this);
if (!targetElement?.isConnected) {
invokersMap.delete(this);
}
// Let idref be null.
// If node has a popovertoggletarget attribute, then set idref to the value of node's popovertoggletarget attribute.
// Otherwise, if node has a popovershowtarget attribute, then set idref to the value of node's popovershowtarget attribute.
// Otherwise, if node has a popoverhidetarget attribute, then set idref to the value of node's popoverhidetarget attribute.
// If idref is null, then return null.
// Let popoverElement be the first element in tree order, within node's root's descendants, whose ID is idref; otherwise, if there is no such element, null.
// If popoverElement is null, then return null.
// If popoverElement's popover attribute is in the no popover state, then return null.
// Return popoverElement.
if (targetElement) {
return targetElement;
}
return (
this.getRootNode().getElementById(
this.getAttribute(name.toLowerCase()),
) || null
);
},
});
};

definePopoverTargetElementProperty('popoverToggleTarget');
definePopoverTargetElementProperty('popoverShowTarget');
definePopoverTargetElementProperty('popoverHideTarget');

const handlePopoverTargetElementInvokation = (invoker: Element | null) => {
yinonov marked this conversation as resolved.
Show resolved Hide resolved
if (!(invoker instanceof HTMLButtonElement)) {
yinonov marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -158,61 +256,21 @@ 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
// if (invoker instanceof HTMLElement && checkInvokerValidity(invoker)) {
const popoverTargetElement = handlePopoverTargetElementInvokation(invoker);
yinonov marked this conversation as resolved.
Show resolved Hide resolved
// }
// Dismiss open 'auto' popovers which are not the containing popovers and are not the target popover element
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();
}
}
};

Expand Down
25 changes: 25 additions & 0 deletions tests/triggers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,28 @@ test('clicking button[popovertoggletarget=shadowedNestedPopover] should hide ope
await page.click('button[popovertoggletarget=shadowedNestedPopover]');
await expect(popover).toBeHidden();
});

// test("button's 'popoverToggleTargetElement' property should return target element", async ({
// page,
// }) => { });

// test("button's 'popoverToggleTargetElement' property should return target element across different tree scope", async ({
// page,
// }) => { });

// test("button's invalid 'popoverToggleTargetElement' property should fail returning target element", async ({
// page,
// }) => { });

test("clicking light tree button with 'popoverToggleTargetElement' property should open shadowed popover", 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#crossTreeToggle');
await expect(popover).toBeHidden();
});