mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user