mirror of
https://github.com/zoriya/v10.git
synced 2026-08-14 09:59:44 +00:00
378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
import { MenuCore, MenuDataAttrs, type MenuInput, POPUP_HOST_ATTR } from '@videojs/core';
|
|
import {
|
|
applyElementProps,
|
|
applyStateDataAttrs,
|
|
createMenu,
|
|
createMenuViewTransition,
|
|
createTransition,
|
|
getAnchorNameStyle,
|
|
getAnchorPositionStyle,
|
|
getMenuViewportAttrs,
|
|
getMenuViewTransitionAttrs,
|
|
getPopupPositionRect,
|
|
getPositioningBoundaryRect,
|
|
getRootPositionOptions,
|
|
isMenuNavigationKey,
|
|
type MenuApi,
|
|
type MenuChangeDetails,
|
|
type MenuOpenChangeReason,
|
|
type MenuViewTransitionState,
|
|
type NavigationState,
|
|
observeMenuViewContent,
|
|
type PositioningBoundary,
|
|
resolveOffsets,
|
|
resolvePositioningBoundary,
|
|
syncMenuViewRoot,
|
|
syncMenuViewTransition,
|
|
type UIFocusEvent,
|
|
type UIKeyboardEvent,
|
|
} from '@videojs/core/dom';
|
|
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
|
import { ContextConsumer, ContextProvider } from '@videojs/element/context';
|
|
import { SnapshotController } from '@videojs/store/html';
|
|
import { applyStyles, supportsAnchorPositioning, tryHidePopover, tryShowPopover } from '@videojs/utils/dom';
|
|
import { containerContext } from '../../player/context';
|
|
import { MediaElement } from '../media-element';
|
|
import { PositionController } from '../position-controller';
|
|
import { type MenuContextValue, menuContext } from './context';
|
|
|
|
export class MenuElement extends MediaElement {
|
|
static readonly tagName: string = 'media-menu';
|
|
|
|
static override properties = {
|
|
open: { type: Boolean },
|
|
defaultOpen: { type: Boolean, attribute: 'default-open' },
|
|
side: { type: String },
|
|
align: { type: String },
|
|
closeOnEscape: { type: Boolean, attribute: 'close-on-escape' },
|
|
closeOnOutsideClick: { type: Boolean, attribute: 'close-on-outside-click' },
|
|
boundary: { type: String },
|
|
} satisfies PropertyDeclarationMap<
|
|
'open' | 'defaultOpen' | 'side' | 'align' | 'closeOnEscape' | 'closeOnOutsideClick' | 'boundary'
|
|
>;
|
|
|
|
open = MenuCore.defaultProps.open;
|
|
defaultOpen = MenuCore.defaultProps.defaultOpen;
|
|
side = MenuCore.defaultProps.side;
|
|
align = MenuCore.defaultProps.align;
|
|
closeOnEscape = MenuCore.defaultProps.closeOnEscape;
|
|
closeOnOutsideClick = MenuCore.defaultProps.closeOnOutsideClick;
|
|
boundary: PositioningBoundary = 'container';
|
|
|
|
readonly #core = new MenuCore();
|
|
readonly #provider = new ContextProvider(this, { context: menuContext });
|
|
readonly #position = new PositionController(this);
|
|
readonly #containerCtx = new ContextConsumer(this, { context: containerContext, subscribe: true });
|
|
// Consume parent menu context — present when this is a nested (submenu) element.
|
|
readonly #parentCtx = new ContextConsumer(this, { context: menuContext, subscribe: true });
|
|
readonly #menuViewTransition = createMenuViewTransition({
|
|
focusFirstItem: () => {
|
|
this.#menu?.highlightFirstItem({ preventScroll: true });
|
|
},
|
|
restoreFocus: (triggerId) => {
|
|
const triggerElement = triggerId ? document.getElementById(triggerId) : null;
|
|
const fallbackTrigger = this.parentElement?.querySelector<HTMLElement>(
|
|
`[data-has-submenu][commandfor="${this.id}"]`
|
|
);
|
|
|
|
(triggerElement ?? fallbackTrigger)?.focus({ preventScroll: true });
|
|
},
|
|
});
|
|
#menu: MenuApi | null = null;
|
|
#snapshot: SnapshotController<MenuInput> | null = null;
|
|
#navSnapshot: SnapshotController<NavigationState> | null = null;
|
|
#menuViewSnapshot: SnapshotController<MenuViewTransitionState> | null = null;
|
|
#navState: NavigationState = { stack: [], direction: 'forward' };
|
|
|
|
#disconnect: AbortController | null = null;
|
|
#triggerAbort: AbortController | null = null;
|
|
#cleanupContentObserver: (() => void) | null = null;
|
|
#currentTrigger: HTMLElement | null = null;
|
|
|
|
override connectedCallback(): void {
|
|
super.connectedCallback();
|
|
if (this.destroyed) return;
|
|
|
|
this.setAttribute(POPUP_HOST_ATTR, '');
|
|
|
|
this.#disconnect = new AbortController();
|
|
|
|
this.#menu = createMenu({
|
|
transition: createTransition(),
|
|
onOpenChange: (nextOpen: boolean, details: MenuChangeDetails) => {
|
|
this.open = nextOpen;
|
|
this.dispatchEvent(new CustomEvent('open-change', { detail: { open: nextOpen, ...details } }));
|
|
},
|
|
closeOnEscape: () => this.closeOnEscape,
|
|
closeOnOutsideClick: () => this.closeOnOutsideClick,
|
|
group: () => (this.#parentCtx.value ? undefined : this.#containerCtx.value?.popupGroup),
|
|
});
|
|
|
|
// The element itself is the content (popup) for root menus.
|
|
// Submenu detection happens in update() once parent context is available.
|
|
this.#menu.setContentElement(this);
|
|
|
|
applyElementProps(
|
|
this,
|
|
{ onKeyDown: this.#handleContentKeyDown, onFocusOut: this.#handleContentFocusOut },
|
|
{ signal: this.#disconnect.signal }
|
|
);
|
|
|
|
if (this.#snapshot) {
|
|
this.#snapshot.track(this.#menu.input);
|
|
} else {
|
|
this.#snapshot = new SnapshotController(this, this.#menu.input);
|
|
}
|
|
|
|
if (this.#navSnapshot) {
|
|
this.#navSnapshot.track(this.#menu.navigationInput);
|
|
} else {
|
|
this.#navSnapshot = new SnapshotController(this, this.#menu.navigationInput);
|
|
}
|
|
|
|
if (this.#menuViewSnapshot) {
|
|
this.#menuViewSnapshot.track(this.#menuViewTransition.input);
|
|
} else {
|
|
this.#menuViewSnapshot = new SnapshotController(this, this.#menuViewTransition.input);
|
|
}
|
|
}
|
|
|
|
protected override firstUpdated(changed: PropertyValues): void {
|
|
super.firstUpdated(changed);
|
|
|
|
if (this.defaultOpen && !this.open) {
|
|
this.#menu?.open();
|
|
}
|
|
}
|
|
|
|
override disconnectedCallback(): void {
|
|
super.disconnectedCallback();
|
|
this.#cleanupContentObserver?.();
|
|
this.#cleanupContentObserver = null;
|
|
this.#cleanupTrigger();
|
|
this.#menu?.destroy();
|
|
this.#menu = null;
|
|
this.#disconnect?.abort();
|
|
this.#disconnect = null;
|
|
this.#menuViewTransition.destroy();
|
|
}
|
|
|
|
close(reason: MenuOpenChangeReason = 'imperative-action'): void {
|
|
this.#menu?.close(reason);
|
|
}
|
|
|
|
protected override willUpdate(changed: PropertyValues): void {
|
|
super.willUpdate(changed);
|
|
|
|
const parentCtx = this.#parentCtx.value ?? null;
|
|
const isSubmenu = parentCtx !== null;
|
|
|
|
this.#core.setProps({
|
|
open: this.open,
|
|
defaultOpen: this.defaultOpen,
|
|
side: this.side,
|
|
align: this.align,
|
|
closeOnEscape: this.closeOnEscape,
|
|
closeOnOutsideClick: this.closeOnOutsideClick,
|
|
isSubmenu,
|
|
});
|
|
|
|
if (this.#menu && changed.has('open') && !isSubmenu) {
|
|
const { active: interactionOpen } = this.#menu.input.current;
|
|
if (this.open !== interactionOpen) {
|
|
if (this.open) {
|
|
this.#menu.open();
|
|
} else {
|
|
this.#menu.close();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
protected override update(_changed: PropertyValues): void {
|
|
super.update(_changed);
|
|
if (!this.#menu) return;
|
|
|
|
const parentCtx = this.#parentCtx.value ?? null;
|
|
const isSubmenu = parentCtx !== null;
|
|
|
|
this.#navState = this.#menu.navigationInput.current;
|
|
const input = this.#menu.input.current;
|
|
this.#core.setInput(input);
|
|
const state = this.#core.getState();
|
|
|
|
if (isSubmenu && parentCtx) {
|
|
this.#updateAsSubmenu(parentCtx);
|
|
} else {
|
|
this.#updateAsRoot(state);
|
|
}
|
|
|
|
// Provide context to child parts.
|
|
// When nested, expose the parent menu's API so items can pop on select.
|
|
const parentMenu = parentCtx?.menu ?? null;
|
|
this.#provider.setValue({
|
|
menu: this.#menu,
|
|
state,
|
|
stateAttrMap: MenuDataAttrs,
|
|
navigation: this.#navState,
|
|
parentMenu,
|
|
});
|
|
}
|
|
|
|
#updateAsRoot(state: ReturnType<MenuCore['getState']>): void {
|
|
if (!this.#menu) return;
|
|
|
|
const triggerElement = this.#position.findTrigger();
|
|
this.#syncTrigger(triggerElement);
|
|
|
|
applyElementProps(this, {
|
|
...this.#core.getContentAttrs(state),
|
|
...getMenuViewportAttrs(),
|
|
});
|
|
applyStateDataAttrs(this, state, MenuDataAttrs);
|
|
|
|
if (state.open) {
|
|
tryShowPopover(this);
|
|
} else {
|
|
tryHidePopover(this);
|
|
}
|
|
|
|
if (this.#currentTrigger) {
|
|
applyElementProps(this.#currentTrigger, this.#core.getTriggerAttrs(state, this.id));
|
|
applyStyles(this.#currentTrigger, getAnchorNameStyle(this.id));
|
|
}
|
|
|
|
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);
|
|
if (!positionOptions) return;
|
|
|
|
const boundaryElement = this.#getBoundaryElement();
|
|
const triggerRect = this.#currentTrigger?.getBoundingClientRect();
|
|
const boundaryRect = getPositioningBoundaryRect(boundaryElement);
|
|
const offsets = resolveOffsets(this);
|
|
|
|
if (supportsAnchorPositioning()) {
|
|
applyStyles(
|
|
this,
|
|
getAnchorPositionStyle(this.id, positionOptions, triggerRect, undefined, boundaryRect, offsets)
|
|
);
|
|
} else {
|
|
const selfRect = getPopupPositionRect(this);
|
|
applyStyles(this, getAnchorPositionStyle(this.id, positionOptions, triggerRect, selfRect, boundaryRect, offsets));
|
|
}
|
|
|
|
this.#position.sync(this.#currentTrigger, boundaryElement);
|
|
}
|
|
|
|
#updateAsSubmenu(parentCtx: MenuContextValue): void {
|
|
const parentNavigation = parentCtx.navigation;
|
|
const topEntry = parentNavigation.stack[parentNavigation.stack.length - 1];
|
|
const activeSubMenuId = topEntry?.menuId ?? null;
|
|
const isActive = activeSubMenuId === this.id;
|
|
|
|
this.#menuViewTransition.setElement(this);
|
|
this.#menuViewTransition.sync({
|
|
active: isActive,
|
|
direction: parentNavigation.direction,
|
|
triggerId: topEntry?.triggerId ?? null,
|
|
});
|
|
|
|
// Apply base submenu attributes regardless of phase.
|
|
const transitionState = this.#menuViewTransition.input.current;
|
|
|
|
this.removeAttribute(MenuDataAttrs.side);
|
|
this.removeAttribute(MenuDataAttrs.align);
|
|
|
|
applyElementProps(this, {
|
|
...getMenuViewTransitionAttrs(transitionState),
|
|
role: 'menu',
|
|
tabIndex: -1,
|
|
'data-submenu': '',
|
|
});
|
|
syncMenuViewTransition(parentCtx.menu.contentElement, this, transitionState);
|
|
}
|
|
|
|
#handleContentKeyDown = (event: UIKeyboardEvent): void => {
|
|
const isNavigationKey = isMenuNavigationKey(event);
|
|
const defaultPreventedBeforeMenu = event.defaultPrevented;
|
|
|
|
this.#menu?.contentProps.onKeyDown(event);
|
|
|
|
const parentCtx = this.#parentCtx.value ?? null;
|
|
|
|
if (!parentCtx) {
|
|
if (event.key === 'Escape') return;
|
|
if (isNavigationKey) {
|
|
event.stopPropagation();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const stack = parentCtx.menu.navigationInput.current.stack;
|
|
const topEntry = stack[stack.length - 1];
|
|
const ownsActiveSubmenu = topEntry?.menuId === this.id;
|
|
const isBackNavigationKey = event.key === 'ArrowLeft' || event.key === 'Escape';
|
|
|
|
if (isBackNavigationKey && ownsActiveSubmenu && !defaultPreventedBeforeMenu) {
|
|
event.preventDefault();
|
|
parentCtx.menu.pop();
|
|
}
|
|
|
|
if (isNavigationKey && (!isBackNavigationKey || ownsActiveSubmenu)) {
|
|
event.stopPropagation();
|
|
}
|
|
};
|
|
|
|
#handleContentFocusOut = (event: UIFocusEvent): void => {
|
|
this.#menu?.contentProps.onFocusOut(event);
|
|
};
|
|
|
|
#syncTrigger(triggerElement: HTMLElement | null): void {
|
|
if (triggerElement === this.#currentTrigger) return;
|
|
|
|
this.#position.cleanup();
|
|
this.#cleanupTrigger();
|
|
this.#currentTrigger = triggerElement;
|
|
this.#menu?.setTriggerElement(triggerElement);
|
|
|
|
if (triggerElement && this.#menu) {
|
|
this.#triggerAbort = new AbortController();
|
|
applyElementProps(triggerElement, this.#menu.triggerProps, { signal: this.#triggerAbort.signal });
|
|
}
|
|
}
|
|
|
|
#cleanupTrigger(): void {
|
|
if (this.#currentTrigger) {
|
|
applyElementProps(this.#currentTrigger, {
|
|
'aria-expanded': undefined,
|
|
'aria-haspopup': undefined,
|
|
'aria-controls': undefined,
|
|
});
|
|
this.#currentTrigger.style.removeProperty('anchor-name');
|
|
}
|
|
|
|
this.#triggerAbort?.abort();
|
|
this.#triggerAbort = null;
|
|
this.#currentTrigger = null;
|
|
}
|
|
|
|
#getBoundaryElement(): Element | null {
|
|
return resolvePositioningBoundary(this.boundary, {
|
|
container: this.#containerCtx.value?.container ?? null,
|
|
root: this.getRootNode() as Document | ShadowRoot,
|
|
});
|
|
}
|
|
}
|