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', () => {