From 419911f2f2b9f505700f5becb623bfe12e3878aa Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Tue, 28 Oct 2025 21:32:39 -0500 Subject: [PATCH] feat: idiomatic html markup, use popover API, add safe polygon utility (#143) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/html/package.json | 1 + packages/html/src/components/media-popover.ts | 566 ++++++------------ packages/html/src/components/media-tooltip.ts | 55 +- packages/html/src/skins/frosted/index.ts | 44 +- packages/html/src/skins/frosted/styles.css | 6 +- packages/html/src/skins/minimal/index.ts | 44 +- packages/html/src/skins/minimal/styles.css | 6 +- packages/utils/src/dom/element.ts | 103 ++++ packages/utils/src/dom/index.ts | 1 + packages/utils/src/dom/safe-polygon.ts | 445 ++++++++++++++ pnpm-lock.yaml | 3 + 11 files changed, 815 insertions(+), 459 deletions(-) create mode 100644 packages/utils/src/dom/safe-polygon.ts diff --git a/packages/html/package.json b/packages/html/package.json index 8708239f..1dc9e483 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -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:*", diff --git a/packages/html/src/components/media-popover.ts b/packages/html/src/components/media-popover.ts index eafee19a..43a5884f 100644 --- a/packages/html/src/components/media-popover.ts +++ b/packages/html/src/components/media-popover.ts @@ -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 = { + [K in keyof T]: T[K]; +}; -export class MediaPopoverRoot extends HTMLElement { +type FloatingContext = Prettify & { + elements: { + domReference: HTMLElement; + floating: HTMLElement; + }; +}; + +class Popover extends HTMLElement { #open = false; + #transitionStatus: 'initial' | 'open' | 'close' | 'unmounted' = 'initial'; #hoverTimeout: ReturnType | 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 { - 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; diff --git a/packages/html/src/components/media-tooltip.ts b/packages/html/src/components/media-tooltip.ts index c37fef64..1501e7b9 100644 --- a/packages/html/src/components/media-tooltip.ts +++ b/packages/html/src/components/media-tooltip.ts @@ -9,23 +9,26 @@ export class MediaTooltipRoot extends HTMLElement { #hoverTimeout: ReturnType | 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(); diff --git a/packages/html/src/skins/frosted/index.ts b/packages/html/src/skins/frosted/index.ts index ae85dc5a..cab22679 100644 --- a/packages/html/src/skins/frosted/index.ts +++ b/packages/html/src/skins/frosted/index.ts @@ -70,27 +70,29 @@ export function getTemplateHTML() { - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/packages/html/src/skins/frosted/styles.css b/packages/html/src/skins/frosted/styles.css index 29772b12..489e70a3 100644 --- a/packages/html/src/skins/frosted/styles.css +++ b/packages/html/src/skins/frosted/styles.css @@ -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); } diff --git a/packages/html/src/skins/minimal/index.ts b/packages/html/src/skins/minimal/index.ts index a2fe1975..fc2570d0 100644 --- a/packages/html/src/skins/minimal/index.ts +++ b/packages/html/src/skins/minimal/index.ts @@ -74,27 +74,29 @@ export function getTemplateHTML() {
- - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/packages/html/src/skins/minimal/styles.css b/packages/html/src/skins/minimal/styles.css index 80d5b3c6..091c8792 100644 --- a/packages/html/src/skins/minimal/styles.css +++ b/packages/html/src/skins/minimal/styles.css @@ -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); } diff --git a/packages/utils/src/dom/element.ts b/packages/utils/src/dom/element.ts index b72e4b3d..7ab1e2bf 100644 --- a/packages/utils/src/dom/element.ts +++ b/packages/utils/src/dom/element.ts @@ -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, + id: string | undefined, + onlyOpenChildren = true, +): Array { + const directChildren = nodes.filter( + node => node.parentId === id && (!onlyOpenChildren || node.context?.open), + ); + return directChildren.flatMap(child => [ + child, + ...getNodeChildren(nodes, child.id, onlyOpenChildren), + ]); +} diff --git a/packages/utils/src/dom/index.ts b/packages/utils/src/dom/index.ts index ccbf7ceb..25c20af9 100644 --- a/packages/utils/src/dom/index.ts +++ b/packages/utils/src/dom/index.ts @@ -2,4 +2,5 @@ export * from './attributes'; export * from './element'; export * from './event'; export * from './keyboard'; +export * from './safe-polygon'; export * from './shadow-dom'; diff --git a/packages/utils/src/dom/safe-polygon.ts b/packages/utils/src/dom/safe-polygon.ts new file mode 100644 index 00000000..9d80ff3f --- /dev/null +++ b/packages/utils/src/dom/safe-polygon.ts @@ -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; + }; + }; +} + +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 { + 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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2e32b96..e40355c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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