feat(packages): add playback rate menu (#1527)

This commit is contained in:
Sam Potts
2026-05-14 08:37:52 +10:00
committed by GitHub
parent ad515d3a24
commit ad831d25f0
141 changed files with 3990 additions and 397 deletions
+56 -13
View File
@@ -1,4 +1,4 @@
import { MenuCore, MenuDataAttrs, type MenuInput } from '@videojs/core';
import { MenuCore, MenuDataAttrs, type MenuInput, POPUP_HOST_ATTR } from '@videojs/core';
import {
applyElementProps,
applyStateDataAttrs,
@@ -10,13 +10,17 @@ import {
getMenuViewportAttrs,
getMenuViewTransitionAttrs,
getPopupPositionRect,
getPositioningBoundaryRect,
getRootPositionOptions,
isMenuNavigationKey,
type MenuApi,
type MenuChangeDetails,
type MenuOpenChangeReason,
type MenuViewTransitionState,
type NavigationState,
type PositioningBoundary,
resolveOffsets,
resolvePositioningBoundary,
syncMenuViewRoot,
syncMenuViewTransition,
type UIFocusEvent,
@@ -26,13 +30,13 @@ 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 = 'media-menu';
static readonly tagName: string = 'media-menu';
static override properties = {
open: { type: Boolean },
@@ -41,8 +45,9 @@ export class MenuElement extends MediaElement {
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'
'open' | 'defaultOpen' | 'side' | 'align' | 'closeOnEscape' | 'closeOnOutsideClick' | 'boundary'
>;
open = MenuCore.defaultProps.open;
@@ -51,10 +56,12 @@ export class MenuElement extends MediaElement {
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({
@@ -84,6 +91,8 @@ export class MenuElement extends MediaElement {
super.connectedCallback();
if (this.destroyed) return;
this.setAttribute(POPUP_HOST_ATTR, '');
this.#disconnect = new AbortController();
this.#menu = createMenu({
@@ -94,6 +103,7 @@ export class MenuElement extends MediaElement {
},
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.
@@ -143,13 +153,25 @@ export class MenuElement extends MediaElement {
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({ ...this, isSubmenu });
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;
@@ -226,17 +248,22 @@ export class MenuElement extends MediaElement {
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));
applyStyles(
this,
getAnchorPositionStyle(this.id, positionOptions, triggerRect, undefined, boundaryRect, offsets)
);
} else {
const triggerRect = this.#currentTrigger?.getBoundingClientRect();
const selfRect = getPopupPositionRect(this);
const boundaryRect = document.documentElement.getBoundingClientRect();
const offsets = resolveOffsets(this);
applyStyles(this, getAnchorPositionStyle(this.id, positionOptions, triggerRect, selfRect, boundaryRect, offsets));
}
this.#position.sync(this.#currentTrigger);
this.#position.sync(this.#currentTrigger, boundaryElement);
}
#updateAsSubmenu(parentCtx: MenuContextValue): void {
@@ -265,23 +292,32 @@ export class MenuElement extends MediaElement {
}
#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) return;
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 && !event.defaultPrevented) {
if (isBackNavigationKey && ownsActiveSubmenu && !defaultPreventedBeforeMenu) {
event.preventDefault();
parentCtx.menu.pop();
}
if (isMenuNavigationKey(event) && (!isBackNavigationKey || ownsActiveSubmenu)) {
if (isNavigationKey && (!isBackNavigationKey || ownsActiveSubmenu)) {
event.stopPropagation();
}
};
@@ -318,4 +354,11 @@ export class MenuElement extends MediaElement {
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,
});
}
}