mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
fix(packages): handle menu child mutations (#1739)
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user