From 17b632f5f9c7ad6c4d84115e7ef42ee02e4ac2ba Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Thu, 2 Oct 2025 14:09:33 -0500 Subject: [PATCH] feat: add media-popover, cleanup html demo (#34) --- examples/html-demo/src/main.ts | 4 +- packages/html/html/package.json | 1 + .../html/html/src/components/media-popover.ts | 200 ++++++++++++++++++ .../html/html/src/skins/media-skin-default.ts | 57 +++-- pnpm-lock.yaml | 3 + 5 files changed, 244 insertions(+), 21 deletions(-) create mode 100644 packages/html/html/src/components/media-popover.ts diff --git a/examples/html-demo/src/main.ts b/examples/html-demo/src/main.ts index 6aa81710..2072129b 100644 --- a/examples/html-demo/src/main.ts +++ b/examples/html-demo/src/main.ts @@ -2,11 +2,11 @@ import { defineVjsPlayer } from '@vjs-10/html'; defineVjsPlayer(); -document.body.innerHTML = ` +document.body.innerHTML = /* html */ `
- +
diff --git a/packages/html/html/package.json b/packages/html/html/package.json index 91be767c..d3550223 100644 --- a/packages/html/html/package.json +++ b/packages/html/html/package.json @@ -31,6 +31,7 @@ ], "license": "Apache-2.0", "dependencies": { + "@floating-ui/dom": "^1.6.13", "@open-wc/context-protocol": "^0.0.9", "@vjs-10/core": "workspace:*", "@vjs-10/html-icons": "workspace:*", diff --git a/packages/html/html/src/components/media-popover.ts b/packages/html/html/src/components/media-popover.ts new file mode 100644 index 00000000..c3303062 --- /dev/null +++ b/packages/html/html/src/components/media-popover.ts @@ -0,0 +1,200 @@ +import type { Placement } from '@floating-ui/dom'; + +import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'; + +export class MediaPopoverRoot extends HTMLElement { + #open = false; + #hoverTimeout: ReturnType | null = null; + #cleanup: (() => void) | null = null; + + constructor() { + super(); + this.addEventListener('mouseenter', this.#handleMouseEnter.bind(this)); + this.addEventListener('mouseleave', this.#handleMouseLeave.bind(this)); + } + + connectedCallback(): void { + this.#updateVisibility(); + } + + disconnectedCallback(): void { + this.#clearHoverTimeout(); + this.#cleanup?.(); + } + + static get observedAttributes(): string[] { + return ['openonhover', 'delay', 'closedelay']; + } + + get openOnHover(): boolean { + return this.hasAttribute('openonhover'); + } + + get delay(): number { + return parseInt(this.getAttribute('delay') ?? '0', 10); + } + + get closeDelay(): number { + return parseInt(this.getAttribute('closedelay') ?? '0', 10); + } + + get #triggerElement(): MediaPopoverTrigger | null { + return this.querySelector('media-popover-trigger') as MediaPopoverTrigger | null; + } + + get #positionerElement(): MediaPopoverPositioner | null { + return this.querySelector('media-popover-positioner') as MediaPopoverPositioner | null; + } + + get #popupElement(): MediaPopoverPopup | null { + return this.querySelector('media-popover-popup') as MediaPopoverPopup | 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'; + } + } + + #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(): void { + if (!this.openOnHover) return; + + this.#clearHoverTimeout(); + this.#hoverTimeout = globalThis.setTimeout(() => { + this.#setOpen(false); + }, this.closeDelay); + } +} + +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'); + } + } +} + +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', + zIndex: '1000', + }); + } + } + + get side(): Placement { + return this.getAttribute('side') as Placement; + } + + get sideOffset(): number { + return parseInt(this.getAttribute('sideoffset') ?? '0', 10); + } +} + +export class MediaPopoverPopup extends HTMLElement { + connectedCallback(): void { + this.setAttribute('role', 'dialog'); + this.setAttribute('aria-modal', 'false'); + } +} + +if (!globalThis.customElements.get('media-popover-root')) { + globalThis.customElements.define('media-popover-root', MediaPopoverRoot); +} + +if (!globalThis.customElements.get('media-popover-trigger')) { + globalThis.customElements.define('media-popover-trigger', MediaPopoverTrigger); +} + +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; + Positioner: typeof MediaPopoverPositioner; + Popup: typeof MediaPopoverPopup; +} = { + Root: MediaPopoverRoot, + Trigger: MediaPopoverTrigger, + Positioner: MediaPopoverPositioner, + Popup: MediaPopoverPopup, +}; + +export default Popover; diff --git a/packages/html/html/src/skins/media-skin-default.ts b/packages/html/html/src/skins/media-skin-default.ts index 62c6e8a3..b5683085 100644 --- a/packages/html/html/src/skins/media-skin-default.ts +++ b/packages/html/html/src/skins/media-skin-default.ts @@ -8,6 +8,7 @@ import '../components/media-time-range'; import '../components/media-fullscreen-button'; import '../components/media-duration-display'; import '../components/media-current-time-display'; +import '../components/media-popover'; import '@vjs-10/html-icons'; export function getTemplateHTML() { @@ -24,6 +25,7 @@ export function getTemplateHTML() { } media-container > ::slotted([slot=media]) { + display: block; width: 100%; height: 100%; } @@ -45,7 +47,6 @@ export function getTemplateHTML() { /* Time Display Styling */ media-current-time-display, media-duration-display { - background: rgb(20 20 30 / .7); padding: 4px 8px; color: rgb(238 238 238); font-family: monospace; @@ -59,28 +60,32 @@ export function getTemplateHTML() { /* Generic Media Button Styling */ .button { border: none; - background: rgb(20 20 30 / .7); - padding: 4px; + padding: 8px; cursor: pointer; color: rgb(238 238 238); + display: flex; + align-items: center; + justify-content: center; + min-width: 24px; + min-height: 24px; } .button .icon { - width: 24px; - height: 24px; + width: 18px; + height: 18px; display: none; } /* Media Play Button UI/Styles */ media-play-button:not([data-paused]) .pause-icon, media-play-button[data-paused] .play-icon { - display: inline-block; + display: block; } /* Media Fullscreen Button UI/Styles */ media-fullscreen-button:not([data-fullscreen]) .fullscreen-enter-icon, media-fullscreen-button[data-fullscreen] .fullscreen-exit-icon { - display: inline-block; + display: block; } /* One way to define the "default visible" icon (CJP) */ @@ -89,11 +94,12 @@ export function getTemplateHTML() { media-mute-button[data-volume-level=low] .volume-low-icon, media-mute-button[data-volume-level=medium] .volume-low-icon, media-mute-button[data-volume-level=off] .volume-off-icon { - display: inline-block; + display: block; } /* Media Control Bar UI/Styles */ .control-bar { + background: rgb(20 20 30 / .7); display: flex; align-items: center; justify-content: flex-start; @@ -170,6 +176,11 @@ export function getTemplateHTML() { border-radius: inherit; } + media-popover-popup { + background: rgb(20 20 30 / .7); + padding: 14px 0; + } + /* VolumeRange Component Styles */ media-volume-range-root { display: flex; @@ -251,17 +262,25 @@ export function getTemplateHTML() { - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 308b3773..bda6792b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,6 +173,9 @@ importers: packages/html/html: dependencies: + '@floating-ui/dom': + specifier: ^1.6.13 + version: 1.7.4 '@open-wc/context-protocol': specifier: ^0.0.9 version: 0.0.9