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:
Wesley Luyten
2025-10-28 21:32:39 -05:00
committed by GitHub
co-authored by Copilot
parent d1583dd47d
commit 419911f2f2
11 changed files with 815 additions and 459 deletions
+1
View File
@@ -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:*",
+177 -389
View File
@@ -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;
+29 -26
View File
@@ -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();
+23 -21
View File
@@ -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>
+5 -1
View File
@@ -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);
}
+23 -21
View File
@@ -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>
+5 -1
View File
@@ -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);
}
+103
View File
@@ -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),
]);
}
+1
View File
@@ -2,4 +2,5 @@ export * from './attributes';
export * from './element';
export * from './event';
export * from './keyboard';
export * from './safe-polygon';
export * from './shadow-dom';
+445
View File
@@ -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;
}
+3
View File
@@ -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