From d2a0b27272f1967e5f7cde2df12920af92e3c300 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Tue, 7 Oct 2025 16:22:05 -0500 Subject: [PATCH] feat: add HTML tooltip component (#40) --- .../html/html/src/components/media-popover.ts | 63 +++- .../html/html/src/components/media-tooltip.ts | 338 ++++++++++++++++++ .../html/html/src/skins/media-skin-default.ts | 98 ++++- packages/html/html/src/utils/element-utils.ts | 12 + .../react/react/src/components/Popover.tsx | 8 +- .../react/react/src/components/Tooltip.tsx | 8 +- .../src/skins/default/MediaSkinDefault.tsx | 6 +- .../react/react/src/skins/default/styles.ts | 2 +- 8 files changed, 502 insertions(+), 33 deletions(-) create mode 100644 packages/html/html/src/components/media-tooltip.ts diff --git a/packages/html/html/src/components/media-popover.ts b/packages/html/html/src/components/media-popover.ts index 0a7beb54..50377d1d 100644 --- a/packages/html/html/src/components/media-popover.ts +++ b/packages/html/html/src/components/media-popover.ts @@ -1,6 +1,7 @@ import type { Placement } from '@floating-ui/dom'; import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'; +import { uniqueId } from '../utils/element-utils'; export class MediaPopoverRoot extends HTMLElement { #open = false; @@ -42,12 +43,16 @@ export class MediaPopoverRoot extends HTMLElement { 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.querySelector('media-popover-positioner') as MediaPopoverPositioner | null; + return this.#portalElement?.querySelector('media-popover-positioner') as MediaPopoverPositioner | null; } get #popupElement(): MediaPopoverPopup | null { - return this.querySelector('media-popover-popup') as MediaPopoverPopup | null; + return this.#portalElement?.querySelector('media-popover-popup') as MediaPopoverPopup | null; } #setOpen(open: boolean): void { @@ -138,6 +143,53 @@ export class MediaPopoverTrigger extends HTMLElement { } } +export class MediaPopoverPortal extends HTMLElement { + #portal: HTMLElement | null = null; + + connectedCallback(): void { + this.style.display = 'contents'; + this.#setupPortal(); + } + + disconnectedCallback(): void { + this.#cleanupPortal(); + } + + querySelector(selector: string): HTMLElement | null { + return this.#portal?.querySelector(selector) ?? null; + } + + handleEvent(event: Event): void { + this.dispatchEvent(new Event(event.type, { bubbles: true })); + } + + #setupPortal(): void { + const portalId = this.getAttribute('root-id'); + if (!portalId) return; + + const portalContainer = (this.getRootNode() as ShadowRoot | Document).getElementById(portalId); + if (!portalContainer) return; + + portalContainer.addEventListener('mouseenter', this); + portalContainer.addEventListener('mouseleave', this); + + this.#portal = document.createElement('div'); + this.#portal.id = uniqueId(); + portalContainer.append(this.#portal); + + this.#portal.append(...this.children); + } + + #cleanupPortal(): void { + if (!this.#portal) return; + + // Move children back to the portal element + this.append(...this.#portal.children); + this.#portal.remove(); + this.#portal = null; + } +} + export class MediaPopoverPositioner extends HTMLElement { connectedCallback(): void { this.style.display = 'contents'; @@ -148,7 +200,6 @@ export class MediaPopoverPositioner extends HTMLElement { position: 'absolute', top: '0', left: '0', - zIndex: '1000', }); } } @@ -177,6 +228,10 @@ 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); } @@ -188,11 +243,13 @@ if (!globalThis.customElements.get('media-popover-popup')) { 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, }; diff --git a/packages/html/html/src/components/media-tooltip.ts b/packages/html/html/src/components/media-tooltip.ts new file mode 100644 index 00000000..3e44eda6 --- /dev/null +++ b/packages/html/html/src/components/media-tooltip.ts @@ -0,0 +1,338 @@ +import type { Placement } from '@floating-ui/dom'; +import type { MediaContainer } from '@/media-container'; + +import { autoUpdate, computePosition, flip, offset, shift, arrow } from '@floating-ui/dom'; +import { uniqueId } from '../utils/element-utils'; + +export class MediaTooltipRoot extends HTMLElement { + #open = false; + #hoverTimeout: ReturnType | null = null; + #cleanup: (() => void) | null = null; + #arrowElement: HTMLElement | null = null; + + constructor() { + super(); + this.addEventListener('mouseenter', this); + this.addEventListener('mouseleave', this); + } + + handleEvent(event: Event): void { + if (event.type === 'mouseenter') { + this.#handleMouseEnter(); + } else if (event.type === 'mouseleave') { + this.#handleMouseLeave(); + } + } + + connectedCallback(): void { + this.#updateVisibility(); + } + + disconnectedCallback(): void { + this.#clearHoverTimeout(); + this.#cleanup?.(); + } + + static get observedAttributes(): string[] { + return ['delay', 'close-delay']; + } + + get delay(): number { + return parseInt(this.getAttribute('delay') ?? '600', 10); + } + + get closeDelay(): number { + return parseInt(this.getAttribute('close-delay') ?? '0', 10); + } + + get #triggerElement(): MediaTooltipTrigger | null { + return this.querySelector('media-tooltip-trigger') as MediaTooltipTrigger | null; + } + + get #portalElement(): MediaTooltipPortal | null { + return this.querySelector('media-tooltip-portal') as MediaTooltipPortal | null; + } + + get #positionerElement(): MediaTooltipPositioner | null { + return this.#portalElement?.querySelector('media-tooltip-positioner') as MediaTooltipPositioner | null; + } + + get #popupElement(): MediaTooltipPopup | null { + return this.#portalElement?.querySelector('media-tooltip-popup') as MediaTooltipPopup | null; + } + + #setOpen(open: boolean): void { + if (this.#open === open) return; + + this.#open = open; + this.#updateVisibility(); + + if (open) { + this.#setupFloating(); + } else { + this.#cleanup?.(); + this.#cleanup = null; + } + } + + #updateVisibility(): void { + this.toggleAttribute('data-open', this.#open); + this.style.display = 'contents'; + + if (this.#popupElement) { + this.#popupElement.style.display = this.#open ? 'block' : 'none'; + } + + // Update trigger aria-expanded + const triggerElement = this.#triggerElement?.firstElementChild as HTMLElement; + if (triggerElement) { + triggerElement.setAttribute('aria-expanded', this.#open.toString()); + } + } + + #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 ?? 0; + const collisionPadding = this.#positionerElement?.collisionPadding ?? 0; + const mediaContainer = this.closest('media-container') as MediaContainer; + + this.#arrowElement = popup.querySelector('media-tooltip-arrow') as HTMLElement; + + const updatePosition = () => { + const middleware = [ + offset(sideOffset), + flip(), + shift({ + boundary: mediaContainer, + padding: collisionPadding + }), + ]; + + // Add arrow middleware if arrow element exists + if (this.#arrowElement) { + middleware.push(arrow({ element: this.#arrowElement })); + } + + computePosition(trigger, popup, { + placement, + middleware, + }).then(({ x, y, middlewareData }: { x: number; y: number; middlewareData: any }) => { + Object.assign(popup.style, { + left: `${x}px`, + top: `${y}px`, + }); + + // Position arrow if it exists + if (this.#arrowElement && middlewareData.arrow) { + const { x: arrowX, y: arrowY } = middlewareData.arrow; + Object.assign(this.#arrowElement.style, { + left: arrowX != null ? `${arrowX}px` : undefined, + top: arrowY != null ? `${arrowY}px` : undefined, + }); + } + }); + }; + + updatePosition(); + this.#cleanup = autoUpdate(trigger, popup, updatePosition); + } + + #clearHoverTimeout(): void { + if (this.#hoverTimeout) { + clearTimeout(this.#hoverTimeout); + this.#hoverTimeout = null; + } + } + + #handleMouseEnter(): void { + this.#clearHoverTimeout(); + this.#hoverTimeout = globalThis.setTimeout(() => { + this.#setOpen(true); + }, this.delay); + } + + #handleMouseLeave(): void { + this.#clearHoverTimeout(); + this.#hoverTimeout = globalThis.setTimeout(() => { + this.#setOpen(false); + }, this.closeDelay); + } +} + +export class MediaTooltipTrigger extends HTMLElement { + connectedCallback(): void { + this.style.display = 'contents'; + + const triggerElement = this.firstElementChild as HTMLElement; + if (triggerElement) { + triggerElement.setAttribute('aria-describedby', 'tooltip'); + triggerElement.setAttribute('aria-expanded', 'false'); + + const mutationObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes') { + const rootElement = this.closest('media-tooltip-root') as MediaTooltipRoot; + let popupElement = rootElement.querySelector('media-tooltip-popup') as MediaTooltipPopup; + + if (!popupElement) { + const portalElement = rootElement.querySelector('media-tooltip-portal') as MediaTooltipPortal; + if (!portalElement) { + return; + } + + popupElement = portalElement.querySelector('media-tooltip-popup') as MediaTooltipPopup; + if (!popupElement) { + return; + } + } + + const attributeName = mutation.attributeName; + if (!attributeName) { + return; + } + + const attributeValue = triggerElement.getAttribute(attributeName); + if (attributeValue !== null) { + popupElement.setAttribute(attributeName, attributeValue); + } else { + popupElement.removeAttribute(attributeName); + } + } + }); + }); + + mutationObserver.observe(triggerElement, { + attributes: true, + }); + } + } +} + +export class MediaTooltipPortal extends HTMLElement { + #portal: HTMLElement | null = null; + + connectedCallback(): void { + this.style.display = 'contents'; + this.#setupPortal(); + } + + disconnectedCallback(): void { + this.#cleanupPortal(); + } + + querySelector(selector: string): HTMLElement | null { + return this.#portal?.querySelector(selector) ?? null; + } + + #setupPortal(): void { + const portalId = this.getAttribute('root-id'); + if (!portalId) return; + + const portalContainer = (this.getRootNode() as ShadowRoot | Document).getElementById(portalId); + if (!portalContainer) return; + + this.#portal = document.createElement('div'); + this.#portal.id = uniqueId(); + portalContainer.append(this.#portal); + + this.#portal.append(...this.children); + } + + #cleanupPortal(): void { + if (!this.#portal) return; + + // Move children back to the portal element + this.append(...this.#portal.children); + this.#portal.remove(); + this.#portal = null; + } +} + +export class MediaTooltipPositioner 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) ?? 'top'; + } + + get sideOffset(): number { + return parseInt(this.getAttribute('side-offset') ?? '0', 10); + } + + get collisionPadding(): number { + return parseInt(this.getAttribute('collision-padding') ?? '0', 10); + } +} + +export class MediaTooltipPopup extends HTMLElement { + connectedCallback(): void { + this.setAttribute('role', 'tooltip'); + this.style.display = 'none'; + } +} + +export class MediaTooltipArrow extends HTMLElement { + connectedCallback(): void { + this.setAttribute('aria-hidden', 'true'); + } +} + +if (!globalThis.customElements.get('media-tooltip-root')) { + globalThis.customElements.define('media-tooltip-root', MediaTooltipRoot); +} + +if (!globalThis.customElements.get('media-tooltip-trigger')) { + globalThis.customElements.define('media-tooltip-trigger', MediaTooltipTrigger); +} + +if (!globalThis.customElements.get('media-tooltip-portal')) { + globalThis.customElements.define('media-tooltip-portal', MediaTooltipPortal); +} + +if (!globalThis.customElements.get('media-tooltip-positioner')) { + globalThis.customElements.define('media-tooltip-positioner', MediaTooltipPositioner); +} + +if (!globalThis.customElements.get('media-tooltip-popup')) { + globalThis.customElements.define('media-tooltip-popup', MediaTooltipPopup); +} + +if (!globalThis.customElements.get('media-tooltip-arrow')) { + globalThis.customElements.define('media-tooltip-arrow', MediaTooltipArrow); +} + +export const Tooltip: { + Root: typeof MediaTooltipRoot; + Trigger: typeof MediaTooltipTrigger; + Portal: typeof MediaTooltipPortal; + Positioner: typeof MediaTooltipPositioner; + Popup: typeof MediaTooltipPopup; + Arrow: typeof MediaTooltipArrow; +} = { + Root: MediaTooltipRoot, + Trigger: MediaTooltipTrigger, + Portal: MediaTooltipPortal, + Positioner: MediaTooltipPositioner, + Popup: MediaTooltipPopup, + Arrow: MediaTooltipArrow, +}; + +export default Tooltip; diff --git a/packages/html/html/src/skins/media-skin-default.ts b/packages/html/html/src/skins/media-skin-default.ts index 1f7237b1..cdf6e8b3 100644 --- a/packages/html/html/src/skins/media-skin-default.ts +++ b/packages/html/html/src/skins/media-skin-default.ts @@ -9,9 +9,13 @@ import '../components/media-fullscreen-button'; import '../components/media-duration-display'; import '../components/media-current-time-display'; import '../components/media-popover'; +import '../components/media-tooltip'; import '@vjs-10/html-icons'; +import { uniqueId } from '../utils/element-utils'; export function getTemplateHTML() { + const portalId = uniqueId(); + return /* html */ ` ${MediaSkin.getTemplateHTML()} +
- - - - + + + + + + + + + + + Play + Pause + + + + @@ -270,21 +318,35 @@ export function getTemplateHTML() { - - - - - - - - - - + + + + + + + + + + + + - - - - + + + + + + + + + + + Enter Fullscreen + Exit Fullscreen + + + +
diff --git a/packages/html/html/src/utils/element-utils.ts b/packages/html/html/src/utils/element-utils.ts index 7238a84f..e3239a21 100644 --- a/packages/html/html/src/utils/element-utils.ts +++ b/packages/html/html/src/utils/element-utils.ts @@ -7,3 +7,15 @@ export function namedNodeMapToObject(namedNodeMap: NamedNodeMap): Record | null; - id?: string; + root?: HTMLElement | ShadowRoot | React.MutableRefObject | null; + rootId?: string; } const PopoverContext = createContext(null); @@ -149,8 +149,8 @@ function PopoverPopup({ className, children }: PopoverPopupProps): JSX.Element { ); } -function PopoverPortal({ children, container, id }: PopoverPortalProps): JSX.Element { - return {children}; +function PopoverPortal({ children, root, rootId }: PopoverPortalProps): JSX.Element { + return {children}; } // Export compound component diff --git a/packages/react/react/src/components/Tooltip.tsx b/packages/react/react/src/components/Tooltip.tsx index e2c23605..6698a52f 100644 --- a/packages/react/react/src/components/Tooltip.tsx +++ b/packages/react/react/src/components/Tooltip.tsx @@ -65,8 +65,8 @@ interface TooltipArrowProps { interface TooltipPortalProps { children: ReactNode; - container?: HTMLElement | ShadowRoot | React.MutableRefObject | null; - id?: string; + root?: HTMLElement | ShadowRoot | React.MutableRefObject | null; + rootId?: string; } const TooltipContext = createContext(null); @@ -226,8 +226,8 @@ function TooltipArrow({ className = '', children }: TooltipArrowProps): JSX.Elem ); } -function TooltipPortal({ children, container, id }: TooltipPortalProps): JSX.Element { - return {children}; +function TooltipPortal({ children, root, rootId }: TooltipPortalProps): JSX.Element { + return {children}; } // Export compound component diff --git a/packages/react/react/src/skins/default/MediaSkinDefault.tsx b/packages/react/react/src/skins/default/MediaSkinDefault.tsx index aa374fe8..4aaf4bf5 100644 --- a/packages/react/react/src/skins/default/MediaSkinDefault.tsx +++ b/packages/react/react/src/skins/default/MediaSkinDefault.tsx @@ -45,7 +45,7 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps - + Play @@ -81,7 +81,7 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps - + @@ -102,7 +102,7 @@ export default function MediaSkinDefault({ children, className = '' }: SkinProps - + Enter Fullscreen diff --git a/packages/react/react/src/skins/default/styles.ts b/packages/react/react/src/skins/default/styles.ts index 70040c60..a2fcce7f 100644 --- a/packages/react/react/src/skins/default/styles.ts +++ b/packages/react/react/src/skins/default/styles.ts @@ -4,7 +4,7 @@ import { cn } from "../../utils/cn"; const styles: MediaDefaultSkinStyles = { MediaContainer: cn( - 'media-container relative @container/root group/root overflow-clip', + 'relative @container/root group/root overflow-clip', // Base typography 'text-sm', // Prevent rounded corners in fullscreen.