perf(core): batch menu viewport measurements

This commit is contained in:
Sam Potts
2026-07-13 11:01:53 +10:00
parent 82b9e43bb5
commit f0215990f8
4 changed files with 471 additions and 106 deletions
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>Sandbox — Ejected HTML menu layout</title>
</head>
<body class="bg-slate-950 text-white min-h-screen p-8 font-sans antialiased">
<div id="root"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
@@ -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) => `
<article class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<div class="mb-3 flex items-center justify-between">
<span class="inline-flex rounded-full bg-indigo-100 px-2 py-1 text-xs font-semibold text-indigo-700">${index + 1}</span>
<button class="rounded bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-200">Manage</button>
</div>
<h2 class="text-base font-semibold text-gray-900">Tailwind content card</h2>
<p class="mt-1 text-sm leading-6 text-gray-600">Host-page content to exercise selector matching during layout.</p>
</article>
`
).join('');
const panels = Array.from({ length: PANEL_COUNT }, (_, index) => {
const panelNumber = index + 1;
return {
panelNumber,
trigger: `
<media-menu-item commandfor="settings-test-menu-${panelNumber}" class="media-menu__item media-menu__item--submenu">
<media-text>Test panel ${panelNumber}</media-text>
<span class="media-menu__hint">
<media-icon name="chevron" class="media-icon media-menu__chevron"></media-icon>
</span>
</media-menu-item>
`,
content: `
<media-menu id="settings-test-menu-${panelNumber}" class="media-menu__panel">
<media-menu-back class="media-menu__back">
<media-icon name="chevron" class="media-icon media-menu__chevron media-icon--flipped"></media-icon>
<media-text>Test panel ${panelNumber}</media-text>
</media-menu-back>
<div class="media-menu__separator"></div>
<div class="media-menu__group">
<media-menu-item class="media-menu__item">Option one</media-menu-item>
<media-menu-item class="media-menu__item">Option two</media-menu-item>
</div>
</media-menu>
`,
};
});
root.innerHTML = `
<main class="mx-auto flex max-w-5xl flex-col gap-6">
<header class="flex flex-col gap-2">
<h1 class="text-2xl font-semibold">Ejected HTML menu layout</h1>
<p class="max-w-3xl text-sm text-slate-300">
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.
</p>
</header>
<section class="flex flex-wrap items-center gap-3 rounded-lg bg-slate-900 p-4 ring-1 ring-white/10">
<button id="record-open" class="rounded bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-400">
Record next settings-menu open
</button>
<span id="status" class="text-sm text-slate-300">Close the settings menu, then start a recording.</span>
<output id="result" class="ml-auto text-sm font-medium text-sky-300"></output>
</section>
<section class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
${pageContent}
</section>
<section class="mx-auto w-full max-w-3xl overflow-hidden rounded-lg bg-black ring-1 ring-white/10">
<video-player>
<media-container class="media-default-skin media-default-skin--video block bg-black" style="aspect-ratio: 16 / 9">
<video muted playsinline></video>
<media-controls data-visible class="media-surface media-controls">
<div class="media-button-group">
<button id="settings-trigger" commandfor="settings-menu" class="media-button media-button--subtle media-button--icon media-button--settings !grid">
<media-icon name="gear" class="media-icon media-icon--settings"></media-icon>
<span class="media-sr-only">Settings</span>
</button>
<media-menu id="settings-menu" side="top" align="center" class="media-surface media-popover media-menu media-menu--settings">
<media-menu-view class="media-menu__panel">
<div class="media-menu__group">${panels.map((panel) => panel.trigger).join('')}</div>
</media-menu-view>
${panels.map((panel) => panel.content).join('')}
</media-menu>
</div>
</media-controls>
</media-container>
</video-player>
</section>
</main>
`;
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<LayoutCall['target'], number> {
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 }
);
@@ -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<HTMLElement, string>;
}
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<HTMLElement, MenuViewportTransitionState>();
const viewportTransitionStates = new WeakMap<HTMLElement, ViewportTransitionState>();
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<HTMLElement>(`:scope > [${MENU_VIEWPORT_ATTR}]`) ?? content;
return content.querySelector<HTMLElement>(`: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<HTMLElement>(`:scope > [${MENU_ROOT_VIEW_ATTR}]`);
return viewport.querySelector<HTMLElement>(`: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);
}
@@ -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', () => {