mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 13:48:14 +00:00
feat: idiomatic html markup, use popover API, add safe polygon utility (#143)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -48,6 +48,7 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.6.13",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@open-wc/context-protocol": "^0.0.9",
|
||||
"@videojs/core": "workspace:*",
|
||||
|
||||
@@ -1,48 +1,64 @@
|
||||
import type { ComputePositionReturn } from '@floating-ui/core';
|
||||
import type { Placement } from '@floating-ui/dom';
|
||||
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
|
||||
import { uniqueId } from '@videojs/utils';
|
||||
import { contains, getDocument, getDocumentOrShadowRoot, safePolygon } from '@videojs/utils/dom';
|
||||
|
||||
import { getDocument, getNextTabbable, getPreviousTabbable, isOutsideEvent } from '@videojs/utils/dom';
|
||||
type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
};
|
||||
|
||||
export class MediaPopoverRoot extends HTMLElement {
|
||||
type FloatingContext = Prettify<ComputePositionReturn> & {
|
||||
elements: {
|
||||
domReference: HTMLElement;
|
||||
floating: HTMLElement;
|
||||
};
|
||||
};
|
||||
|
||||
class Popover extends HTMLElement {
|
||||
#open = false;
|
||||
#transitionStatus: 'initial' | 'open' | 'close' | 'unmounted' = 'initial';
|
||||
#hoverTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
#cleanup: (() => void) | null = null;
|
||||
#transitionStatus: 'initial' | 'open' | 'close' | 'unmounted' = 'initial';
|
||||
#abortController: AbortController | null = null;
|
||||
#floatingContext: FloatingContext | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
this.#updateVisibility();
|
||||
|
||||
this.#abortController ??= new AbortController();
|
||||
const { signal } = this.#abortController;
|
||||
|
||||
this.addEventListener('mouseenter', this, { signal });
|
||||
this.addEventListener('mouseleave', this, { signal });
|
||||
this.addEventListener('focusin', this, { signal });
|
||||
this.addEventListener('focusout', this, { signal });
|
||||
const trigger = this.#triggerElement as HTMLElement;
|
||||
if (trigger) {
|
||||
if (globalThis.matchMedia?.('(hover: hover)')?.matches) {
|
||||
trigger.addEventListener('pointerenter', this, { signal });
|
||||
trigger.addEventListener('pointerleave', this, { signal });
|
||||
}
|
||||
|
||||
getDocument(this).documentElement.addEventListener('mouseleave', this, { signal });
|
||||
trigger.addEventListener('focusin', this, { signal });
|
||||
trigger.addEventListener('focusout', this, { signal });
|
||||
}
|
||||
|
||||
this.addEventListener('pointerenter', this, { signal });
|
||||
this.addEventListener('focusout', this, { signal });
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#clearHoverTimeout();
|
||||
this.#cleanup?.();
|
||||
|
||||
this.#transitionStatus = 'unmounted';
|
||||
this.#updateVisibility();
|
||||
|
||||
this.#abortController?.abort();
|
||||
this.#abortController = null;
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
switch (event.type) {
|
||||
case 'mouseenter':
|
||||
this.#handleMouseEnter();
|
||||
case 'pointerenter':
|
||||
this.#handlePointerEnter(event as PointerEvent);
|
||||
break;
|
||||
case 'mouseleave':
|
||||
this.#handleMouseLeave(event as MouseEvent);
|
||||
case 'pointerleave':
|
||||
this.#handlePointerLeave(event as PointerEvent);
|
||||
break;
|
||||
case 'pointermove':
|
||||
this.#handlePointerMove(event as PointerEvent);
|
||||
break;
|
||||
case 'focusin':
|
||||
this.#handleFocusIn(event as FocusEvent);
|
||||
@@ -56,7 +72,7 @@ export class MediaPopoverRoot extends HTMLElement {
|
||||
}
|
||||
|
||||
static get observedAttributes(): string[] {
|
||||
return ['open-on-hover', 'delay', 'close-delay'];
|
||||
return ['open-on-hover', 'delay', 'close-delay', 'side', 'side-offset'];
|
||||
}
|
||||
|
||||
get openOnHover(): boolean {
|
||||
@@ -71,339 +87,6 @@ export class MediaPopoverRoot extends HTMLElement {
|
||||
return Number.parseInt(this.getAttribute('close-delay') ?? '0', 10);
|
||||
}
|
||||
|
||||
get #triggerElement(): MediaPopoverTrigger | null {
|
||||
return this.querySelector('media-popover-trigger') as MediaPopoverTrigger | null;
|
||||
}
|
||||
|
||||
get #portalElement(): MediaPopoverPortal | null {
|
||||
return this.querySelector('media-popover-portal') as MediaPopoverPortal | null;
|
||||
}
|
||||
|
||||
get #positionerElement(): MediaPopoverPositioner | null {
|
||||
return this.#portalElement?.querySelector('media-popover-positioner') as MediaPopoverPositioner | null;
|
||||
}
|
||||
|
||||
get #popupElement(): MediaPopoverPopup | null {
|
||||
return this.#portalElement?.querySelector('media-popover-popup') as MediaPopoverPopup | null;
|
||||
}
|
||||
|
||||
setOpen(open: boolean): void {
|
||||
if (this.#open === open) return;
|
||||
|
||||
this.#open = open;
|
||||
|
||||
if (open) {
|
||||
this.#setupFloating();
|
||||
this.#portalElement?.renderGuards();
|
||||
} else {
|
||||
this.#portalElement?.removeGuards();
|
||||
this.#cleanup?.();
|
||||
this.#cleanup = null;
|
||||
}
|
||||
|
||||
if (open) {
|
||||
this.#transitionStatus = 'initial';
|
||||
requestAnimationFrame(() => {
|
||||
this.#transitionStatus = 'open';
|
||||
this.#updateVisibility();
|
||||
});
|
||||
} else {
|
||||
this.#transitionStatus = 'close';
|
||||
}
|
||||
|
||||
this.#updateVisibility();
|
||||
}
|
||||
|
||||
#updateVisibility(): void {
|
||||
this.style.display = 'contents';
|
||||
|
||||
if (this.#popupElement) {
|
||||
const placement = this.#positionerElement?.side ?? 'top';
|
||||
this.#popupElement.setAttribute('data-side', placement);
|
||||
|
||||
this.#popupElement.toggleAttribute('data-starting-style', this.#transitionStatus === 'initial');
|
||||
this.#popupElement.toggleAttribute('data-open', this.#transitionStatus === 'initial' || this.#transitionStatus === 'open');
|
||||
this.#popupElement.toggleAttribute('data-ending-style', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted');
|
||||
this.#popupElement.toggleAttribute('data-closed', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted');
|
||||
|
||||
this.#abortController ??= new AbortController();
|
||||
const { signal } = this.#abortController;
|
||||
this.#popupElement.addEventListener('mouseleave', this, { signal });
|
||||
}
|
||||
|
||||
const triggerElement = this.#triggerElement?.firstElementChild as HTMLElement;
|
||||
if (triggerElement) {
|
||||
triggerElement.setAttribute('aria-expanded', this.#open.toString());
|
||||
triggerElement.toggleAttribute('data-popup-open', this.#open);
|
||||
|
||||
if (this.#popupElement?.id) {
|
||||
triggerElement.setAttribute('aria-controls', this.#popupElement?.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#setupFloating(): void {
|
||||
if (!this.#triggerElement || !this.#popupElement) return;
|
||||
|
||||
const trigger = this.#triggerElement.firstElementChild as HTMLElement;
|
||||
const popup = this.#popupElement;
|
||||
|
||||
if (!trigger || !popup) return;
|
||||
|
||||
const placement = this.#positionerElement?.side ?? 'top';
|
||||
const sideOffset = this.#positionerElement?.sideOffset;
|
||||
|
||||
const updatePosition = () => {
|
||||
computePosition(trigger, popup, {
|
||||
placement,
|
||||
middleware: [offset(sideOffset), flip(), shift()],
|
||||
}).then(({ x, y }: { x: number; y: number }) => {
|
||||
Object.assign(popup.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
this.#cleanup = autoUpdate(trigger, popup, updatePosition);
|
||||
}
|
||||
|
||||
#clearHoverTimeout(): void {
|
||||
if (this.#hoverTimeout) {
|
||||
clearTimeout(this.#hoverTimeout);
|
||||
this.#hoverTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
#handleMouseEnter(): void {
|
||||
if (!this.openOnHover) return;
|
||||
|
||||
this.#clearHoverTimeout();
|
||||
this.#hoverTimeout = globalThis.setTimeout(() => {
|
||||
this.setOpen(true);
|
||||
}, this.delay);
|
||||
}
|
||||
|
||||
#handleMouseLeave(event: MouseEvent): void {
|
||||
if (!this.openOnHover) return;
|
||||
|
||||
if (event.relatedTarget && this.#popupElement?.contains(event.relatedTarget as Node)) return;
|
||||
|
||||
this.#clearHoverTimeout();
|
||||
this.#hoverTimeout = globalThis.setTimeout(() => {
|
||||
this.setOpen(false);
|
||||
}, this.closeDelay);
|
||||
}
|
||||
|
||||
#handleFocusIn(_event: FocusEvent): void {
|
||||
this.setOpen(true);
|
||||
}
|
||||
|
||||
#handleFocusOut(event: FocusEvent): void {
|
||||
const relatedTarget = event.relatedTarget as HTMLElement;
|
||||
if (relatedTarget && relatedTarget.hasAttribute('data-focus-guard')) return;
|
||||
|
||||
this.setOpen(false);
|
||||
};
|
||||
}
|
||||
|
||||
export class MediaPopoverTrigger extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.style.display = 'contents';
|
||||
|
||||
const triggerElement = this.firstElementChild as HTMLElement;
|
||||
if (triggerElement) {
|
||||
triggerElement.setAttribute('aria-haspopup', 'true');
|
||||
triggerElement.setAttribute('aria-expanded', 'false');
|
||||
|
||||
const mutationObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes') {
|
||||
const rootElement = this.closest('media-popover-root') as MediaPopoverRoot;
|
||||
let popupElement = rootElement.querySelector('media-popover-popup') as MediaPopoverPopup;
|
||||
|
||||
if (!popupElement) {
|
||||
const portalElement = rootElement.querySelector('media-popover-portal') as MediaPopoverPortal;
|
||||
if (!portalElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
popupElement = portalElement.querySelector('media-popover-popup') as MediaPopoverPopup;
|
||||
if (!popupElement) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const attributeName = mutation.attributeName;
|
||||
if (!attributeName || !attributeName.startsWith('data-')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attributeValue = triggerElement.getAttribute(attributeName);
|
||||
if (attributeValue !== null) {
|
||||
popupElement.setAttribute(attributeName, attributeValue);
|
||||
} else {
|
||||
popupElement.removeAttribute(attributeName);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
mutationObserver.observe(triggerElement, {
|
||||
attributes: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaPopoverPortal extends HTMLElement {
|
||||
#portal: HTMLElement | null = null;
|
||||
#childrenArray: Element[] = [];
|
||||
#guards: HTMLElement[] = [];
|
||||
|
||||
connectedCallback(): void {
|
||||
this.style.display = 'contents';
|
||||
this.#setupPortal();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.#cleanupPortal();
|
||||
}
|
||||
|
||||
querySelector(selector: string): HTMLElement | null {
|
||||
return this.#portal!.querySelector(selector);
|
||||
}
|
||||
|
||||
querySelectorAll(selector: string): NodeListOf<Element> {
|
||||
return this.#portal!.querySelectorAll(selector);
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
this.dispatchEvent(new Event(event.type, { bubbles: true }));
|
||||
}
|
||||
|
||||
#setupPortal(): void {
|
||||
const portalId = this.getAttribute('root-id') ?? '@default_portal_id';
|
||||
if (!portalId) return;
|
||||
|
||||
/* @TODO We need to make sure portal logic is non-brittle longer term (CJP) */
|
||||
// NOTE: Hacky solution in part to ensure styling propagates from skin to container's baked in portal (TL;DR - Shadow DOM vs. Light DOM CSS) (CJP)
|
||||
const portalContainer
|
||||
= ((this.getRootNode() as ShadowRoot | Document).getElementById(portalId)
|
||||
?? (this.getRootNode() as ShadowRoot | Document)
|
||||
.querySelector('media-container')
|
||||
?.shadowRoot
|
||||
?.getElementById(portalId))
|
||||
? (this.getRootNode() as ShadowRoot | Document).querySelector('media-container')
|
||||
: undefined;
|
||||
if (!portalContainer) return;
|
||||
|
||||
this.#portal = document.createElement('div');
|
||||
this.#portal.slot = 'portal';
|
||||
this.#portal.id = uniqueId();
|
||||
|
||||
this.#childrenArray = Array.from(this.children);
|
||||
this.#portal.append(...this.#childrenArray);
|
||||
portalContainer.append(this.#portal);
|
||||
}
|
||||
|
||||
#cleanupPortal(): void {
|
||||
if (!this.#portal) return;
|
||||
|
||||
this.removeGuards();
|
||||
|
||||
this.append(...this.#childrenArray);
|
||||
this.#portal.remove();
|
||||
this.#portal = null;
|
||||
this.#childrenArray = [];
|
||||
}
|
||||
|
||||
renderGuards(): void {
|
||||
if (!this.#portal) return;
|
||||
|
||||
if (this.#guards.length === 0) {
|
||||
const beforeInsideGuard = createFocusGuard('inside');
|
||||
const afterInsideGuard = createFocusGuard('inside');
|
||||
const beforeOutsideGuard = createFocusGuard('outside');
|
||||
const afterOutsideGuard = createFocusGuard('outside');
|
||||
|
||||
beforeOutsideGuard.addEventListener('focus', (event: FocusEvent) => {
|
||||
if (this.#portal && isOutsideEvent(event, this.#portal)) {
|
||||
beforeInsideGuard.focus();
|
||||
} else {
|
||||
getPreviousTabbable(this)?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
afterOutsideGuard.addEventListener('focus', (event: FocusEvent) => {
|
||||
if (this.#portal && isOutsideEvent(event, this.#portal)) {
|
||||
afterInsideGuard.focus();
|
||||
} else {
|
||||
getNextTabbable(this)?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
beforeInsideGuard.addEventListener('focus', (event: FocusEvent) => {
|
||||
if (this.#portal && isOutsideEvent(event, this.#portal)) {
|
||||
getNextTabbable(this.#portal)?.focus();
|
||||
} else {
|
||||
beforeOutsideGuard.focus();
|
||||
}
|
||||
});
|
||||
|
||||
afterInsideGuard.addEventListener('focus', (event: FocusEvent) => {
|
||||
if (this.#portal && isOutsideEvent(event, this.#portal)) {
|
||||
getPreviousTabbable(this.#portal)?.focus();
|
||||
} else {
|
||||
afterOutsideGuard.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Add guards to portal element (outside guards)
|
||||
this.prepend(beforeOutsideGuard);
|
||||
this.append(afterOutsideGuard);
|
||||
|
||||
// Add guards to portal container (inside guards)
|
||||
this.#portal.prepend(beforeInsideGuard);
|
||||
this.#portal.append(afterInsideGuard);
|
||||
|
||||
this.#guards = [beforeOutsideGuard, afterOutsideGuard, beforeInsideGuard, afterInsideGuard];
|
||||
}
|
||||
}
|
||||
|
||||
removeGuards(): void {
|
||||
this.#guards.forEach(guard => guard.remove());
|
||||
this.#guards = [];
|
||||
}
|
||||
}
|
||||
|
||||
const visuallyHiddenStyles = 'position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;';
|
||||
|
||||
function createFocusGuard(dataType: 'inside' | 'outside'): HTMLElement {
|
||||
const focusGuard = document.createElement('span');
|
||||
focusGuard.setAttribute('data-type', dataType);
|
||||
focusGuard.setAttribute('tabindex', '0');
|
||||
focusGuard.setAttribute('data-focus-guard', '');
|
||||
focusGuard.setAttribute('aria-hidden', 'true');
|
||||
focusGuard.style.cssText = visuallyHiddenStyles;
|
||||
return focusGuard;
|
||||
}
|
||||
|
||||
export class MediaPopoverPositioner extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.style.display = 'contents';
|
||||
|
||||
const popup = this.firstElementChild as HTMLElement;
|
||||
if (popup) {
|
||||
Object.assign(popup.style, {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get side(): Placement {
|
||||
return this.getAttribute('side') as Placement;
|
||||
}
|
||||
@@ -411,48 +94,153 @@ export class MediaPopoverPositioner extends HTMLElement {
|
||||
get sideOffset(): number {
|
||||
return Number.parseInt(this.getAttribute('side-offset') ?? '0', 10);
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaPopoverPopup extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.setAttribute('role', 'dialog');
|
||||
this.setAttribute('aria-modal', 'false');
|
||||
this.id = uniqueId();
|
||||
get #triggerElement(): HTMLElement | null {
|
||||
return getDocumentOrShadowRoot(this)?.querySelector(`[popovertarget="${this.id}"]`) as HTMLElement | null;
|
||||
}
|
||||
|
||||
#setOpen(open: boolean): void {
|
||||
if (this.#open === open) return;
|
||||
|
||||
this.#open = open;
|
||||
|
||||
if (open) {
|
||||
this.#setupFloating();
|
||||
|
||||
this.#transitionStatus = 'initial';
|
||||
this.#updateVisibility();
|
||||
|
||||
this.showPopover();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.#transitionStatus = 'open';
|
||||
this.#updateVisibility();
|
||||
});
|
||||
} else {
|
||||
this.#transitionStatus = 'close';
|
||||
this.#updateVisibility();
|
||||
|
||||
const transitions = this.getAnimations().filter(anim => anim instanceof CSSTransition);
|
||||
if (transitions.length > 0) {
|
||||
Promise.all(transitions.map(t => t.finished))
|
||||
.then(() => this.hidePopover())
|
||||
.catch(() => this.hidePopover());
|
||||
} else {
|
||||
this.hidePopover();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#updateVisibility(): void {
|
||||
this.toggleAttribute('data-starting-style', this.#transitionStatus === 'initial');
|
||||
this.toggleAttribute('data-open', this.#transitionStatus === 'initial' || this.#transitionStatus === 'open');
|
||||
this.toggleAttribute('data-ending-style', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted');
|
||||
this.toggleAttribute('data-closed', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted');
|
||||
}
|
||||
|
||||
#setupFloating(): void {
|
||||
const trigger = this.#triggerElement as HTMLElement;
|
||||
if (!trigger) return;
|
||||
|
||||
const placement = this.side ?? 'top';
|
||||
const sideOffset = this.sideOffset;
|
||||
|
||||
const updatePosition = () => {
|
||||
computePosition(trigger, this, {
|
||||
placement,
|
||||
middleware: [offset(sideOffset), flip(), shift()],
|
||||
strategy: 'fixed',
|
||||
}).then((data: ComputePositionReturn) => {
|
||||
this.#floatingContext = {
|
||||
...data,
|
||||
elements: {
|
||||
domReference: trigger,
|
||||
floating: this,
|
||||
},
|
||||
};
|
||||
|
||||
Object.assign(this.style, {
|
||||
left: `${data.x}px`,
|
||||
top: `${data.y}px`,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
this.#cleanup = autoUpdate(trigger, this, updatePosition);
|
||||
}
|
||||
|
||||
#clearHoverTimeout(): void {
|
||||
if (this.#hoverTimeout) {
|
||||
globalThis.clearTimeout(this.#hoverTimeout);
|
||||
this.#hoverTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
#handlePointerEnter(event: PointerEvent): void {
|
||||
if (!this.openOnHover) return;
|
||||
|
||||
this.#clearHoverTimeout();
|
||||
|
||||
if (event.currentTarget === this) {
|
||||
this.#addPointerMoveListener();
|
||||
}
|
||||
|
||||
if (this.#open) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#hoverTimeout = globalThis.setTimeout(() => {
|
||||
this.#setOpen(true);
|
||||
}, this.delay);
|
||||
}
|
||||
|
||||
#handlePointerLeave(_event: PointerEvent): void {
|
||||
this.#addPointerMoveListener();
|
||||
}
|
||||
|
||||
#addPointerMoveListener(): void {
|
||||
if (!globalThis.matchMedia?.('(hover: hover)')?.matches) return;
|
||||
|
||||
const { signal } = this.#abortController as AbortController;
|
||||
getDocument(this).documentElement.addEventListener('pointermove', this, { signal });
|
||||
}
|
||||
|
||||
#handlePointerMove(event: PointerEvent): void {
|
||||
if (!this.openOnHover || !this.#floatingContext) return;
|
||||
|
||||
const close = safePolygon({ blockPointerEvents: true })({
|
||||
...this.#floatingContext,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
onClose: () => {
|
||||
getDocument(this).documentElement.removeEventListener('pointermove', this);
|
||||
|
||||
this.#clearHoverTimeout();
|
||||
this.#hoverTimeout = globalThis.setTimeout(() => {
|
||||
this.#setOpen(false);
|
||||
}, this.closeDelay);
|
||||
},
|
||||
});
|
||||
close(event);
|
||||
}
|
||||
|
||||
#handleFocusIn(_event: FocusEvent): void {
|
||||
this.#setOpen(true);
|
||||
}
|
||||
|
||||
#handleFocusOut(event: FocusEvent): void {
|
||||
const relatedTarget = event.relatedTarget as HTMLElement;
|
||||
if (relatedTarget && contains(this, relatedTarget)) return;
|
||||
|
||||
this.#setOpen(false);
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-popover-root')) {
|
||||
globalThis.customElements.define('media-popover-root', MediaPopoverRoot);
|
||||
if (!globalThis.customElements.get('media-popover')) {
|
||||
globalThis.customElements.define('media-popover', Popover);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-popover-trigger')) {
|
||||
globalThis.customElements.define('media-popover-trigger', MediaPopoverTrigger);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-popover-portal')) {
|
||||
globalThis.customElements.define('media-popover-portal', MediaPopoverPortal);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-popover-positioner')) {
|
||||
globalThis.customElements.define('media-popover-positioner', MediaPopoverPositioner);
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('media-popover-popup')) {
|
||||
globalThis.customElements.define('media-popover-popup', MediaPopoverPopup);
|
||||
}
|
||||
|
||||
export const Popover: {
|
||||
Root: typeof MediaPopoverRoot;
|
||||
Trigger: typeof MediaPopoverTrigger;
|
||||
Portal: typeof MediaPopoverPortal;
|
||||
Positioner: typeof MediaPopoverPositioner;
|
||||
Popup: typeof MediaPopoverPopup;
|
||||
} = {
|
||||
Root: MediaPopoverRoot,
|
||||
Trigger: MediaPopoverTrigger,
|
||||
Portal: MediaPopoverPortal,
|
||||
Positioner: MediaPopoverPositioner,
|
||||
Popup: MediaPopoverPopup,
|
||||
};
|
||||
export { Popover };
|
||||
|
||||
export default Popover;
|
||||
|
||||
@@ -9,23 +9,26 @@ export class MediaTooltipRoot extends HTMLElement {
|
||||
#hoverTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
#cleanup: (() => void) | null = null;
|
||||
#arrowElement: HTMLElement | null = null;
|
||||
#mousePosition = { x: 0, y: 0 };
|
||||
#pointerPosition = { x: 0, y: 0 };
|
||||
#transitionStatus: 'initial' | 'open' | 'close' | 'unmounted' = 'initial';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener('mouseenter', this);
|
||||
this.addEventListener('mouseleave', this);
|
||||
this.addEventListener('mousemove', this);
|
||||
|
||||
if (globalThis.matchMedia?.('(hover: hover)')?.matches) {
|
||||
this.addEventListener('pointerenter', this);
|
||||
this.addEventListener('pointerleave', this);
|
||||
this.addEventListener('pointermove', this);
|
||||
}
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
if (event.type === 'mouseenter') {
|
||||
this.#handleMouseEnter();
|
||||
} else if (event.type === 'mouseleave') {
|
||||
this.#handleMouseLeave();
|
||||
} else if (event.type === 'mousemove') {
|
||||
this.#handleMouseMove(event as MouseEvent);
|
||||
if (event.type === 'pointerenter') {
|
||||
this.#handlePointerEnter();
|
||||
} else if (event.type === 'pointerleave') {
|
||||
this.#handlePointerLeave(event as PointerEvent);
|
||||
} else if (event.type === 'pointermove') {
|
||||
this.#handlePointerMove(event as PointerEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,34 +160,34 @@ export class MediaTooltipRoot extends HTMLElement {
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: triggerRect.top,
|
||||
right: this.#mousePosition.x,
|
||||
right: this.#pointerPosition.x,
|
||||
bottom: triggerRect.bottom,
|
||||
left: this.#mousePosition.x,
|
||||
x: this.#mousePosition.x,
|
||||
left: this.#pointerPosition.x,
|
||||
x: this.#pointerPosition.x,
|
||||
y: triggerRect.top,
|
||||
};
|
||||
} else if (this.trackCursorAxis === 'y') {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: this.#mousePosition.y,
|
||||
top: this.#pointerPosition.y,
|
||||
right: triggerRect.right,
|
||||
bottom: this.#mousePosition.y,
|
||||
bottom: this.#pointerPosition.y,
|
||||
left: triggerRect.left,
|
||||
x: triggerRect.left,
|
||||
y: this.#mousePosition.y,
|
||||
y: this.#pointerPosition.y,
|
||||
};
|
||||
} else {
|
||||
// Track both axes (trackCursorAxis === 'both')
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: this.#mousePosition.y,
|
||||
right: this.#mousePosition.x,
|
||||
bottom: this.#mousePosition.y,
|
||||
left: this.#mousePosition.x,
|
||||
x: this.#mousePosition.x,
|
||||
y: this.#mousePosition.y,
|
||||
top: this.#pointerPosition.y,
|
||||
right: this.#pointerPosition.x,
|
||||
bottom: this.#pointerPosition.y,
|
||||
left: this.#pointerPosition.x,
|
||||
x: this.#pointerPosition.x,
|
||||
y: this.#pointerPosition.y,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -232,23 +235,23 @@ export class MediaTooltipRoot extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
#handleMouseEnter(): void {
|
||||
#handlePointerEnter(): void {
|
||||
this.#clearHoverTimeout();
|
||||
this.#hoverTimeout = globalThis.setTimeout(() => {
|
||||
this.#setOpen(true);
|
||||
}, this.delay);
|
||||
}
|
||||
|
||||
#handleMouseLeave(): void {
|
||||
#handlePointerLeave(_event: PointerEvent): void {
|
||||
this.#clearHoverTimeout();
|
||||
this.#hoverTimeout = globalThis.setTimeout(() => {
|
||||
this.#setOpen(false);
|
||||
}, this.closeDelay);
|
||||
}
|
||||
|
||||
#handleMouseMove(event: MouseEvent): void {
|
||||
#handlePointerMove(event: PointerEvent): void {
|
||||
if (this.trackCursorAxis) {
|
||||
this.#mousePosition = { x: event.clientX, y: event.clientY };
|
||||
this.#pointerPosition = { x: event.clientX, y: event.clientY };
|
||||
|
||||
if (this.#open) {
|
||||
this.#updatePosition();
|
||||
|
||||
@@ -70,27 +70,29 @@ export function getTemplateHTML() {
|
||||
<media-duration-display></media-duration-display>
|
||||
</div>
|
||||
|
||||
<media-popover-root open-on-hover delay="200" close-delay="100">
|
||||
<media-popover-trigger>
|
||||
<media-mute-button class="button">
|
||||
<media-volume-high-icon class="icon volume-high-icon"></media-volume-high-icon>
|
||||
<media-volume-low-icon class="icon volume-low-icon"></media-volume-low-icon>
|
||||
<media-volume-off-icon class="icon volume-off-icon"></media-volume-off-icon>
|
||||
</media-mute-button>
|
||||
</media-popover-trigger>
|
||||
<media-popover-portal>
|
||||
<media-popover-positioner side="top" side-offset="12" collision-padding="12">
|
||||
<media-popover-popup class="surface popup-animation">
|
||||
<media-volume-slider-root class="slider-root" orientation="vertical">
|
||||
<media-volume-slider-track class="slider-track">
|
||||
<media-volume-slider-progress class="slider-progress"></media-volume-slider-progress>
|
||||
</media-volume-slider-track>
|
||||
<media-volume-slider-thumb class="slider-thumb"></media-volume-slider-thumb>
|
||||
</media-volume-slider-root>
|
||||
</media-popover-popup>
|
||||
</media-popover-positioner>
|
||||
</media-popover-portal>
|
||||
</media-popover-root>
|
||||
<media-mute-button popovertarget="volume-slider-popover" class="button">
|
||||
<media-volume-high-icon class="icon volume-high-icon"></media-volume-high-icon>
|
||||
<media-volume-low-icon class="icon volume-low-icon"></media-volume-low-icon>
|
||||
<media-volume-off-icon class="icon volume-off-icon"></media-volume-off-icon>
|
||||
</media-mute-button>
|
||||
<media-popover
|
||||
id="volume-slider-popover"
|
||||
class="surface popup-animation"
|
||||
popover
|
||||
open-on-hover
|
||||
delay="200"
|
||||
close-delay="100"
|
||||
side="top"
|
||||
side-offset="12"
|
||||
collision-padding="12"
|
||||
>
|
||||
<media-volume-slider-root class="slider-root" orientation="vertical">
|
||||
<media-volume-slider-track class="slider-track">
|
||||
<media-volume-slider-progress class="slider-progress"></media-volume-slider-progress>
|
||||
</media-volume-slider-track>
|
||||
<media-volume-slider-thumb class="slider-thumb"></media-volume-slider-thumb>
|
||||
</media-volume-slider-root>
|
||||
</media-popover>
|
||||
|
||||
<media-tooltip-root delay="500" close-delay="0">
|
||||
<media-tooltip-trigger>
|
||||
|
||||
@@ -325,7 +325,11 @@ preview-time-display {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
media-popover-popup {
|
||||
media-popover {
|
||||
margin: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
padding: 0.75rem 0.25rem;
|
||||
border-radius: calc(infinity * 1px);
|
||||
}
|
||||
|
||||
@@ -74,27 +74,29 @@ export function getTemplateHTML() {
|
||||
</media-tooltip-root>
|
||||
|
||||
<div class="button-group">
|
||||
<media-popover-root open-on-hover delay="200" close-delay="100">
|
||||
<media-popover-trigger>
|
||||
<media-mute-button class="button">
|
||||
<media-volume-high-icon class="icon volume-high-icon"></media-volume-high-icon>
|
||||
<media-volume-low-icon class="icon volume-low-icon"></media-volume-low-icon>
|
||||
<media-volume-off-icon class="icon volume-off-icon"></media-volume-off-icon>
|
||||
</media-mute-button>
|
||||
</media-popover-trigger>
|
||||
<media-popover-portal>
|
||||
<media-popover-positioner side="top" side-offset="2" collision-padding="12">
|
||||
<media-popover-popup class="popup-animation">
|
||||
<media-volume-slider-root class="slider-root" orientation="vertical">
|
||||
<media-volume-slider-track class="slider-track">
|
||||
<media-volume-slider-progress class="slider-progress"></media-volume-slider-progress>
|
||||
</media-volume-slider-track>
|
||||
<media-volume-slider-thumb class="slider-thumb"></media-volume-slider-thumb>
|
||||
</media-volume-slider-root>
|
||||
</media-popover-popup>
|
||||
</media-popover-positioner>
|
||||
</media-popover-portal>
|
||||
</media-popover-root>
|
||||
<media-mute-button popovertarget="volume-slider-popover" class="button">
|
||||
<media-volume-high-icon class="icon volume-high-icon"></media-volume-high-icon>
|
||||
<media-volume-low-icon class="icon volume-low-icon"></media-volume-low-icon>
|
||||
<media-volume-off-icon class="icon volume-off-icon"></media-volume-off-icon>
|
||||
</media-mute-button>
|
||||
<media-popover
|
||||
id="volume-slider-popover"
|
||||
class="popup-animation"
|
||||
popover
|
||||
open-on-hover
|
||||
delay="200"
|
||||
close-delay="100"
|
||||
side="top"
|
||||
side-offset="2"
|
||||
collision-padding="12"
|
||||
>
|
||||
<media-volume-slider-root class="slider-root" orientation="vertical">
|
||||
<media-volume-slider-track class="slider-track">
|
||||
<media-volume-slider-progress class="slider-progress"></media-volume-slider-progress>
|
||||
</media-volume-slider-track>
|
||||
<media-volume-slider-thumb class="slider-thumb"></media-volume-slider-thumb>
|
||||
</media-volume-slider-root>
|
||||
</media-popover>
|
||||
|
||||
<media-tooltip-root delay="500" close-delay="0">
|
||||
<media-tooltip-trigger>
|
||||
|
||||
@@ -302,7 +302,11 @@ preview-time-display {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
media-popover-popup {
|
||||
media-popover {
|
||||
margin: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
padding: 0.75rem 0.25rem;
|
||||
border-radius: calc(infinity * 1px);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,109 @@ export function activeElement(
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the document or shadow root of a node, not the node itself which can lead to bugs.
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/Node/getRootNode#return_value
|
||||
* @param node - The node to get the root node from.
|
||||
*/
|
||||
export function getDocumentOrShadowRoot(
|
||||
node: Node,
|
||||
): Document | ShadowRoot | null {
|
||||
const rootNode = node?.getRootNode?.();
|
||||
if (rootNode instanceof ShadowRoot || rootNode instanceof Document) {
|
||||
return rootNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDocument(node: Element | null): Document {
|
||||
return node?.ownerDocument ?? document;
|
||||
}
|
||||
|
||||
export function isElement(value: unknown): value is Element {
|
||||
if (!hasWindow()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value instanceof Element || value instanceof getWindow(value).Element;
|
||||
}
|
||||
|
||||
export function contains(parent?: Element | null, child?: Element | null): boolean {
|
||||
if (!parent || !child) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rootNode = child.getRootNode?.();
|
||||
|
||||
// First, attempt with faster native method
|
||||
if (parent.contains(child)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// then fallback to custom implementation with Shadow DOM support
|
||||
if (rootNode && isShadowRoot(rootNode)) {
|
||||
let next = child;
|
||||
while (next) {
|
||||
if (parent === next) {
|
||||
return true;
|
||||
}
|
||||
// @ts-expect-error - next.host is not defined in the type
|
||||
next = next.parentNode || next.host;
|
||||
}
|
||||
}
|
||||
|
||||
// Give up, the result is false
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getTarget(event: Event): EventTarget | null {
|
||||
if ('composedPath' in event) {
|
||||
return event.composedPath()[0] ?? null;
|
||||
}
|
||||
|
||||
// TS thinks `event` is of type never as it assumes all browsers support
|
||||
// `composedPath()`, but browsers without shadow DOM don't.
|
||||
return (event as Event).target;
|
||||
}
|
||||
|
||||
export function isShadowRoot(value: unknown): value is ShadowRoot {
|
||||
if (!hasWindow() || typeof ShadowRoot === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
value instanceof ShadowRoot || value instanceof getWindow(value).ShadowRoot
|
||||
);
|
||||
}
|
||||
|
||||
function hasWindow() {
|
||||
return typeof window !== 'undefined';
|
||||
}
|
||||
|
||||
export function getWindow(node: any): typeof window {
|
||||
return node?.ownerDocument?.defaultView || window;
|
||||
}
|
||||
|
||||
export interface FloatingNodeType {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
context: FloatingContext;
|
||||
};
|
||||
|
||||
interface FloatingContext {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export function getNodeChildren(
|
||||
nodes: Array<FloatingNodeType>,
|
||||
id: string | undefined,
|
||||
onlyOpenChildren = true,
|
||||
): Array<FloatingNodeType> {
|
||||
const directChildren = nodes.filter(
|
||||
node => node.parentId === id && (!onlyOpenChildren || node.context?.open),
|
||||
);
|
||||
return directChildren.flatMap(child => [
|
||||
child,
|
||||
...getNodeChildren(nodes, child.id, onlyOpenChildren),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ export * from './attributes';
|
||||
export * from './element';
|
||||
export * from './event';
|
||||
export * from './keyboard';
|
||||
export * from './safe-polygon';
|
||||
export * from './shadow-dom';
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import type { FloatingNodeType } from './element';
|
||||
import { contains, getNodeChildren, getTarget, isElement } from './element';
|
||||
|
||||
type Point = [number, number];
|
||||
type Polygon = Point[];
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface HandleClose {
|
||||
(context: HandleCloseContext): (event: MouseEvent) => void;
|
||||
__options?: SafePolygonOptions;
|
||||
}
|
||||
|
||||
interface HandleCloseContext {
|
||||
x: number;
|
||||
y: number;
|
||||
placement: string;
|
||||
elements: Elements;
|
||||
onClose: () => void;
|
||||
nodeId?: string;
|
||||
tree?: {
|
||||
nodesRef: {
|
||||
current: Array<FloatingNodeType>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface Elements {
|
||||
domReference: HTMLElement;
|
||||
floating: HTMLElement;
|
||||
}
|
||||
|
||||
type Side = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
function clearTimeoutIfSet(timeoutRef: { current: number }) {
|
||||
if (timeoutRef.current !== -1) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function isPointInPolygon(point: Point, polygon: Polygon) {
|
||||
const [x, y] = point;
|
||||
let isInside = false;
|
||||
const length = polygon.length;
|
||||
for (let i = 0, j = length - 1; i < length; j = i++) {
|
||||
const [xi, yi] = polygon[i] || [0, 0];
|
||||
const [xj, yj] = polygon[j] || [0, 0];
|
||||
const intersect
|
||||
= (yi >= y) !== (yj >= y) && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||
if (intersect) {
|
||||
isInside = !isInside;
|
||||
}
|
||||
}
|
||||
return isInside;
|
||||
}
|
||||
|
||||
function isInside(point: Point, rect: Rect) {
|
||||
return (
|
||||
point[0] >= rect.x
|
||||
&& point[0] <= rect.x + rect.width
|
||||
&& point[1] >= rect.y
|
||||
&& point[1] <= rect.y + rect.height
|
||||
);
|
||||
}
|
||||
|
||||
export interface SafePolygonOptions {
|
||||
buffer?: number;
|
||||
blockPointerEvents?: boolean;
|
||||
requireIntent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a safe polygon area that the user can traverse without closing the
|
||||
* floating element once leaving the reference element.
|
||||
* @see https://floating-ui.com/docs/useHover#safepolygon
|
||||
*/
|
||||
export function safePolygon(options: SafePolygonOptions = {}): HandleClose {
|
||||
const {
|
||||
buffer = 0.5,
|
||||
blockPointerEvents = false,
|
||||
requireIntent = true,
|
||||
} = options;
|
||||
|
||||
const timeoutRef = { current: -1 };
|
||||
|
||||
let hasLanded = false;
|
||||
let lastX: number | null = null;
|
||||
let lastY: number | null = null;
|
||||
let lastCursorTime
|
||||
= typeof performance !== 'undefined' ? performance.now() : 0;
|
||||
|
||||
function getCursorSpeed(x: number, y: number): number | null {
|
||||
const currentTime = performance.now();
|
||||
const elapsedTime = currentTime - lastCursorTime;
|
||||
|
||||
if (lastX === null || lastY === null || elapsedTime === 0) {
|
||||
lastX = x;
|
||||
lastY = y;
|
||||
lastCursorTime = currentTime;
|
||||
return null;
|
||||
}
|
||||
|
||||
const deltaX = x - lastX;
|
||||
const deltaY = y - lastY;
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
const speed = distance / elapsedTime; // px / ms
|
||||
|
||||
lastX = x;
|
||||
lastY = y;
|
||||
lastCursorTime = currentTime;
|
||||
|
||||
return speed;
|
||||
}
|
||||
|
||||
const fn: HandleClose = ({
|
||||
x,
|
||||
y,
|
||||
placement,
|
||||
elements,
|
||||
onClose,
|
||||
nodeId,
|
||||
tree,
|
||||
}) => {
|
||||
return function onMouseMove(event: MouseEvent) {
|
||||
function close() {
|
||||
clearTimeoutIfSet(timeoutRef);
|
||||
onClose();
|
||||
}
|
||||
|
||||
clearTimeoutIfSet(timeoutRef);
|
||||
|
||||
if (
|
||||
!elements.domReference
|
||||
|| !elements.floating
|
||||
|| placement == null
|
||||
|| x == null
|
||||
|| y == null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { clientX, clientY } = event;
|
||||
const clientPoint: Point = [clientX, clientY];
|
||||
const target = getTarget(event) as Element | null;
|
||||
const isLeave = event.type === 'mouseleave';
|
||||
const isOverFloatingEl = contains(elements.floating, target);
|
||||
const isOverReferenceEl = contains(elements.domReference, target);
|
||||
const refRect = elements.domReference.getBoundingClientRect();
|
||||
const rect = elements.floating.getBoundingClientRect();
|
||||
const side = placement.split('-')[0] as Side;
|
||||
const cursorLeaveFromRight = x > rect.right - rect.width / 2;
|
||||
const cursorLeaveFromBottom = y > rect.bottom - rect.height / 2;
|
||||
const isOverReferenceRect = isInside(clientPoint, refRect);
|
||||
const isFloatingWider = rect.width > refRect.width;
|
||||
const isFloatingTaller = rect.height > refRect.height;
|
||||
const left = (isFloatingWider ? refRect : rect).left;
|
||||
const right = (isFloatingWider ? refRect : rect).right;
|
||||
const top = (isFloatingTaller ? refRect : rect).top;
|
||||
const bottom = (isFloatingTaller ? refRect : rect).bottom;
|
||||
|
||||
if (isOverFloatingEl) {
|
||||
hasLanded = true;
|
||||
|
||||
if (!isLeave) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isOverReferenceEl) {
|
||||
hasLanded = false;
|
||||
}
|
||||
|
||||
if (isOverReferenceEl && !isLeave) {
|
||||
hasLanded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent overlapping floating element from being stuck in an open-close
|
||||
// loop: https://github.com/floating-ui/floating-ui/issues/1910
|
||||
if (
|
||||
isLeave
|
||||
&& isElement(event.relatedTarget)
|
||||
&& contains(elements.floating, event.relatedTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If any nested child is open, abort.
|
||||
if (tree && getNodeChildren(tree.nodesRef.current, nodeId).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the pointer is leaving from the opposite side, the "buffer" logic
|
||||
// creates a point where the floating element remains open, but should be
|
||||
// ignored.
|
||||
// A constant of 1 handles floating point rounding errors.
|
||||
if (
|
||||
(side === 'top' && y >= refRect.bottom - 1)
|
||||
|| (side === 'bottom' && y <= refRect.top + 1)
|
||||
|| (side === 'left' && x >= refRect.right - 1)
|
||||
|| (side === 'right' && x <= refRect.left + 1)
|
||||
) {
|
||||
return close();
|
||||
}
|
||||
|
||||
// Ignore when the cursor is within the rectangular trough between the
|
||||
// two elements. Since the triangle is created from the cursor point,
|
||||
// which can start beyond the ref element's edge, traversing back and
|
||||
// forth from the ref to the floating element can cause it to close. This
|
||||
// ensures it always remains open in that case.
|
||||
let rectPoly: Point[] = [];
|
||||
|
||||
switch (side) {
|
||||
case 'top':
|
||||
rectPoly = [
|
||||
[left, refRect.top + 1],
|
||||
[left, rect.bottom - 1],
|
||||
[right, rect.bottom - 1],
|
||||
[right, refRect.top + 1],
|
||||
];
|
||||
break;
|
||||
case 'bottom':
|
||||
rectPoly = [
|
||||
[left, rect.top + 1],
|
||||
[left, refRect.bottom - 1],
|
||||
[right, refRect.bottom - 1],
|
||||
[right, rect.top + 1],
|
||||
];
|
||||
break;
|
||||
case 'left':
|
||||
rectPoly = [
|
||||
[rect.right - 1, bottom],
|
||||
[rect.right - 1, top],
|
||||
[refRect.left + 1, top],
|
||||
[refRect.left + 1, bottom],
|
||||
];
|
||||
break;
|
||||
case 'right':
|
||||
rectPoly = [
|
||||
[refRect.right - 1, bottom],
|
||||
[refRect.right - 1, top],
|
||||
[rect.left + 1, top],
|
||||
[rect.left + 1, bottom],
|
||||
];
|
||||
break;
|
||||
default:
|
||||
rectPoly = [];
|
||||
break;
|
||||
}
|
||||
|
||||
function getPolygon([x, y]: Point): Array<Point> {
|
||||
switch (side) {
|
||||
case 'top': {
|
||||
const cursorPointOne: Point = [
|
||||
isFloatingWider
|
||||
? x + buffer / 2
|
||||
: cursorLeaveFromRight
|
||||
? x + buffer * 4
|
||||
: x - buffer * 4,
|
||||
y + buffer + 1,
|
||||
];
|
||||
const cursorPointTwo: Point = [
|
||||
isFloatingWider
|
||||
? x - buffer / 2
|
||||
: cursorLeaveFromRight
|
||||
? x + buffer * 4
|
||||
: x - buffer * 4,
|
||||
y + buffer + 1,
|
||||
];
|
||||
const commonPoints: [Point, Point] = [
|
||||
[
|
||||
rect.left,
|
||||
cursorLeaveFromRight
|
||||
? rect.bottom - buffer
|
||||
: isFloatingWider
|
||||
? rect.bottom - buffer
|
||||
: rect.top,
|
||||
],
|
||||
[
|
||||
rect.right,
|
||||
cursorLeaveFromRight
|
||||
? isFloatingWider
|
||||
? rect.bottom - buffer
|
||||
: rect.top
|
||||
: rect.bottom - buffer,
|
||||
],
|
||||
];
|
||||
|
||||
return [cursorPointOne, cursorPointTwo, ...commonPoints];
|
||||
}
|
||||
case 'bottom': {
|
||||
const cursorPointOne: Point = [
|
||||
isFloatingWider
|
||||
? x + buffer / 2
|
||||
: cursorLeaveFromRight
|
||||
? x + buffer * 4
|
||||
: x - buffer * 4,
|
||||
y - buffer,
|
||||
];
|
||||
const cursorPointTwo: Point = [
|
||||
isFloatingWider
|
||||
? x - buffer / 2
|
||||
: cursorLeaveFromRight
|
||||
? x + buffer * 4
|
||||
: x - buffer * 4,
|
||||
y - buffer,
|
||||
];
|
||||
const commonPoints: [Point, Point] = [
|
||||
[
|
||||
rect.left,
|
||||
cursorLeaveFromRight
|
||||
? rect.top + buffer
|
||||
: isFloatingWider
|
||||
? rect.top + buffer
|
||||
: rect.bottom,
|
||||
],
|
||||
[
|
||||
rect.right,
|
||||
cursorLeaveFromRight
|
||||
? isFloatingWider
|
||||
? rect.top + buffer
|
||||
: rect.bottom
|
||||
: rect.top + buffer,
|
||||
],
|
||||
];
|
||||
|
||||
return [cursorPointOne, cursorPointTwo, ...commonPoints];
|
||||
}
|
||||
case 'left': {
|
||||
const cursorPointOne: Point = [
|
||||
x + buffer + 1,
|
||||
isFloatingTaller
|
||||
? y + buffer / 2
|
||||
: cursorLeaveFromBottom
|
||||
? y + buffer * 4
|
||||
: y - buffer * 4,
|
||||
];
|
||||
const cursorPointTwo: Point = [
|
||||
x + buffer + 1,
|
||||
isFloatingTaller
|
||||
? y - buffer / 2
|
||||
: cursorLeaveFromBottom
|
||||
? y + buffer * 4
|
||||
: y - buffer * 4,
|
||||
];
|
||||
const commonPoints: [Point, Point] = [
|
||||
[
|
||||
cursorLeaveFromBottom
|
||||
? rect.right - buffer
|
||||
: isFloatingTaller
|
||||
? rect.right - buffer
|
||||
: rect.left,
|
||||
rect.top,
|
||||
],
|
||||
[
|
||||
cursorLeaveFromBottom
|
||||
? isFloatingTaller
|
||||
? rect.right - buffer
|
||||
: rect.left
|
||||
: rect.right - buffer,
|
||||
rect.bottom,
|
||||
],
|
||||
];
|
||||
|
||||
return [...commonPoints, cursorPointOne, cursorPointTwo];
|
||||
}
|
||||
case 'right': {
|
||||
const cursorPointOne: Point = [
|
||||
x - buffer,
|
||||
isFloatingTaller
|
||||
? y + buffer / 2
|
||||
: cursorLeaveFromBottom
|
||||
? y + buffer * 4
|
||||
: y - buffer * 4,
|
||||
];
|
||||
const cursorPointTwo: Point = [
|
||||
x - buffer,
|
||||
isFloatingTaller
|
||||
? y - buffer / 2
|
||||
: cursorLeaveFromBottom
|
||||
? y + buffer * 4
|
||||
: y - buffer * 4,
|
||||
];
|
||||
const commonPoints: [Point, Point] = [
|
||||
[
|
||||
cursorLeaveFromBottom
|
||||
? rect.left + buffer
|
||||
: isFloatingTaller
|
||||
? rect.left + buffer
|
||||
: rect.right,
|
||||
rect.top,
|
||||
],
|
||||
[
|
||||
cursorLeaveFromBottom
|
||||
? isFloatingTaller
|
||||
? rect.left + buffer
|
||||
: rect.right
|
||||
: rect.left + buffer,
|
||||
rect.bottom,
|
||||
],
|
||||
];
|
||||
|
||||
return [cursorPointOne, cursorPointTwo, ...commonPoints];
|
||||
}
|
||||
default:
|
||||
return [[x, y]];
|
||||
}
|
||||
}
|
||||
|
||||
if (isPointInPolygon([clientX, clientY], rectPoly)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasLanded && !isOverReferenceRect) {
|
||||
return close();
|
||||
}
|
||||
|
||||
if (!isLeave && requireIntent) {
|
||||
const cursorSpeed = getCursorSpeed(event.clientX, event.clientY);
|
||||
const cursorSpeedThreshold = 0.1;
|
||||
if (cursorSpeed !== null && cursorSpeed < cursorSpeedThreshold) {
|
||||
return close();
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPointInPolygon([clientX, clientY], getPolygon([x, y]))) {
|
||||
close();
|
||||
} else if (!hasLanded && requireIntent) {
|
||||
timeoutRef.current = window.setTimeout(close, 40);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
fn.__options = {
|
||||
blockPointerEvents,
|
||||
};
|
||||
|
||||
return fn;
|
||||
}
|
||||
Generated
+3
@@ -155,6 +155,9 @@ importers:
|
||||
|
||||
packages/html:
|
||||
dependencies:
|
||||
'@floating-ui/core':
|
||||
specifier: ^1.6.13
|
||||
version: 1.7.3
|
||||
'@floating-ui/dom':
|
||||
specifier: ^1.6.13
|
||||
version: 1.7.4
|
||||
|
||||
Reference in New Issue
Block a user