fix(packages): handle menu child mutations (#1739)

This commit is contained in:
Sam Potts
2026-06-30 10:20:07 +10:00
committed by GitHub
parent a6d30a9e61
commit 9ab7adefb7
7 changed files with 163 additions and 2 deletions
+1 -1
View File
@@ -252,7 +252,7 @@ export class HlsJsMedia extends HTMLVideoElementHost implements HlsMediaProps {
return {
...this.config.hlsJs,
preferPlayback: this.config.preferPlayback,
contentType: this.config.contentType,
contentType: this.config.contentType ?? inferContentType(this.#src),
};
}
@@ -3,7 +3,7 @@ import { MediaError } from '../../../../core/media/media-error';
import type { RemotePlaybackLike } from '../../../../core/media/types';
import { addComponent, type Component } from '../../media-host';
import { NativeHlsMedia } from '../../native-hls';
import { ContentTypes, HlsJsMedia } from '../index';
import { ContentTypes, Hls, HlsJsMedia } from '../index';
afterEach(() => {
document.body.innerHTML = '';
@@ -162,6 +162,26 @@ describe('HlsJsMedia', () => {
expect(handler).not.toHaveBeenCalled();
});
it('recreates the engine when inferred content type changes', () => {
vi.spyOn(Hls, 'isSupported').mockReturnValue(true);
const video = document.createElement('video');
document.body.appendChild(video);
const media = new HlsJsMedia();
media.attach(video);
media.src = 'https://example.com/video.mp4';
media.load();
expect(media.engine).toBeNull();
media.src = 'https://example.com/video.m3u8';
media.load();
expect(media.engine).not.toBeNull();
});
it('resets free-form config when a new object is assigned', () => {
const { media } = setup();
@@ -44,6 +44,7 @@ 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 = [
@@ -273,6 +274,34 @@ export function syncMenuViewRoot(
setViewportSize(content, size);
}
export function observeMenuViewContent(content: HTMLElement, onChange: () => void): () => void {
if (typeof MutationObserver === 'undefined') return () => {};
let rafId = 0;
function scheduleChange(): void {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
rafId = 0;
onChange();
});
}
const observer = new MutationObserver(scheduleChange);
observer.observe(content, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: MENU_VIEW_LAYOUT_ATTRS,
});
return () => {
cancelAnimationFrame(rafId);
observer.disconnect();
};
}
export function syncMenuViewTransition(
content: HTMLElement | null,
view: HTMLElement | null,
@@ -4,6 +4,7 @@ import {
getMenuRootViewAttrs,
getMenuViewportAttrs,
getMenuViewportElement,
observeMenuViewContent,
syncMenuViewRoot,
syncMenuViewTransition,
} from '../menu-viewport-transition';
@@ -38,6 +39,15 @@ function createRect(width: number, height: number): DOMRect {
} as DOMRect;
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
async function waitForMutationFrame(): Promise<void> {
await Promise.resolve();
await nextFrame();
}
function mockMenuViewSize(
element: HTMLElement,
{
@@ -125,6 +135,29 @@ describe('menu-viewport-transition', () => {
expect(getMenuViewportElement(content)).toBe(content);
});
it('observes child and availability changes in menu content', async () => {
const content = addElement();
const item = document.createElement('div');
const onChange = vi.fn();
const cleanup = observeMenuViewContent(content, onChange);
content.append(item);
await waitForMutationFrame();
expect(onChange).toHaveBeenCalledTimes(1);
item.setAttribute('data-availability', 'available');
await waitForMutationFrame();
expect(onChange).toHaveBeenCalledTimes(2);
cleanup();
item.setAttribute('data-availability', 'unavailable');
await waitForMutationFrame();
expect(onChange).toHaveBeenCalledTimes(2);
});
it('toggles menu viewport data attributes for active and exiting phases', () => {
const content = addElement();
const rootView = document.createElement('div');
+10
View File
@@ -18,6 +18,7 @@ import {
type MenuOpenChangeReason,
type MenuViewTransitionState,
type NavigationState,
observeMenuViewContent,
type PositioningBoundary,
resolveOffsets,
resolvePositioningBoundary,
@@ -85,6 +86,7 @@ export class MenuElement extends MediaElement {
#disconnect: AbortController | null = null;
#triggerAbort: AbortController | null = null;
#cleanupContentObserver: (() => void) | null = null;
#currentTrigger: HTMLElement | null = null;
override connectedCallback(): void {
@@ -145,6 +147,8 @@ export class MenuElement extends MediaElement {
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#cleanupContentObserver?.();
this.#cleanupContentObserver = null;
this.#cleanupTrigger();
this.#menu?.destroy();
this.#menu = null;
@@ -239,10 +243,16 @@ export class MenuElement extends MediaElement {
}
if (!state.open) {
this.#cleanupContentObserver?.();
this.#cleanupContentObserver = null;
this.#position.cleanup();
return;
}
this.#cleanupContentObserver ??= observeMenuViewContent(this, () => {
this.requestUpdate();
});
syncMenuViewRoot(this, this.#navState.stack.length > 0);
const positionOptions = getRootPositionOptions(state.side, state.align);
@@ -12,6 +12,7 @@ import {
getRootPositionOptions,
isEventWithinElement,
isMenuNavigationKey,
observeMenuViewContent,
resolveOffsets,
resolvePositioningBoundary,
syncMenuViewRoot,
@@ -242,6 +243,18 @@ export const MenuContent = forwardRef<HTMLDivElement, MenuContentProps>(function
syncMenuViewRoot(internalRef.current, activeSubMenuId !== null);
}, [isSubmenu, state.open, activeSubMenuId]);
useLayoutEffect(() => {
if (isSubmenu) return;
if (!state.open) return;
const contentElement = internalRef.current;
if (!contentElement) return;
return observeMenuViewContent(contentElement, () => {
syncMenuViewRoot(contentElement, activeSubMenuId !== null);
});
}, [isSubmenu, state.open, activeSubMenuId]);
useLayoutEffect(() => {
if (!isSubmenu) return;
@@ -331,6 +331,48 @@ function expectNoMenuStateAttrs(element: HTMLElement): void {
}
}
function createRect(width: number, height: number): DOMRect {
return {
x: 0,
y: 0,
width,
height,
top: 0,
right: width,
bottom: height,
left: 0,
toJSON: () => ({}),
} as DOMRect;
}
function mockMenuViewSize(element: HTMLElement, getHeight: () => number): void {
element.getBoundingClientRect = vi.fn(() => createRect(160, getHeight()));
Object.defineProperty(element, 'scrollWidth', {
configurable: true,
get: () => 160,
});
Object.defineProperty(element, 'scrollHeight', {
configurable: true,
get: getHeight,
});
}
function DynamicMenuFixture({ showCaptions }: { showCaptions: boolean }) {
return (
<MenuRoot defaultOpen>
<MenuTrigger>Settings</MenuTrigger>
<MenuContent data-testid="content">
<MenuView data-testid="root-view">
<MenuItem>Speed</MenuItem>
{showCaptions ? <MenuItem>Captions</MenuItem> : null}
</MenuView>
</MenuContent>
</MenuRoot>
);
}
describe('MenuContent', () => {
it('scopes menu state data attributes to content elements', async () => {
render(
@@ -457,6 +499,20 @@ describe('MenuContent', () => {
expect(screen.getByTestId('root-view').hasAttribute('data-menu-view')).toBe(true);
});
it('remeasures an open root view when menu items are added', async () => {
const { rerender } = render(<DynamicMenuFixture showCaptions={false} />);
const content = screen.getByTestId('content');
const rootView = screen.getByTestId('root-view');
mockMenuViewSize(rootView, () => rootView.children.length * 20);
rerender(<DynamicMenuFixture showCaptions />);
await waitFor(() => {
expect(content.style.getPropertyValue('--media-menu-height')).toBe('40px');
});
});
it('forces layout while the submenu starting style is applied', async () => {
const startingStyleMeasurements: boolean[] = [];
const getBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;