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