diff --git a/apps/sandbox/templates/html-ejected-menu-layout/index.html b/apps/sandbox/templates/html-ejected-menu-layout/index.html
new file mode 100644
index 00000000..2d2f5d45
--- /dev/null
+++ b/apps/sandbox/templates/html-ejected-menu-layout/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Sandbox — Ejected HTML menu layout
+
+
+
+
+
+
diff --git a/apps/sandbox/templates/html-ejected-menu-layout/main.ts b/apps/sandbox/templates/html-ejected-menu-layout/main.ts
new file mode 100644
index 00000000..8c9bcab1
--- /dev/null
+++ b/apps/sandbox/templates/html-ejected-menu-layout/main.ts
@@ -0,0 +1,193 @@
+// Ejected HTML menu layout sandbox with Tailwind browser CDN CSS.
+// http://localhost:5173/html-ejected-menu-layout/
+import '@videojs/html/icons/element';
+import '@videojs/html/video/skin.css';
+import '@videojs/html/video/ui';
+
+interface LayoutCall {
+ target: 'menu' | 'root view' | 'submenu view' | 'other';
+ time: number;
+ duration: number;
+}
+
+const SETTLE_TIME = 700;
+
+const root = document.getElementById('root')!;
+const panelCount = Number(new URLSearchParams(location.search).get('panels'));
+const PANEL_COUNT = Number.isInteger(panelCount) && panelCount > 0 && panelCount <= 13 ? panelCount : 13;
+const pageContent = Array.from(
+ { length: 24 },
+ (_, index) => `
+
+
+ ${index + 1}
+ Manage
+
+ Tailwind content card
+ Host-page content to exercise selector matching during layout.
+
+ `
+).join('');
+
+const panels = Array.from({ length: PANEL_COUNT }, (_, index) => {
+ const panelNumber = index + 1;
+
+ return {
+ panelNumber,
+ trigger: `
+
+ `,
+ content: `
+
+ `,
+ };
+});
+
+root.innerHTML = `
+
+
+ Ejected HTML menu layout
+
+ The default video skin in light DOM with ${PANEL_COUNT} mounted settings submenus, Tailwind 4 browser CDN CSS,
+ and host-page content. Use this to compare layout-read counts and duration before and after a menu change.
+
+
+
+
+
+ Record next settings-menu open
+
+ Close the settings menu, then start a recording.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+
+`;
+
+const settingsMenu = document.getElementById('settings-menu')!;
+
+const recordButton = document.getElementById('record-open')!;
+const result = document.getElementById('result')!;
+const status = document.getElementById('status')!;
+const settingsTrigger = document.getElementById('settings-trigger')!;
+const calls: LayoutCall[] = [];
+const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
+let recording = false;
+
+function getTarget(element: Element): LayoutCall['target'] {
+ if (element === settingsMenu) return 'menu';
+ if (element.matches('[data-menu-root-view]')) return 'root view';
+ if (element.matches('[data-menu-view]')) return 'submenu view';
+ return 'other';
+}
+
+function getSummary(): Record {
+ return {
+ menu: calls.filter((call) => call.target === 'menu').length,
+ 'root view': calls.filter((call) => call.target === 'root view').length,
+ 'submenu view': calls.filter((call) => call.target === 'submenu view').length,
+ other: calls.filter((call) => call.target === 'other').length,
+ };
+}
+
+function getDuration(): number {
+ return calls.reduce((duration, call) => duration + call.duration, 0);
+}
+
+Element.prototype.getBoundingClientRect = function (...args): DOMRect {
+ const track = recording && settingsMenu.contains(this);
+ const time = track ? performance.now() : 0;
+ const rect = originalGetBoundingClientRect.apply(this, args);
+
+ if (track) {
+ calls.push({
+ target: getTarget(this),
+ time,
+ duration: performance.now() - time,
+ });
+ }
+
+ return rect;
+};
+
+recordButton.addEventListener('click', () => {
+ calls.length = 0;
+ recording = true;
+ result.textContent = '';
+ status.textContent = 'Recording. Open the settings menu once.';
+});
+
+settingsTrigger.addEventListener('click', () => {
+ if (!recording) return;
+
+ window.setTimeout(() => {
+ recording = false;
+ const summary = getSummary();
+ const total = Object.values(summary).reduce((count, value) => count + value, 0);
+
+ result.textContent = `${total} reads · ${getDuration().toFixed(1)} ms · root ${summary['root view']} · submenu ${summary['submenu view']}`;
+ status.textContent = `Recorded an open with ${settingsMenu.querySelectorAll(':scope > media-menu').length} mounted submenus.`;
+ }, SETTLE_TIME);
+});
+
+Object.assign(window, {
+ menuLayoutProbe: {
+ calls,
+ getSummary,
+ start() {
+ calls.length = 0;
+ recording = true;
+ },
+ stop() {
+ recording = false;
+ },
+ },
+});
+
+window.addEventListener(
+ 'pagehide',
+ () => {
+ Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
+ },
+ { once: true }
+);
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 35ab952f..2f5492db 100644
--- a/packages/core/src/dom/ui/menu/menu-viewport-transition.ts
+++ b/packages/core/src/dom/ui/menu/menu-viewport-transition.ts
@@ -18,7 +18,7 @@ export interface MenuRootViewAttrs {
'data-menu-view': '';
}
-interface MenuViewSize {
+interface ViewSize {
width: number;
height: number;
}
@@ -29,29 +29,29 @@ interface InlineStyleSnapshotEntry {
priority: string;
}
-interface PendingMenuViewTransition {
+interface PendingViewTransition {
entering: HTMLElement;
availableWidth: number | null;
- fromSize: MenuViewSize;
- toSize: MenuViewSize;
+ fromSize: ViewSize;
+ toSize: ViewSize;
}
-interface MenuViewportTransitionState {
- pending: PendingMenuViewTransition | null;
+interface ViewportTransitionState {
+ pending: PendingViewTransition | null;
phaseKeys: WeakMap;
}
-const DEFAULT_MENU_VIEWPORT_MIN_WIDTH = 160;
-const MENU_VIEW_ATTR = 'data-menu-view';
-const MENU_VIEW_STATE_ATTR = 'data-menu-view-state';
-const MENU_VIEW_ACTIVE_STATE = 'active';
-const MENU_VIEW_INACTIVE_STATE = 'inactive';
-const MENU_ROOT_VIEW_ATTR = 'data-menu-root-view';
-const MENU_VIEWPORT_ATTR = 'data-menu-viewport';
-const MENU_VIEW_LAYOUT_ATTRS = ['data-availability'];
-const MENU_WIDTH_VAR = '--media-menu-width';
-const MENU_HEIGHT_VAR = '--media-menu-height';
-const MENU_VIEW_MEASURE_STYLE_PROPERTIES = [
+const DEFAULT_MIN_WIDTH = 160;
+const VIEW_ATTR = 'data-menu-view';
+const VIEW_STATE_ATTR = 'data-menu-view-state';
+const VIEW_ACTIVE_STATE = 'active';
+const VIEW_INACTIVE_STATE = 'inactive';
+const ROOT_VIEW_ATTR = 'data-menu-root-view';
+const VIEWPORT_ATTR = 'data-menu-viewport';
+const VIEW_LAYOUT_ATTRS = ['data-availability'];
+const WIDTH_VAR = '--media-menu-width';
+const HEIGHT_VAR = '--media-menu-height';
+const VIEW_MEASURE_STYLE_PROPERTIES = [
'position',
'top',
'right',
@@ -63,7 +63,7 @@ const MENU_VIEW_MEASURE_STYLE_PROPERTIES = [
'max-width',
];
-const viewportTransitionStates = new WeakMap();
+const viewportTransitionStates = new WeakMap();
export function getMenuViewportAttrs(): MenuViewportAttrs {
return {
@@ -78,7 +78,7 @@ export function getMenuRootViewAttrs(): MenuRootViewAttrs {
};
}
-function getViewportTransitionState(content: HTMLElement): MenuViewportTransitionState {
+function getViewportTransitionState(content: HTMLElement): ViewportTransitionState {
let state = viewportTransitionStates.get(content);
if (!state) {
@@ -95,7 +95,7 @@ function getViewportTransitionState(content: HTMLElement): MenuViewportTransitio
export function getMenuViewportElement(content: HTMLElement | null): HTMLElement | null {
if (!content) return null;
- return content.querySelector(`:scope > [${MENU_VIEWPORT_ATTR}]`) ?? content;
+ return content.querySelector(`:scope > [${VIEWPORT_ATTR}]`) ?? content;
}
function getViewportElement(content: HTMLElement, view?: HTMLElement | null): HTMLElement {
@@ -113,16 +113,16 @@ function getViewportElement(content: HTMLElement, view?: HTMLElement | null): HT
}
function getRootViewElement(viewport: HTMLElement): HTMLElement | null {
- return viewport.querySelector(`:scope > [${MENU_ROOT_VIEW_ATTR}]`);
+ return viewport.querySelector(`:scope > [${ROOT_VIEW_ATTR}]`);
}
-function getActiveMenuViewElement(viewport: HTMLElement): HTMLElement | null {
+function getActiveViewElement(viewport: HTMLElement): HTMLElement | null {
return (
Array.from(viewport.children).find(
(child): child is HTMLElement =>
child instanceof HTMLElement &&
- child.hasAttribute(MENU_VIEW_ATTR) &&
- !child.hasAttribute(MENU_ROOT_VIEW_ATTR) &&
+ child.hasAttribute(VIEW_ATTR) &&
+ !child.hasAttribute(ROOT_VIEW_ATTR) &&
!child.hidden &&
!child.hasAttribute(TransitionDataAttrs.transitionEnding)
) ?? null
@@ -130,7 +130,7 @@ function getActiveMenuViewElement(viewport: HTMLElement): HTMLElement | null {
}
function resolveMinWidth(options: MenuViewportTransitionOptions | undefined): number {
- return options?.minWidth ?? DEFAULT_MENU_VIEWPORT_MIN_WIDTH;
+ return options?.minWidth ?? DEFAULT_MIN_WIDTH;
}
function resolveAvailableWidth(
@@ -147,7 +147,7 @@ function resolveAvailableWidth(
}
function snapshotInlineStyle(element: HTMLElement): InlineStyleSnapshotEntry[] {
- return MENU_VIEW_MEASURE_STYLE_PROPERTIES.map((property) => ({
+ return VIEW_MEASURE_STYLE_PROPERTIES.map((property) => ({
property,
value: element.style.getPropertyValue(property),
priority: element.style.getPropertyPriority(property),
@@ -164,89 +164,132 @@ function restoreInlineStyle(element: HTMLElement, snapshot: InlineStyleSnapshotE
}
}
-function measureMenuView(
+function measureViews(
+ content: HTMLElement,
+ views: readonly [HTMLElement],
+ minWidth: number,
+ options?: MenuViewportTransitionOptions
+): [ViewSize];
+function measureViews(
+ content: HTMLElement,
+ views: readonly [HTMLElement, HTMLElement],
+ minWidth: number,
+ options?: MenuViewportTransitionOptions
+): [ViewSize, ViewSize];
+function measureViews(
+ content: HTMLElement,
+ views: readonly HTMLElement[],
+ minWidth: number,
+ options?: MenuViewportTransitionOptions
+): ViewSize[] {
+ const snapshots = views.map((view) => ({
+ view,
+ snapshot: snapshotInlineStyle(view),
+ }));
+ const availableWidth = resolveAvailableWidth(content, options);
+
+ try {
+ for (const { view } of snapshots) {
+ view.style.setProperty('position', 'absolute');
+ view.style.setProperty('top', '0px');
+ view.style.setProperty('right', 'auto');
+ view.style.setProperty('bottom', 'auto');
+ view.style.setProperty('left', '0px');
+ view.style.setProperty('width', 'max-content');
+ view.style.setProperty('height', 'auto');
+ view.style.setProperty('min-width', `${minWidth}px`);
+ view.style.setProperty('max-width', 'none');
+ }
+
+ const sizes = snapshots.map(({ view }) => {
+ const 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
+ );
+
+ return { view, rect, naturalWidth, width };
+ });
+ const constrained = sizes.filter((size) => size.width !== size.naturalWidth);
+
+ for (const { view, width } of constrained) {
+ view.style.setProperty('width', `${width}px`);
+ view.style.setProperty('max-width', `${width}px`);
+ }
+
+ for (const size of constrained) {
+ size.rect = size.view.getBoundingClientRect();
+ }
+
+ return sizes.map((size) => ({
+ width: size.width,
+ height: Math.ceil(Math.max(size.rect.height, size.view.scrollHeight)),
+ }));
+ } finally {
+ for (const { view, snapshot } of snapshots) restoreInlineStyle(view, snapshot);
+ }
+}
+
+function measureView(
content: HTMLElement,
view: HTMLElement,
minWidth: number,
options?: MenuViewportTransitionOptions
-): MenuViewSize {
- const snapshot = snapshotInlineStyle(view);
- const availableWidth = resolveAvailableWidth(content, options);
-
- try {
- view.style.setProperty('position', 'absolute');
- view.style.setProperty('top', '0px');
- view.style.setProperty('right', 'auto');
- view.style.setProperty('bottom', 'auto');
- view.style.setProperty('left', '0px');
- view.style.setProperty('width', 'max-content');
- view.style.setProperty('height', 'auto');
- view.style.setProperty('min-width', `${minWidth}px`);
- view.style.setProperty('max-width', 'none');
- forceLayout(view);
-
- 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,
- height: Math.ceil(Math.max(rect.height, view.scrollHeight)),
- };
- } finally {
- restoreInlineStyle(view, snapshot);
- forceLayout(view);
- }
+): ViewSize {
+ return measureViews(content, [view], minWidth, options)[0];
}
-function setViewportSize(content: HTMLElement, size: MenuViewSize): void {
- content.style.setProperty(MENU_WIDTH_VAR, `${size.width}px`);
- content.style.setProperty(MENU_HEIGHT_VAR, `${size.height}px`);
+function setViewportSize(content: HTMLElement, size: ViewSize): void {
+ content.style.setProperty(WIDTH_VAR, `${size.width}px`);
+ content.style.setProperty(HEIGHT_VAR, `${size.height}px`);
}
-function setMenuViewState(
- view: HTMLElement,
- state: typeof MENU_VIEW_ACTIVE_STATE | typeof MENU_VIEW_INACTIVE_STATE
-): void {
- view.setAttribute(MENU_VIEW_STATE_ATTR, state);
+function setViewState(view: HTMLElement, state: typeof VIEW_ACTIVE_STATE | typeof VIEW_INACTIVE_STATE): void {
+ view.setAttribute(VIEW_STATE_ATTR, state);
- if (state === MENU_VIEW_ACTIVE_STATE) {
+ if (state === VIEW_ACTIVE_STATE) {
view.setAttribute('data-open', '');
} else {
view.removeAttribute('data-open');
}
}
-function prepareEnteringMenuView(
+function prepareEnteringView(
content: HTMLElement,
rootView: HTMLElement,
entering: HTMLElement,
- state: MenuViewportTransitionState,
+ minWidth: number,
+ availableWidth: number | null,
+ options?: MenuViewportTransitionOptions
+): PendingViewTransition {
+ const [fromSize, toSize] = measureViews(content, [rootView, entering], minWidth, options);
+
+ return { entering, availableWidth, fromSize, toSize };
+}
+
+function prepareEnteringTransition(
+ content: HTMLElement,
+ rootView: HTMLElement,
+ entering: HTMLElement,
+ state: ViewportTransitionState,
options?: MenuViewportTransitionOptions
): void {
const minWidth = resolveMinWidth(options);
const availableWidth = resolveAvailableWidth(content, options);
- const fromSize = measureMenuView(content, rootView, minWidth, options);
- const toSize = measureMenuView(content, entering, minWidth, options);
- state.pending = { entering, availableWidth, fromSize, toSize };
- setMenuViewState(rootView, MENU_VIEW_ACTIVE_STATE);
- setViewportSize(content, fromSize);
+ const pending = prepareEnteringView(content, rootView, entering, minWidth, availableWidth, options);
+
+ state.pending = pending;
+ setViewState(rootView, VIEW_ACTIVE_STATE);
+ setViewportSize(content, pending.fromSize);
forceLayout(content);
}
-function startEnteringMenuView(
+function startEnteringView(
content: HTMLElement,
rootView: HTMLElement,
entering: HTMLElement,
- state: MenuViewportTransitionState,
+ state: ViewportTransitionState,
options?: MenuViewportTransitionOptions
): void {
const minWidth = resolveMinWidth(options);
@@ -254,39 +297,33 @@ function startEnteringMenuView(
const current =
state.pending?.entering === entering && state.pending.availableWidth === availableWidth
? state.pending
- : {
- entering,
- availableWidth,
- fromSize: measureMenuView(content, rootView, minWidth, options),
- toSize: measureMenuView(content, entering, minWidth, options),
- };
+ : prepareEnteringView(content, rootView, entering, minWidth, availableWidth, options);
state.pending = null;
setViewportSize(content, current.fromSize);
forceLayout(rootView);
- setMenuViewState(rootView, MENU_VIEW_INACTIVE_STATE);
+ setViewState(rootView, VIEW_INACTIVE_STATE);
forceLayout(rootView);
setViewportSize(content, current.toSize);
}
-function startExitingMenuView(
+function startExitingView(
content: HTMLElement,
rootView: HTMLElement,
exiting: HTMLElement,
- transitionState: MenuViewportTransitionState,
+ transitionState: ViewportTransitionState,
options?: MenuViewportTransitionOptions
): void {
transitionState.pending = null;
const minWidth = resolveMinWidth(options);
- const fromSize = measureMenuView(content, exiting, minWidth, options);
- const toSize = measureMenuView(content, rootView, minWidth, options);
+ const [fromSize, toSize] = measureViews(content, [exiting, rootView], minWidth, options);
setViewportSize(content, fromSize);
- setMenuViewState(rootView, MENU_VIEW_INACTIVE_STATE);
+ setViewState(rootView, VIEW_INACTIVE_STATE);
forceLayout(rootView);
- setMenuViewState(rootView, MENU_VIEW_ACTIVE_STATE);
+ setViewState(rootView, VIEW_ACTIVE_STATE);
forceLayout(rootView);
setViewportSize(content, toSize);
}
@@ -303,11 +340,11 @@ export function syncMenuViewRoot(
if (!rootView) return;
- const activeView = getActiveMenuViewElement(viewport);
+ const activeView = getActiveViewElement(viewport);
if (activeView) {
- if (rootView.getAttribute(MENU_VIEW_STATE_ATTR) === MENU_VIEW_INACTIVE_STATE) {
- const size = measureMenuView(content, activeView, resolveMinWidth(options), options);
+ if (rootView.getAttribute(VIEW_STATE_ATTR) === VIEW_INACTIVE_STATE) {
+ const size = measureView(content, activeView, resolveMinWidth(options), options);
setViewportSize(content, size);
}
@@ -316,9 +353,9 @@ export function syncMenuViewRoot(
if (hasActiveChildView) return;
- const size = measureMenuView(content, rootView, resolveMinWidth(options), options);
+ const size = measureView(content, rootView, resolveMinWidth(options), options);
- setMenuViewState(rootView, MENU_VIEW_ACTIVE_STATE);
+ setViewState(rootView, VIEW_ACTIVE_STATE);
setViewportSize(content, size);
}
@@ -353,7 +390,7 @@ export function observeMenuViewContent(content: HTMLElement, onChange: () => voi
subtree: true,
attributes: true,
attributeOldValue: true,
- attributeFilter: MENU_VIEW_LAYOUT_ATTRS,
+ attributeFilter: VIEW_LAYOUT_ATTRS,
});
return () => {
@@ -377,29 +414,31 @@ export function syncMenuViewTransition(
const state = getViewportTransitionState(content);
const phaseKey = `${viewState.phase}:${viewState.direction}`;
+ const previousPhaseKey = state.phaseKeys.get(view);
const shouldResyncActiveView =
- viewState.phase === 'active' && rootView.getAttribute(MENU_VIEW_STATE_ATTR) !== MENU_VIEW_INACTIVE_STATE;
+ viewState.phase === 'active' && rootView.getAttribute(VIEW_STATE_ATTR) !== VIEW_INACTIVE_STATE;
- if (state.phaseKeys.get(view) === phaseKey && !shouldResyncActiveView) return;
+ if (previousPhaseKey === phaseKey && !shouldResyncActiveView) return;
state.phaseKeys.set(view, phaseKey);
if (viewState.phase === 'hidden') {
- state.phaseKeys.delete(view);
- syncMenuViewRoot(content, getActiveMenuViewElement(viewport) !== null, options);
+ if (!previousPhaseKey || previousPhaseKey.startsWith('hidden:')) return;
+
+ syncMenuViewRoot(content, getActiveViewElement(viewport) !== null, options);
return;
}
if (viewState.phase === 'entering') {
- prepareEnteringMenuView(content, rootView, view, state, options);
+ prepareEnteringTransition(content, rootView, view, state, options);
return;
}
if (viewState.phase === 'active') {
- startEnteringMenuView(content, rootView, view, state, options);
+ startEnteringView(content, rootView, view, state, options);
return;
}
- startExitingMenuView(content, rootView, view, state, options);
+ startExitingView(content, rootView, view, state, options);
}
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 7136306f..ffbace06 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
@@ -221,6 +221,29 @@ describe('menu-viewport-transition', () => {
expect(content.style.getPropertyValue('--media-menu-width')).toBe('160px');
});
+ it('does not measure the root view for initially hidden child views', () => {
+ const content = addElement();
+ const rootView = document.createElement('div');
+ const menuViews = Array.from({ length: 13 }, () => document.createElement('div'));
+
+ applyAttrs(rootView, getMenuRootViewAttrs());
+ rootView.getBoundingClientRect = vi.fn(() => createRect(160, 100));
+ for (const menuView of menuViews) menuView.setAttribute('data-menu-view', '');
+ content.append(rootView, ...menuViews);
+
+ for (const menuView of menuViews) {
+ syncMenuViewTransition(content, menuView, {
+ phase: 'hidden',
+ direction: 'forward',
+ triggerId: null,
+ });
+ }
+
+ expect(rootView.getBoundingClientRect).not.toHaveBeenCalled();
+ expect(content.style.getPropertyValue('--media-menu-width')).toBe('');
+ expect(content.style.getPropertyValue('--media-menu-height')).toBe('');
+ });
+
it('measures the root view height at the available menu width', () => {
const content = addElement();
const rootView = document.createElement('div');
@@ -242,6 +265,61 @@ describe('menu-viewport-transition', () => {
expect(content.style.getPropertyValue('--media-menu-width')).toBe('180px');
expect(content.style.getPropertyValue('--media-menu-height')).toBe('128px');
+ expect(rootView.getBoundingClientRect).toHaveBeenCalledTimes(2);
+ });
+
+ it('avoids duplicate reads when measuring a natural menu view size', () => {
+ const content = addElement();
+ const rootView = document.createElement('div');
+
+ applyAttrs(rootView, getMenuRootViewAttrs());
+ content.append(rootView);
+
+ mockMenuViewSize(rootView, {
+ currentWidth: 160,
+ currentHeight: 80,
+ naturalWidth: 160,
+ naturalHeight: 80,
+ });
+
+ syncMenuViewRoot(content, false);
+
+ expect(rootView.getBoundingClientRect).toHaveBeenCalledTimes(1);
+ });
+
+ it('batches entering menu view measurements', () => {
+ 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',
+ });
+
+ expect(rootView.getBoundingClientRect).toHaveBeenCalledTimes(1);
+ expect(menuView.getBoundingClientRect).toHaveBeenCalledTimes(2);
});
it('measures an entering submenu height at the available menu width', () => {
@@ -439,6 +517,48 @@ describe('menu-viewport-transition', () => {
expect(content.style.getPropertyValue('--media-menu-height')).toBe('109px');
expect(rootView.style.getPropertyValue('width')).toBe('');
expect(rootView.style.getPropertyValue('height')).toBe('');
+ expect(menuView.getBoundingClientRect).toHaveBeenCalledTimes(1);
+ expect(rootView.getBoundingClientRect).toHaveBeenCalledTimes(3);
+ });
+
+ it('resyncs the root view when a visible child view becomes hidden', () => {
+ 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: 220,
+ currentHeight: 170,
+ naturalWidth: 160,
+ naturalHeight: 100,
+ });
+ mockMenuViewSize(menuView, {
+ currentWidth: 220,
+ currentHeight: 170,
+ naturalWidth: 220,
+ naturalHeight: 170,
+ });
+
+ syncMenuViewTransition(content, menuView, {
+ phase: 'active',
+ direction: 'forward',
+ triggerId: 'trigger-1',
+ });
+ menuView.hidden = true;
+
+ syncMenuViewTransition(content, menuView, {
+ phase: 'hidden',
+ direction: 'back',
+ triggerId: 'trigger-1',
+ });
+
+ expect(rootView.getAttribute('data-menu-view-state')).toBe('active');
+ expect(content.style.getPropertyValue('--media-menu-width')).toBe('160px');
+ expect(content.style.getPropertyValue('--media-menu-height')).toBe('100px');
});
it('does not restore the root view when a hidden child sibling still has an active view', () => {