diff --git a/packages/core/src/dom/ui/menu/menu-viewport-transition.ts b/packages/core/src/dom/ui/menu/menu-viewport-transition.ts
index b5168662..4e82ea69 100644
--- a/packages/core/src/dom/ui/menu/menu-viewport-transition.ts
+++ b/packages/core/src/dom/ui/menu/menu-viewport-transition.ts
@@ -1,9 +1,12 @@
+import { resolveCSSLength } from '@videojs/utils/dom';
+import { PopoverCSSVars } from '../../../core/ui/popover/popover-css-vars';
import { TransitionDataAttrs } from '../../../core/ui/transition';
import { forceLayout } from '../../utils/layout';
import type { MenuViewTransitionState } from './create-menu-view-transition';
export interface MenuViewportTransitionOptions {
minWidth?: number;
+ availableWidth?: number | string;
}
export interface MenuViewportAttrs {
@@ -28,6 +31,7 @@ interface InlineStyleSnapshotEntry {
interface PendingMenuViewTransition {
entering: HTMLElement;
+ availableWidth: number | null;
fromSize: MenuViewSize;
toSize: MenuViewSize;
}
@@ -129,6 +133,19 @@ function resolveMinWidth(options: MenuViewportTransitionOptions | undefined): nu
return options?.minWidth ?? DEFAULT_MENU_VIEWPORT_MIN_WIDTH;
}
+function resolveAvailableWidth(
+ content: HTMLElement,
+ options: MenuViewportTransitionOptions | undefined
+): number | null {
+ const inlineWidth = content.style.getPropertyValue(PopoverCSSVars.availableWidth);
+ const value =
+ options?.availableWidth || inlineWidth || getComputedStyle(content).getPropertyValue(PopoverCSSVars.availableWidth);
+
+ const width = typeof value === 'number' ? value : resolveCSSLength(content, value);
+
+ return Number.isFinite(width) && width > 0 ? width : null;
+}
+
function snapshotInlineStyle(element: HTMLElement): InlineStyleSnapshotEntry[] {
return MENU_VIEW_MEASURE_STYLE_PROPERTIES.map((property) => ({
property,
@@ -147,8 +164,14 @@ function restoreInlineStyle(element: HTMLElement, snapshot: InlineStyleSnapshotE
}
}
-function measureMenuView(view: HTMLElement, minWidth: number): MenuViewSize {
+function measureMenuView(
+ content: HTMLElement,
+ view: HTMLElement,
+ minWidth: number,
+ options?: MenuViewportTransitionOptions
+): MenuViewSize {
const snapshot = snapshotInlineStyle(view);
+ const availableWidth = resolveAvailableWidth(content, options);
try {
view.style.setProperty('position', 'absolute');
@@ -162,10 +185,19 @@ function measureMenuView(view: HTMLElement, minWidth: number): MenuViewSize {
view.style.setProperty('max-width', 'none');
forceLayout(view);
- const rect = view.getBoundingClientRect();
+ let rect = view.getBoundingClientRect();
+ const naturalWidth = Math.ceil(Math.max(minWidth, rect.width, view.scrollWidth));
+ const width = Math.ceil(availableWidth ? Math.max(minWidth, Math.min(naturalWidth, availableWidth)) : naturalWidth);
+
+ if (width !== naturalWidth) {
+ view.style.setProperty('width', `${width}px`);
+ view.style.setProperty('max-width', `${width}px`);
+ forceLayout(view);
+ rect = view.getBoundingClientRect();
+ }
return {
- width: Math.ceil(Math.max(minWidth, rect.width, view.scrollWidth)),
+ width,
height: Math.ceil(Math.max(rect.height, view.scrollHeight)),
};
} finally {
@@ -200,10 +232,11 @@ function prepareEnteringMenuView(
options?: MenuViewportTransitionOptions
): void {
const minWidth = resolveMinWidth(options);
- const fromSize = measureMenuView(rootView, minWidth);
- const toSize = measureMenuView(entering, minWidth);
+ const availableWidth = resolveAvailableWidth(content, options);
+ const fromSize = measureMenuView(content, rootView, minWidth, options);
+ const toSize = measureMenuView(content, entering, minWidth, options);
- state.pending = { entering, fromSize, toSize };
+ state.pending = { entering, availableWidth, fromSize, toSize };
setMenuViewState(rootView, MENU_VIEW_ACTIVE_STATE);
setViewportSize(content, fromSize);
forceLayout(content);
@@ -217,13 +250,15 @@ function startEnteringMenuView(
options?: MenuViewportTransitionOptions
): void {
const minWidth = resolveMinWidth(options);
+ const availableWidth = resolveAvailableWidth(content, options);
const current =
- state.pending?.entering === entering
+ state.pending?.entering === entering && state.pending.availableWidth === availableWidth
? state.pending
: {
entering,
- fromSize: measureMenuView(rootView, minWidth),
- toSize: measureMenuView(entering, minWidth),
+ availableWidth,
+ fromSize: measureMenuView(content, rootView, minWidth, options),
+ toSize: measureMenuView(content, entering, minWidth, options),
};
state.pending = null;
@@ -245,8 +280,8 @@ function startExitingMenuView(
transitionState.pending = null;
const minWidth = resolveMinWidth(options);
- const fromSize = measureMenuView(exiting, minWidth);
- const toSize = measureMenuView(rootView, minWidth);
+ const fromSize = measureMenuView(content, exiting, minWidth, options);
+ const toSize = measureMenuView(content, rootView, minWidth, options);
setViewportSize(content, fromSize);
setMenuViewState(rootView, MENU_VIEW_INACTIVE_STATE);
@@ -261,14 +296,27 @@ export function syncMenuViewRoot(
hasActiveChildView: boolean,
options?: MenuViewportTransitionOptions
): void {
- if (!content || hasActiveChildView) return;
+ if (!content) return;
const viewport = getViewportElement(content);
const rootView = getRootViewElement(viewport);
- if (!rootView || getActiveMenuViewElement(viewport)) return;
+ if (!rootView) return;
- const size = measureMenuView(rootView, resolveMinWidth(options));
+ const activeView = getActiveMenuViewElement(viewport);
+
+ if (activeView) {
+ if (rootView.getAttribute(MENU_VIEW_STATE_ATTR) === MENU_VIEW_INACTIVE_STATE) {
+ const size = measureMenuView(content, activeView, resolveMinWidth(options), options);
+ setViewportSize(content, size);
+ }
+
+ return;
+ }
+
+ if (hasActiveChildView) return;
+
+ const size = measureMenuView(content, rootView, resolveMinWidth(options), options);
setMenuViewState(rootView, MENU_VIEW_ACTIVE_STATE);
setViewportSize(content, size);
@@ -318,7 +366,10 @@ export function syncMenuViewTransition(
const state = getViewportTransitionState(content);
const phaseKey = `${viewState.phase}:${viewState.direction}`;
- if (state.phaseKeys.get(view) === phaseKey) return;
+ const shouldResyncActiveView =
+ viewState.phase === 'active' && rootView.getAttribute(MENU_VIEW_STATE_ATTR) !== MENU_VIEW_INACTIVE_STATE;
+
+ if (state.phaseKeys.get(view) === phaseKey && !shouldResyncActiveView) return;
state.phaseKeys.set(view, phaseKey);
diff --git a/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts b/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts
index c30ef800..09a5ff2f 100644
--- a/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts
+++ b/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts
@@ -55,11 +55,15 @@ function mockMenuViewSize(
currentHeight,
naturalWidth,
naturalHeight,
+ constrainedWidth,
+ constrainedHeight,
}: {
currentWidth: number;
currentHeight: number;
naturalWidth: number;
naturalHeight: number;
+ constrainedWidth?: number;
+ constrainedHeight?: number;
}
): void {
function isMeasuringNaturalSize(): boolean {
@@ -68,18 +72,35 @@ function mockMenuViewSize(
);
}
- element.getBoundingClientRect = vi.fn(() =>
- isMeasuringNaturalSize() ? createRect(naturalWidth, naturalHeight) : createRect(currentWidth, currentHeight)
- );
+ function isMeasuringConstrainedSize(): boolean {
+ return element.style.getPropertyValue('width') === `${constrainedWidth}px`;
+ }
+
+ function getSize(): { width: number; height: number } {
+ if (isMeasuringNaturalSize()) {
+ return { width: naturalWidth, height: naturalHeight };
+ }
+
+ if (constrainedWidth && constrainedHeight && isMeasuringConstrainedSize()) {
+ return { width: constrainedWidth, height: constrainedHeight };
+ }
+
+ return { width: currentWidth, height: currentHeight };
+ }
+
+ element.getBoundingClientRect = vi.fn(() => {
+ const size = getSize();
+ return createRect(size.width, size.height);
+ });
Object.defineProperty(element, 'scrollWidth', {
configurable: true,
- get: () => (isMeasuringNaturalSize() ? naturalWidth : currentWidth),
+ get: () => getSize().width,
});
Object.defineProperty(element, 'scrollHeight', {
configurable: true,
- get: () => (isMeasuringNaturalSize() ? naturalHeight : currentHeight),
+ get: () => getSize().height,
});
}
@@ -190,6 +211,156 @@ describe('menu-viewport-transition', () => {
expect(content.style.getPropertyValue('--media-menu-width')).toBe('160px');
});
+ it('measures the root view height at the available menu width', () => {
+ const content = addElement();
+ const rootView = document.createElement('div');
+
+ applyAttrs(rootView, getMenuRootViewAttrs());
+ content.style.setProperty('--media-popover-available-width', '180px');
+ content.append(rootView);
+
+ mockMenuViewSize(rootView, {
+ currentWidth: 160,
+ currentHeight: 80,
+ naturalWidth: 260,
+ naturalHeight: 80,
+ constrainedWidth: 180,
+ constrainedHeight: 128,
+ });
+
+ syncMenuViewRoot(content, false);
+
+ expect(content.style.getPropertyValue('--media-menu-width')).toBe('180px');
+ expect(content.style.getPropertyValue('--media-menu-height')).toBe('128px');
+ });
+
+ it('measures an entering submenu height at the available menu width', () => {
+ const content = addElement();
+ const rootView = document.createElement('div');
+ const menuView = document.createElement('div');
+
+ applyAttrs(rootView, getMenuRootViewAttrs());
+ menuView.setAttribute('data-menu-view', '');
+ content.style.setProperty('--media-popover-available-width', '180px');
+ content.append(rootView, menuView);
+
+ mockMenuViewSize(rootView, {
+ currentWidth: 160,
+ currentHeight: 100,
+ naturalWidth: 160,
+ naturalHeight: 100,
+ });
+ mockMenuViewSize(menuView, {
+ currentWidth: 160,
+ currentHeight: 100,
+ naturalWidth: 260,
+ naturalHeight: 100,
+ constrainedWidth: 180,
+ constrainedHeight: 148,
+ });
+
+ syncMenuViewTransition(content, menuView, {
+ phase: 'entering',
+ direction: 'forward',
+ triggerId: 'trigger-1',
+ });
+ syncMenuViewTransition(content, menuView, {
+ phase: 'active',
+ direction: 'forward',
+ triggerId: 'trigger-1',
+ });
+
+ expect(content.style.getPropertyValue('--media-menu-width')).toBe('180px');
+ expect(content.style.getPropertyValue('--media-menu-height')).toBe('148px');
+ });
+
+ it('remeasures a pending submenu when the available menu width changes before active', () => {
+ const content = addElement();
+ const rootView = document.createElement('div');
+ const menuView = document.createElement('div');
+
+ applyAttrs(rootView, getMenuRootViewAttrs());
+ menuView.setAttribute('data-menu-view', '');
+ content.append(rootView, menuView);
+
+ mockMenuViewSize(rootView, {
+ currentWidth: 160,
+ currentHeight: 100,
+ naturalWidth: 160,
+ naturalHeight: 100,
+ });
+ mockMenuViewSize(menuView, {
+ currentWidth: 160,
+ currentHeight: 100,
+ naturalWidth: 260,
+ naturalHeight: 100,
+ constrainedWidth: 180,
+ constrainedHeight: 148,
+ });
+
+ syncMenuViewTransition(content, menuView, {
+ phase: 'entering',
+ direction: 'forward',
+ triggerId: 'trigger-1',
+ });
+
+ content.style.setProperty('--media-popover-available-width', '180px');
+
+ syncMenuViewTransition(content, menuView, {
+ phase: 'active',
+ direction: 'forward',
+ triggerId: 'trigger-1',
+ });
+
+ expect(content.style.getPropertyValue('--media-menu-width')).toBe('180px');
+ expect(content.style.getPropertyValue('--media-menu-height')).toBe('148px');
+ });
+
+ it('resyncs an active submenu when the available menu width changes', () => {
+ const content = addElement();
+ const rootView = document.createElement('div');
+ const menuView = document.createElement('div');
+
+ applyAttrs(rootView, getMenuRootViewAttrs());
+ menuView.setAttribute('data-menu-view', '');
+ content.append(rootView, menuView);
+
+ mockMenuViewSize(rootView, {
+ currentWidth: 160,
+ currentHeight: 100,
+ naturalWidth: 160,
+ naturalHeight: 100,
+ });
+ mockMenuViewSize(menuView, {
+ currentWidth: 260,
+ currentHeight: 100,
+ naturalWidth: 260,
+ naturalHeight: 100,
+ constrainedWidth: 180,
+ constrainedHeight: 148,
+ });
+
+ syncMenuViewTransition(content, menuView, {
+ phase: 'entering',
+ direction: 'forward',
+ triggerId: 'trigger-1',
+ });
+ syncMenuViewTransition(content, menuView, {
+ phase: 'active',
+ direction: 'forward',
+ triggerId: 'trigger-1',
+ });
+
+ expect(content.style.getPropertyValue('--media-menu-width')).toBe('260px');
+ expect(content.style.getPropertyValue('--media-menu-height')).toBe('100px');
+
+ content.style.setProperty('--media-popover-available-width', '180px');
+ syncMenuViewRoot(content, true);
+
+ expect(content.style.getPropertyValue('--media-menu-width')).toBe('180px');
+ expect(content.style.getPropertyValue('--media-menu-height')).toBe('148px');
+ });
+
it('forces root view layout around the active submenu transition', () => {
const content = addElement();
const rootView = document.createElement('div');
diff --git a/packages/html/src/define/audio/minimal-skin.tailwind.ts b/packages/html/src/define/audio/minimal-skin.tailwind.ts
index 82ef8020..72dacbe8 100644
--- a/packages/html/src/define/audio/minimal-skin.tailwind.ts
+++ b/packages/html/src/define/audio/minimal-skin.tailwind.ts
@@ -109,7 +109,7 @@ function getTemplateHTML() {
diff --git a/packages/html/src/define/audio/skin.tailwind.ts b/packages/html/src/define/audio/skin.tailwind.ts
index 7e4d5850..0d683112 100644
--- a/packages/html/src/define/audio/skin.tailwind.ts
+++ b/packages/html/src/define/audio/skin.tailwind.ts
@@ -104,7 +104,7 @@ function getTemplateHTML() {
diff --git a/packages/html/src/define/live-video/minimal-skin.tailwind.ts b/packages/html/src/define/live-video/minimal-skin.tailwind.ts
index 611833f4..02c9936f 100644
--- a/packages/html/src/define/live-video/minimal-skin.tailwind.ts
+++ b/packages/html/src/define/live-video/minimal-skin.tailwind.ts
@@ -94,7 +94,7 @@ function getTemplateHTML() {
diff --git a/packages/html/src/define/live-video/skin.tailwind.ts b/packages/html/src/define/live-video/skin.tailwind.ts
index 3f036d14..d89b9e2b 100644
--- a/packages/html/src/define/live-video/skin.tailwind.ts
+++ b/packages/html/src/define/live-video/skin.tailwind.ts
@@ -96,7 +96,7 @@ function getTemplateHTML() {
diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts
index 8acdef9f..ba26d216 100644
--- a/packages/html/src/define/video/minimal-skin.tailwind.ts
+++ b/packages/html/src/define/video/minimal-skin.tailwind.ts
@@ -145,27 +145,27 @@ function getTemplateHTML() {