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
@@ -1,7 +1,8 @@
import { ControlsCore, ControlsDataAttrs } from '@videojs/core';
import { ControlsCore, ControlsDataAttrs, POPUP_HOST_SELECTOR } from '@videojs/core';
import { applyStateDataAttrs, logMissingFeature, selectControls } from '@videojs/core/dom';
import type { PropertyValues } from '@videojs/element';
import { ContextProvider } from '@videojs/element/context';
import { isFunction } from '@videojs/utils/predicate';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
@@ -14,6 +15,7 @@ export class ControlsElement extends MediaElement {
readonly #core = new ControlsCore();
readonly #mediaState = new PlayerController(this, playerContext, selectControls);
readonly #provider = new ContextProvider(this, { context: controlsContext });
#visible = true;
override connectedCallback(): void {
super.connectedCallback();
@@ -38,5 +40,21 @@ export class ControlsElement extends MediaElement {
state,
stateAttrMap: ControlsDataAttrs,
});
const wasVisible = this.#visible;
this.#visible = state.visible;
if (wasVisible && !state.visible) {
this.#closeOwnedOverlays();
}
}
#closeOwnedOverlays(): void {
for (const element of this.querySelectorAll(POPUP_HOST_SELECTOR)) {
const host = element as Element & { close?: unknown };
if (!isFunction(host.close)) continue;
host.close('imperative-action');
}
}
}
@@ -0,0 +1,170 @@
import { type MediaControlsState, POPUP_HOST_ATTR } from '@videojs/core';
import type { AnyPlayerStore } from '@videojs/core/dom';
import { ContextProvider } from '@videojs/element/context';
import { createStore, flush } from '@videojs/store';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { playerContext } from '../../../player/context';
import { MediaElement } from '../../media-element';
import { MenuElement } from '../../menu/menu-element';
import { PlaybackRateMenuElement } from '../../playback-rate-menu/playback-rate-menu-element';
import { PopoverElement } from '../../popover/popover-element';
import { TooltipElement } from '../../tooltip/tooltip-element';
import { ControlsElement } from '../controls-element';
function ensureCustomElementDefined(Constructor: CustomElementConstructor & { readonly tagName: string }): void {
const { tagName } = Constructor;
if (!customElements.get(tagName)) {
customElements.define(tagName, Constructor);
}
}
function createDefinedElement<Class extends CustomElementConstructor & { readonly tagName: string }>(
Constructor: Class
): InstanceType<Class> {
ensureCustomElementDefined(Constructor);
return document.createElement(Constructor.tagName) as InstanceType<Class>;
}
function defineElement(tagName: string, Base: CustomElementConstructor): void {
if (!customElements.get(tagName)) {
customElements.define(tagName, Base);
}
}
function createControlsStore(): AnyPlayerStore {
return createStore<unknown>()<MediaControlsState>({
name: 'controls',
state: ({ get, set }) => {
return {
userActive: true,
controlsVisible: true,
toggleControls() {
const visible = !(get().controlsVisible as boolean);
set({ userActive: visible, controlsVisible: visible });
return visible;
},
};
},
}) as unknown as AnyPlayerStore;
}
class TestPlayerProviderElement extends MediaElement {
store = createControlsStore();
readonly #provider = new ContextProvider(this, { context: playerContext, initialValue: this.store });
override connectedCallback(): void {
super.connectedCallback();
this.#provider.setValue(this.store);
}
setVisible(visible: boolean): void {
const state = this.store.state as MediaControlsState;
if (state.controlsVisible === visible) return;
state.toggleControls();
flush();
}
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
async function waitForAssertion(assertion: () => void): Promise<void> {
let error: unknown;
for (let index = 0; index < 10; index++) {
try {
assertion();
return;
} catch (caught) {
error = caught;
await nextFrame();
}
}
throw error;
}
defineElement('test-controls-player-provider', TestPlayerProviderElement);
afterEach(() => {
document.body.innerHTML = '';
});
describe('ControlsElement', () => {
it('closes owned popovers, menus, playback-rate menus, and tooltips when controls hide', async () => {
const provider = document.createElement('test-controls-player-provider') as TestPlayerProviderElement;
const controls = createDefinedElement(ControlsElement);
const popover = createDefinedElement(PopoverElement);
const menu = createDefinedElement(MenuElement);
const playbackRateMenu = createDefinedElement(PlaybackRateMenuElement);
const tooltip = createDefinedElement(TooltipElement);
const popoverClose = vi.spyOn(popover, 'close');
const menuClose = vi.spyOn(menu, 'close');
const playbackRateMenuClose = vi.spyOn(playbackRateMenu, 'close');
const tooltipClose = vi.spyOn(tooltip, 'close');
controls.append(popover, menu, playbackRateMenu, tooltip);
document.body.append(provider);
provider.append(controls);
await controls.updateComplete;
expect(popover.hasAttribute(POPUP_HOST_ATTR)).toBe(true);
expect(menu.hasAttribute(POPUP_HOST_ATTR)).toBe(true);
expect(playbackRateMenu.hasAttribute(POPUP_HOST_ATTR)).toBe(true);
expect(tooltip.hasAttribute(POPUP_HOST_ATTR)).toBe(true);
provider.setVisible(false);
await waitForAssertion(() => {
expect(popoverClose).toHaveBeenCalledWith('imperative-action');
expect(menuClose).toHaveBeenCalledWith('imperative-action');
expect(playbackRateMenuClose).toHaveBeenCalledWith('imperative-action');
expect(tooltipClose).toHaveBeenCalledWith('imperative-action');
});
});
it('does not call close on native dialogs inside controls when controls hide', async () => {
const provider = document.createElement('test-controls-player-provider') as TestPlayerProviderElement;
const controls = createDefinedElement(ControlsElement);
const dialog = document.createElement('dialog');
const closeSpy = vi.spyOn(dialog, 'close');
controls.append(dialog);
document.body.append(provider);
provider.append(controls);
await controls.updateComplete;
provider.setVisible(false);
await waitForAssertion(() => {
expect(closeSpy).not.toHaveBeenCalled();
});
});
it('ignores popup host markers when close is missing or not a function', async () => {
const provider = document.createElement('test-controls-player-provider') as TestPlayerProviderElement;
const controls = createDefinedElement(ControlsElement);
const withoutClose = document.createElement('div');
withoutClose.setAttribute(POPUP_HOST_ATTR, '');
const wrongClose = document.createElement('div');
wrongClose.setAttribute(POPUP_HOST_ATTR, '');
Object.assign(wrongClose, { close: 'not-callable' });
controls.append(withoutClose, wrongClose);
document.body.append(provider);
provider.append(controls);
await controls.updateComplete;
expect(() => provider.setVisible(false)).not.toThrow();
});
});
@@ -60,7 +60,7 @@ export class MenuCheckboxItemElement extends MediaElement {
},
onPointerenter: () => {
const currentCtx = this.#ctx.value;
if (!this.disabled) currentCtx?.menu.highlight(this);
if (!this.disabled) currentCtx?.menu.highlight(this, { focus: false });
},
},
{ signal: this.#disconnect.signal }
+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,
});
}
}
@@ -79,7 +79,7 @@ export class MenuItemElement extends MediaElement {
},
onPointerenter: () => {
const currentCtx = this.#ctx.value;
if (!this.disabled) currentCtx?.menu.highlight(this);
if (!this.disabled) currentCtx?.menu.highlight(this, { focus: false });
},
},
{ signal: this.#disconnect.signal }
@@ -6,7 +6,7 @@ import { MediaElement } from '../media-element';
import { menuContext, menuRadioGroupContext } from './context';
export class MenuRadioGroupElement extends MediaElement {
static readonly tagName = 'media-menu-radio-group';
static readonly tagName: string = 'media-menu-radio-group';
static override properties = {
value: { type: String },
@@ -63,7 +63,7 @@ export class MenuRadioItemElement extends MediaElement {
},
onPointerenter: () => {
const currentMenuCtx = this.#menuCtx.value;
if (!this.disabled) currentMenuCtx?.menu.highlight(this);
if (!this.disabled) currentMenuCtx?.menu.highlight(this, { focus: false });
},
},
{ signal: this.#disconnect.signal }
@@ -1,5 +1,12 @@
import type { MediaControlsState } from '@videojs/core';
import type { AnyPlayerStore } from '@videojs/core/dom';
import { ContextProvider } from '@videojs/element/context';
import { createStore, flush } from '@videojs/store';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { playerContext } from '../../../player/context';
import { ControlsElement } from '../../controls/controls-element';
import { MediaElement } from '../../media-element';
import { MenuCheckboxItemElement } from '../menu-checkbox-item-element';
import { MenuElement } from '../menu-element';
import { MenuItemElement } from '../menu-item-element';
@@ -17,6 +24,53 @@ function createElement<Element extends HTMLElement>(Base: abstract new () => Ele
return document.createElement(tag) as Element;
}
function defineElement(tagName: string, Base: CustomElementConstructor): void {
if (!customElements.get(tagName)) {
customElements.define(tagName, Base);
}
}
function createControlsStore(): AnyPlayerStore {
return createStore<unknown>()<MediaControlsState>({
name: 'controls',
state: ({ get, set }) => {
return {
userActive: true,
controlsVisible: true,
toggleControls() {
const visible = !(get().controlsVisible as boolean);
set({ userActive: visible, controlsVisible: visible });
return visible;
},
};
},
}) as unknown as AnyPlayerStore;
}
class TestPlayerProviderElement extends MediaElement {
store = createControlsStore();
readonly #provider = new ContextProvider(this, { context: playerContext, initialValue: this.store });
override connectedCallback(): void {
super.connectedCallback();
this.#provider.setValue(this.store);
}
setVisible(visible: boolean): void {
const state = this.store.state as MediaControlsState;
if (state.controlsVisible === visible) return;
state.toggleControls();
flush();
}
}
defineElement('test-menu-player-provider', TestPlayerProviderElement);
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
@@ -209,6 +263,24 @@ describe('MenuElement', () => {
);
});
it('highlights pointer-entered items without moving focus', async () => {
const root = createElement(MenuElement);
const item = createElement(MenuItemElement);
root.append(item);
document.body.append(root);
await root.updateComplete;
await item.updateComplete;
const focus = vi.spyOn(item, 'focus');
item.dispatchEvent(new Event('pointerenter'));
expect(focus).not.toHaveBeenCalled();
expect(item.hasAttribute('data-highlighted')).toBe(true);
});
it('closes when focus moves outside the root menu', async () => {
const root = createElement(MenuElement);
const item = createElement(MenuItemElement);
@@ -382,4 +454,107 @@ describe('MenuElement', () => {
child.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
expect(onRootKeyDown).toHaveBeenCalledTimes(1);
});
it('stops propagation for root menu keyboard navigation', async () => {
const wrapper = document.createElement('div');
const root = createElement(MenuElement);
const item = createElement(MenuItemElement);
const onWrapperKeyDown = vi.fn();
root.open = true;
item.textContent = 'Auto';
wrapper.addEventListener('keydown', onWrapperKeyDown);
root.append(item);
wrapper.append(root);
document.body.append(wrapper);
await root.updateComplete;
await item.updateComplete;
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
expect(onWrapperKeyDown).not.toHaveBeenCalled();
expect(item.hasAttribute('data-highlighted')).toBe(true);
const handled = root.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true })
);
expect(handled).toBe(false);
expect(onWrapperKeyDown).not.toHaveBeenCalled();
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
expect(onWrapperKeyDown).toHaveBeenCalledTimes(1);
});
it('stops propagation for root trigger keyboard navigation while open', async () => {
const wrapper = document.createElement('div');
const trigger = document.createElement('button');
const root = createElement(MenuElement);
const item = createElement(MenuItemElement);
const onWrapperKeyDown = vi.fn();
root.id = 'root-menu';
root.open = true;
trigger.setAttribute('commandfor', 'root-menu');
item.textContent = 'Auto';
wrapper.addEventListener('keydown', onWrapperKeyDown);
root.append(item);
wrapper.append(trigger, root);
document.body.append(wrapper);
await root.updateComplete;
await item.updateComplete;
const handled = trigger.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true })
);
expect(handled).toBe(false);
expect(onWrapperKeyDown).not.toHaveBeenCalled();
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
expect(onWrapperKeyDown).not.toHaveBeenCalled();
expect(item.hasAttribute('data-highlighted')).toBe(true);
});
it('closes an open root menu when parent controls hide', async () => {
const provider = document.createElement('test-menu-player-provider') as TestPlayerProviderElement;
const controls = createElement(ControlsElement);
const trigger = document.createElement('button');
const root = createElement(MenuElement);
const item = createElement(MenuItemElement);
const onOpenChange = vi.fn();
const focus = vi.spyOn(trigger, 'focus');
root.id = 'root-menu';
root.open = true;
trigger.setAttribute('commandfor', 'root-menu');
item.textContent = 'Auto';
root.addEventListener('open-change', onOpenChange);
root.append(item);
controls.append(trigger, root);
document.body.append(provider);
provider.append(controls);
await controls.updateComplete;
await root.updateComplete;
await item.updateComplete;
provider.setVisible(false);
await waitForAssertion(() => {
expect(root.open).toBe(false);
});
expect(onOpenChange).toHaveBeenCalledWith(
expect.objectContaining({ detail: expect.objectContaining({ open: false, reason: 'imperative-action' }) })
);
expect(focus).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,62 @@
import { PlaybackRateMenuCore, PlaybackRateMenuDataAttrs } from '@videojs/core';
import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectPlaybackRate } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MenuElement } from '../menu/menu-element';
export class PlaybackRateMenuElement extends MenuElement {
static override readonly tagName = 'media-playback-rate-menu';
static override properties = {
...MenuElement.properties,
label: { type: String },
disabled: { type: Boolean },
} satisfies PropertyDeclarationMap<
| 'open'
| 'defaultOpen'
| 'side'
| 'align'
| 'closeOnEscape'
| 'closeOnOutsideClick'
| 'boundary'
| 'label'
| 'disabled'
>;
label = '';
disabled = false;
override align: MenuElement['align'] = 'center';
formatRate = PlaybackRateMenuCore.defaultProps.formatRate;
readonly #core = new PlaybackRateMenuCore();
readonly #mediaState = new PlayerController(this, playerContext, selectPlaybackRate);
override connectedCallback(): void {
super.connectedCallback();
if (this.destroyed) return;
if (__DEV__ && !this.#mediaState.value && this.#mediaState.displayName) {
logMissingFeature(this.localName, this.#mediaState.displayName);
}
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const media = this.#mediaState.value;
if (!media) return;
this.#core.setProps(this);
this.#core.setMedia(media);
const state = this.#core.getState();
applyElementProps(this, this.#core.getAttrs(state));
applyStateDataAttrs(this, state, PlaybackRateMenuDataAttrs);
}
}
export namespace PlaybackRateMenuElement {
export type State = PlaybackRateMenuCore.State;
}
@@ -0,0 +1,100 @@
import { PlaybackRateMenuCore, PlaybackRateMenuDataAttrs } from '@videojs/core';
import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectPlaybackRate } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaElement } from '../media-element';
export class PlaybackRateMenuTriggerElement extends MediaElement {
static readonly tagName = 'media-playback-rate-menu-trigger';
static override properties = {
label: { type: String },
disabled: { type: Boolean },
commandfor: { type: String },
} satisfies PropertyDeclarationMap<'label' | 'disabled' | 'commandfor'>;
label = '';
disabled = false;
commandfor: string | undefined = undefined;
formatRate = PlaybackRateMenuCore.defaultProps.formatRate;
readonly #core = new PlaybackRateMenuCore();
readonly #mediaState = new PlayerController(this, playerContext, selectPlaybackRate);
#disconnect: AbortController | null = null;
override connectedCallback(): void {
super.connectedCallback();
if (this.destroyed) return;
this.#disconnect = new AbortController();
applyElementProps(
this,
{
onClick: this.#handleClick,
onKeyDown: this.#handleKeyDown,
},
{ signal: this.#disconnect.signal }
);
if (__DEV__ && !this.#mediaState.value && this.#mediaState.displayName) {
logMissingFeature(this.localName, this.#mediaState.displayName);
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#disconnect?.abort();
this.#disconnect = null;
}
/** Returns the trigger's current label derived from media state. */
getLabel(): string | undefined {
return this.#core.state.current.label || undefined;
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const media = this.#mediaState.value;
if (!media) return;
this.#core.setProps(this);
this.#core.setMedia(media);
const state = this.#core.getState();
applyElementProps(this, {
role: 'button',
tabIndex: 0,
...this.#core.getAttrs(state),
});
applyStateDataAttrs(this, state, PlaybackRateMenuDataAttrs);
}
#handleClick = (event: MouseEvent): void => {
if (this.#mediaState.value && !this.#core.state.current.disabled) return;
event.preventDefault();
event.stopImmediatePropagation();
};
#handleKeyDown = (event: KeyboardEvent): void => {
if (event.target !== event.currentTarget) return;
if (!this.#mediaState.value || this.#core.state.current.disabled) {
if (event.key !== 'Tab') event.preventDefault();
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.click();
}
};
}
export namespace PlaybackRateMenuTriggerElement {
export type State = PlaybackRateMenuCore.State;
}
@@ -0,0 +1,147 @@
import { PlaybackRateMenuCore, PlaybackRateMenuDataAttrs } from '@videojs/core';
import { applyStateDataAttrs, logMissingFeature, selectPlaybackRate } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MenuItemIndicatorElement } from '../menu/menu-item-indicator-element';
import { MenuRadioGroupElement } from '../menu/menu-radio-group-element';
import { MenuRadioItemElement } from '../menu/menu-radio-item-element';
export class PlaybackRateOptionsElement extends MenuRadioGroupElement {
static override readonly tagName = 'media-playback-rate-options';
static override properties = {
...MenuRadioGroupElement.properties,
disabled: { type: Boolean },
} satisfies PropertyDeclarationMap<'value' | 'label' | 'disabled'>;
disabled = false;
formatRate = PlaybackRateMenuCore.defaultProps.formatRate;
readonly #core = new PlaybackRateMenuCore();
readonly #mediaState = new PlayerController(this, playerContext, selectPlaybackRate);
#ratesKey = '';
#disconnect: AbortController | null = null;
override connectedCallback(): void {
super.connectedCallback();
if (this.destroyed) return;
this.#disconnect = new AbortController();
this.addEventListener('value-change', this.#handleValueChange, { signal: this.#disconnect.signal });
if (__DEV__ && !this.#mediaState.value && this.#mediaState.displayName) {
logMissingFeature(this.localName, this.#mediaState.displayName);
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#disconnect?.abort();
this.#disconnect = null;
}
protected override update(changed: PropertyValues): void {
const media = this.#mediaState.value;
let state: PlaybackRateMenuCore.State | null = null;
if (media) {
this.#core.setProps({ formatRate: this.formatRate, disabled: this.disabled });
this.#core.setMedia(media);
state = this.#core.getState();
this.value = this.#core.getRateValue(state.rate);
this.label = this.label || 'Playback rate';
this.#syncContent(state);
}
super.update(changed);
if (state) applyStateDataAttrs(this, state, PlaybackRateMenuDataAttrs);
}
#syncContent(state: PlaybackRateMenuCore.State): void {
const template = this.#getTemplate();
const templateKey = template?.innerHTML ?? '';
const ratesKey = `${state.rates.join('|')}::${templateKey}`;
if (ratesKey !== this.#ratesKey) {
this.#ratesKey = ratesKey;
for (const child of [...this.children]) {
if (child instanceof HTMLTemplateElement) continue;
child.remove();
}
this.append(...state.rates.map((rate) => this.#createItem(rate, template)));
}
for (const item of this.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)) {
const checked = item.value === this.value;
item.disabled = state.disabled;
for (const indicator of item.querySelectorAll<MenuItemIndicatorElement>(MenuItemIndicatorElement.tagName)) {
indicator.checked = checked;
}
}
}
#createItem(rate: number, template: HTMLTemplateElement | null): MenuRadioItemElement {
const item = this.#createItemFromTemplate(template);
const value = this.#core.getRateValue(rate);
item.value = value;
item.setAttribute('data-rate', value);
this.#setLabel(item, this.#core.getRateLabel(rate));
return item;
}
#createItemFromTemplate(template: HTMLTemplateElement | null): MenuRadioItemElement {
if (!template) return document.createElement(MenuRadioItemElement.tagName) as MenuRadioItemElement;
const fragment = template.content.cloneNode(true) as DocumentFragment;
const root = fragment.firstElementChild;
if (!root || root.localName !== MenuRadioItemElement.tagName || root.nextElementSibling) {
return document.createElement(MenuRadioItemElement.tagName) as MenuRadioItemElement;
}
return root as MenuRadioItemElement;
}
#setLabel(item: MenuRadioItemElement, label: string): void {
const labelPart = item.querySelector<HTMLElement>('[data-part~="label"]');
if (labelPart) {
labelPart.textContent = label;
} else {
item.textContent = label;
}
}
#getTemplate(): HTMLTemplateElement | null {
for (const child of this.children) {
if (child instanceof HTMLTemplateElement) return child;
}
return null;
}
#handleValueChange = (event: Event): void => {
if (event.target !== this) return;
const media = this.#mediaState.value;
if (!media) return;
const { value } = (event as CustomEvent<{ value: string }>).detail;
this.#core.selectValue(media, value);
};
}
export namespace PlaybackRateOptionsElement {
export type State = PlaybackRateMenuCore.State;
}
@@ -0,0 +1,235 @@
import type { MediaPlaybackRateState } from '@videojs/core';
import type { AnyPlayerStore } from '@videojs/core/dom';
import { ContextProvider } from '@videojs/element/context';
import { createStore } from '@videojs/store';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { playerContext } from '../../../player/context';
import { MediaElement } from '../../media-element';
import { MenuItemIndicatorElement } from '../../menu/menu-item-indicator-element';
import { MenuRadioGroupElement } from '../../menu/menu-radio-group-element';
import { MenuRadioItemElement } from '../../menu/menu-radio-item-element';
import { PlaybackRateMenuElement } from '../playback-rate-menu-element';
import { PlaybackRateMenuTriggerElement } from '../playback-rate-menu-trigger-element';
import { PlaybackRateOptionsElement } from '../playback-rate-options-element';
let tagCounter = 0;
function uniqueTag(base: string): string {
return `${base}-${tagCounter++}`;
}
function createElement<Element extends HTMLElement>(Base: abstract new () => Element): Element {
const tag = uniqueTag('test-el');
customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {});
return document.createElement(tag) as Element;
}
function defineElement(tagName: string, Base: CustomElementConstructor): void {
if (!customElements.get(tagName)) {
customElements.define(tagName, Base);
}
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
async function waitForAssertion(assertion: () => void): Promise<void> {
let error: unknown;
for (let index = 0; index < 10; index++) {
try {
assertion();
return;
} catch (caught) {
error = caught;
await nextFrame();
}
}
throw error;
}
function createPlaybackRateStore({
playbackRates = [0.5, 1, 1.5, 2],
playbackRate = 1.5,
setPlaybackRate = vi.fn(),
}: {
playbackRates?: readonly number[] | undefined;
playbackRate?: number | undefined;
setPlaybackRate?: ((rate: number) => void) | undefined;
} = {}): AnyPlayerStore {
return createStore<unknown>()<MediaPlaybackRateState>({
name: 'playbackRate',
state: () => {
return {
playbackRates,
playbackRate,
setPlaybackRate,
};
},
}) as unknown as AnyPlayerStore;
}
class TestPlayerProviderElement extends MediaElement {
store: AnyPlayerStore = createPlaybackRateStore();
readonly #provider = new ContextProvider(this, { context: playerContext });
override connectedCallback(): void {
this.#provider.setValue(this.store);
super.connectedCallback();
}
setStore(store: AnyPlayerStore): void {
this.store = store;
this.#provider.setValue(store);
}
}
defineElement(MenuRadioGroupElement.tagName, MenuRadioGroupElement);
defineElement(MenuRadioItemElement.tagName, MenuRadioItemElement);
defineElement(MenuItemIndicatorElement.tagName, MenuItemIndicatorElement);
defineElement(PlaybackRateOptionsElement.tagName, PlaybackRateOptionsElement);
defineElement('test-playback-rate-player', TestPlayerProviderElement);
function setup({
playbackRates,
playbackRate,
setPlaybackRate,
template,
}: {
playbackRates?: readonly number[] | undefined;
playbackRate?: number | undefined;
setPlaybackRate?: ((rate: number) => void) | undefined;
template?: string | undefined;
} = {}) {
const store = createPlaybackRateStore({ playbackRates, playbackRate, setPlaybackRate });
const provider = document.createElement('test-playback-rate-player') as TestPlayerProviderElement;
const trigger = createElement(PlaybackRateMenuTriggerElement);
const menu = createElement(PlaybackRateMenuElement);
const options = createElement(PlaybackRateOptionsElement);
provider.setStore(store);
trigger.commandfor = 'playback-rate-menu';
menu.id = 'playback-rate-menu';
if (template) {
const templateElement = document.createElement('template');
templateElement.innerHTML = template;
options.append(templateElement);
}
menu.append(options);
provider.append(trigger, menu);
document.body.append(provider);
return { menu, options, provider, store, trigger };
}
async function waitForMenu(
menu: PlaybackRateMenuElement,
trigger?: PlaybackRateMenuTriggerElement,
options?: PlaybackRateOptionsElement
): Promise<void> {
await trigger?.updateComplete;
await menu.updateComplete;
await options?.updateComplete;
const group = menu.querySelector<PlaybackRateOptionsElement>(PlaybackRateOptionsElement.tagName);
await group?.updateComplete;
const items = [...menu.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)];
const indicators = [...menu.querySelectorAll<MenuItemIndicatorElement>(MenuItemIndicatorElement.tagName)];
await Promise.all(items.map((item) => item.updateComplete));
await Promise.all(indicators.map((indicator) => indicator.updateComplete));
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('PlaybackRateMenuElement', () => {
it('renders radio items from the available playback rates', async () => {
const { menu, trigger } = setup({ playbackRates: [1, 1.25, 1.5], playbackRate: 1.25 });
await waitForMenu(menu, trigger);
const items = [...menu.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)];
expect(items.map((item) => item.textContent)).toEqual(['1×', '1.25×', '1.5×']);
await waitForAssertion(() => {
expect(items.map((item) => item.getAttribute('aria-checked'))).toEqual(['false', 'true', 'false']);
});
expect(menu.getAttribute('aria-label')).toBe('Playback rate 1.25');
expect(menu.getAttribute('data-rate')).toBe('1.25');
});
it('renders radio items from a template', async () => {
const { menu, options, trigger } = setup({
template:
'<media-menu-radio-item class="custom-item"><span class="custom-label" data-part="label"></span><media-menu-item-indicator force-mount class="custom-indicator"></media-menu-item-indicator></media-menu-radio-item>',
});
await waitForMenu(menu, trigger, options);
const item = menu.querySelector<MenuRadioItemElement>(MenuRadioItemElement.tagName)!;
const indicators = [...menu.querySelectorAll<MenuItemIndicatorElement>(MenuItemIndicatorElement.tagName)];
expect(item.className).toBe('custom-item');
expect(item.querySelector('[data-part~="label"]')?.textContent).toBe('0.5×');
expect(indicators.map((indicator) => indicator.checked)).toEqual([false, false, true, false]);
});
it('center aligns the root popup by default', async () => {
const { menu, trigger } = setup();
await waitForMenu(menu, trigger);
expect(menu.align).toBe('center');
expect(menu.getAttribute('data-align')).toBe('center');
});
it('sets the selected playback rate', async () => {
const setPlaybackRate = vi.fn();
const { menu, trigger } = setup({ setPlaybackRate });
await waitForMenu(menu, trigger);
const item = [...menu.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)].find(
(candidate) => candidate.value === '2'
)!;
item.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(setPlaybackRate).toHaveBeenCalledWith(2);
});
});
describe('PlaybackRateMenuTriggerElement', () => {
it('renders a dynamic trigger from the current playback rate', async () => {
const { trigger } = setup({ playbackRate: 2 });
await trigger.updateComplete;
expect(trigger.getAttribute('role')).toBe('button');
expect(trigger.getAttribute('aria-label')).toBe('Playback rate 2');
expect(trigger.getAttribute('data-rate')).toBe('2');
});
it('prevents activation when there are no playback rates', async () => {
const { trigger } = setup({ playbackRates: [] });
await trigger.updateComplete;
const onClick = vi.fn();
trigger.addEventListener('click', onClick);
trigger.click();
expect(trigger.getAttribute('aria-disabled')).toBe('true');
expect(trigger.hasAttribute('data-disabled')).toBe(true);
expect(onClick).not.toHaveBeenCalled();
});
});
+31 -10
View File
@@ -1,4 +1,4 @@
import { PopoverCore, PopoverDataAttrs, type PopoverInput, type PopoverProps } from '@videojs/core';
import { POPUP_HOST_ATTR, PopoverCore, PopoverDataAttrs, type PopoverInput, type PopoverProps } from '@videojs/core';
import {
applyElementProps,
applyStateDataAttrs,
@@ -7,17 +7,21 @@ import {
getAnchorNameStyle,
getAnchorPositionStyle,
getPopupPositionRect,
getPositioningBoundaryRect,
type PopoverApi,
type PopoverChangeDetails,
type PopoverOpenChangeReason,
type PositioningBoundary,
resolveOffsets,
resolvePositioningBoundary,
} from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } 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';
export class PopoverElement extends MediaElement {
static readonly tagName = 'media-popover';
@@ -32,7 +36,8 @@ export class PopoverElement extends MediaElement {
openOnHover: { type: Boolean, attribute: 'open-on-hover' },
delay: { type: Number },
closeDelay: { type: Number, attribute: 'close-delay' },
} satisfies PropertyDeclarationMap<keyof PopoverCore.Props>;
boundary: { type: String },
} satisfies PropertyDeclarationMap<keyof PopoverCore.Props | 'boundary'>;
open = PopoverCore.defaultProps.open;
defaultOpen = PopoverCore.defaultProps.defaultOpen;
@@ -44,8 +49,10 @@ export class PopoverElement extends MediaElement {
openOnHover = PopoverCore.defaultProps.openOnHover;
delay = PopoverCore.defaultProps.delay;
closeDelay = PopoverCore.defaultProps.closeDelay;
boundary: PositioningBoundary = 'container';
readonly #core = new PopoverCore();
readonly #containerCtx = new ContextConsumer(this, { context: containerContext, subscribe: true });
readonly #position = new PositionController(this);
#popover: PopoverApi | null = null;
#snapshot: SnapshotController<PopoverInput> | null = null;
@@ -59,6 +66,8 @@ export class PopoverElement extends MediaElement {
super.connectedCallback();
if (this.destroyed) return;
this.setAttribute(POPUP_HOST_ATTR, '');
this.#disconnect = new AbortController();
this.#popover = createPopover({
@@ -72,6 +81,7 @@ export class PopoverElement extends MediaElement {
openOnHover: () => this.openOnHover,
delay: () => this.delay,
closeDelay: () => this.closeDelay,
group: () => this.#containerCtx.value?.popupGroup,
});
// Register self as the popup element — the element IS the popup.
@@ -112,6 +122,10 @@ export class PopoverElement extends MediaElement {
super.destroyCallback();
}
close(reason: PopoverOpenChangeReason = 'imperative-action'): void {
this.#popover?.close(reason);
}
protected override willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
this.#core.setProps(this);
@@ -168,20 +182,20 @@ export class PopoverElement extends MediaElement {
// Apply positioning styles to self.
const posOpts = { side: state.side, align: state.align };
const boundaryElement = this.#getBoundaryElement();
const triggerRect = this.#currentTrigger?.getBoundingClientRect();
const boundaryRect = getPositioningBoundaryRect(boundaryElement);
const offsets = resolveOffsets(this);
if (supportsAnchorPositioning()) {
// Native CSS Anchor Positioning — no JS rect measurements needed.
applyStyles(this, getAnchorPositionStyle(this.id, posOpts));
applyStyles(this, getAnchorPositionStyle(this.id, posOpts, triggerRect, undefined, boundaryRect, offsets));
} else {
// JS fallback: measure rects and resolve CSS var offsets.
const triggerRect = this.#currentTrigger?.getBoundingClientRect();
const selfRect = getPopupPositionRect(this);
const boundaryRect = document.documentElement.getBoundingClientRect();
const offsets = resolveOffsets(this);
applyStyles(this, getAnchorPositionStyle(this.id, posOpts, triggerRect, selfRect, boundaryRect, offsets));
}
this.#position.sync(this.#currentTrigger);
this.#position.sync(this.#currentTrigger, boundaryElement);
}
// --- Trigger management ---
@@ -215,4 +229,11 @@ export class PopoverElement 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,
});
}
}
+13 -6
View File
@@ -1,5 +1,5 @@
import { isEventWithinElement } from '@videojs/core/dom';
import type { ReactiveController, ReactiveControllerHost } from '@videojs/element';
import { supportsAnchorPositioning } from '@videojs/utils/dom';
export type PositionControllerHost = ReactiveControllerHost & HTMLElement;
@@ -8,7 +8,8 @@ export type PositionControllerHost = ReactiveControllerHost & HTMLElement;
* popup elements (tooltips, popovers). Tracks scroll, resize, and
* ResizeObserver events to keep the popup aligned with its trigger.
*
* When native CSS Anchor Positioning is supported, `sync()` is a no-op.
* Native CSS Anchor Positioning still uses this controller for dynamic
* sizing variables such as available width/height.
*/
export class PositionController implements ReactiveController {
readonly #host: PositionControllerHost;
@@ -17,6 +18,7 @@ export class PositionController implements ReactiveController {
#frame = 0;
#resizeObserver: ResizeObserver | null = null;
#trigger: HTMLElement | null = null;
#boundary: Element | null = null;
constructor(host: PositionControllerHost) {
this.#host = host;
@@ -31,17 +33,19 @@ export class PositionController implements ReactiveController {
}
/** Start or update position tracking for the given trigger. */
sync(trigger: HTMLElement | null): void {
if (supportsAnchorPositioning()) return;
sync(trigger: HTMLElement | null, boundary: Element | null = null): void {
if (!trigger) return;
if (this.#abort && this.#trigger === trigger) return;
if (this.#abort && this.#trigger === trigger && this.#boundary === boundary) return;
this.cleanup();
this.#abort = new AbortController();
this.#trigger = trigger;
this.#boundary = boundary;
const { signal } = this.#abort;
const reposition = () => {
const reposition = (event?: Event) => {
if (event && isEventWithinElement(event, this.#host)) return;
cancelAnimationFrame(this.#frame);
this.#frame = requestAnimationFrame(() => {
if (signal.aborted) return;
@@ -58,6 +62,7 @@ export class PositionController implements ReactiveController {
});
this.#resizeObserver.observe(trigger);
this.#resizeObserver.observe(this.#host);
if (boundary) this.#resizeObserver.observe(boundary);
}
reposition();
@@ -68,8 +73,10 @@ export class PositionController implements ReactiveController {
this.#abort?.abort();
this.#abort = null;
this.#trigger = null;
this.#boundary = null;
cancelAnimationFrame(this.#frame);
this.#frame = 0;
// Disconnect unobserves trigger, host, and boundary together.
this.#resizeObserver?.disconnect();
this.#resizeObserver = null;
}
@@ -1,4 +1,11 @@
import { type ButtonState, TooltipCore, TooltipCSSVars, TooltipDataAttrs, type TooltipInput } from '@videojs/core';
import {
type ButtonState,
POPUP_HOST_ATTR,
TooltipCore,
TooltipCSSVars,
TooltipDataAttrs,
type TooltipInput,
} from '@videojs/core';
import {
applyElementProps,
applyStateDataAttrs,
@@ -7,9 +14,13 @@ import {
getAnchorNameStyle,
getAnchorPositionStyle,
getPopupPositionRect,
getPositioningBoundaryRect,
type PositioningBoundary,
resolveOffsets,
resolvePositioningBoundary,
type TooltipApi,
type TooltipChangeDetails,
type TooltipOpenChangeReason,
} from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
@@ -17,6 +28,7 @@ import type { State } from '@videojs/store';
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 { tooltipGroupContext } from './context';
@@ -42,7 +54,8 @@ export class TooltipElement extends MediaElement {
closeDelay: { type: Number, attribute: 'close-delay' },
disableHoverablePopup: { type: Boolean, attribute: 'disable-hoverable-popup' },
disabled: { type: Boolean },
} satisfies PropertyDeclarationMap<keyof TooltipCore.Props>;
boundary: { type: String },
} satisfies PropertyDeclarationMap<keyof TooltipCore.Props | 'boundary'>;
open = TooltipCore.defaultProps.open;
defaultOpen = TooltipCore.defaultProps.defaultOpen;
@@ -52,9 +65,11 @@ export class TooltipElement extends MediaElement {
closeDelay = TooltipCore.defaultProps.closeDelay;
disableHoverablePopup = TooltipCore.defaultProps.disableHoverablePopup;
disabled = TooltipCore.defaultProps.disabled;
boundary: PositioningBoundary = 'container';
readonly #core = new TooltipCore();
readonly #groupConsumer = new ContextConsumer(this, { context: tooltipGroupContext });
readonly #containerCtx = new ContextConsumer(this, { context: containerContext, subscribe: true });
readonly #position = new PositionController(this);
#tooltip: TooltipApi | null = null;
#snapshot: SnapshotController<TooltipInput> | null = null;
@@ -68,6 +83,8 @@ export class TooltipElement extends MediaElement {
super.connectedCallback();
if (this.destroyed) return;
this.setAttribute(POPUP_HOST_ATTR, '');
this.#disconnect = new AbortController();
this.#tooltip = createTooltip({
@@ -117,6 +134,10 @@ export class TooltipElement extends MediaElement {
this.#disconnect = null;
}
close(reason: TooltipOpenChangeReason = 'imperative-action'): void {
this.#tooltip?.close(reason);
}
protected override willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
this.#core.setProps(this);
@@ -172,26 +193,26 @@ export class TooltipElement extends MediaElement {
// Apply positioning styles to self.
const posOpts = { side: state.side, align: state.align };
const boundaryElement = this.#getBoundaryElement();
const triggerRect = this.#currentTrigger?.getBoundingClientRect();
const boundaryRect = getPositioningBoundaryRect(boundaryElement);
const offsets = resolveOffsets(this, TooltipCSSVars);
if (supportsAnchorPositioning()) {
// Native CSS Anchor Positioning — no JS rect measurements needed.
applyStyles(
this,
getAnchorPositionStyle(this.id, posOpts, undefined, undefined, undefined, undefined, TooltipCSSVars)
getAnchorPositionStyle(this.id, posOpts, triggerRect, undefined, boundaryRect, offsets, TooltipCSSVars)
);
} else {
// JS fallback: measure rects and resolve CSS var offsets.
const triggerRect = this.#currentTrigger?.getBoundingClientRect();
const selfRect = getPopupPositionRect(this);
const boundaryRect = document.documentElement.getBoundingClientRect();
const offsets = resolveOffsets(this, TooltipCSSVars);
applyStyles(
this,
getAnchorPositionStyle(this.id, posOpts, triggerRect, selfRect, boundaryRect, offsets, TooltipCSSVars)
);
}
this.#position.sync(this.#currentTrigger);
this.#position.sync(this.#currentTrigger, boundaryElement);
}
// --- Trigger management ---
@@ -230,4 +251,11 @@ export class TooltipElement 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,
});
}
}