feat(packages): add flip functionality to popovers/tooltips/menus (#1857)

This commit is contained in:
Sam Potts
2026-07-28 16:30:24 +10:00
committed by GitHub
parent 359e56ca49
commit be89470447
34 changed files with 699 additions and 141 deletions
+18 -6
View File
@@ -10,6 +10,7 @@ import {
getMenuViewportAttrs,
getMenuViewTransitionAttrs,
getPopupPositionRect,
getPositionedSide,
getPositioningBoundaryRect,
getRootPositionOptions,
isMenuNavigationKey,
@@ -263,16 +264,25 @@ export class MenuElement extends MediaElement {
const boundaryRect = getPositioningBoundaryRect(boundaryElement);
const offsets = resolveOffsets(this);
const anchorSupported = supportsAnchorPositioning();
const getNextStyle = () =>
getAnchorPositionStyle(
if (!triggerRect) return;
const getNextPosition = () => {
const popupRect = getPopupPositionRect(this, positionOptions.side);
const side = getPositionedSide(triggerRect, popupRect, boundaryRect, positionOptions, offsets);
const style = getAnchorPositionStyle(
this.id,
positionOptions,
{ ...positionOptions, side },
triggerRect,
anchorSupported ? undefined : getPopupPositionRect(this),
anchorSupported ? undefined : popupRect,
boundaryRect,
offsets
);
let nextStyle = getNextStyle();
return { side, style };
};
let nextPosition = getNextPosition();
let nextStyle = nextPosition.style;
this.setAttribute(MenuDataAttrs.side, nextPosition.side);
if (anchorSupported) {
applyStyles(this, nextStyle);
@@ -282,7 +292,9 @@ export class MenuElement extends MediaElement {
syncMenuViewRoot(this, this.#navState.stack.length > 0, availableWidth ? { availableWidth } : undefined);
if (!anchorSupported) {
nextStyle = getNextStyle();
nextPosition = getNextPosition();
nextStyle = nextPosition.style;
this.setAttribute(MenuDataAttrs.side, nextPosition.side);
applyStyles(this, nextStyle);
}
@@ -107,10 +107,40 @@ function expectNoMenuStateAttrs(element: HTMLElement): void {
}
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
describe('MenuElement', () => {
it('exposes the positioned side on root content', async () => {
const trigger = document.createElement('button');
const root = createElement(MenuElement);
const view = createElement(MenuViewElement);
const item = createElement(MenuItemElement);
root.id = 'menu';
root.open = true;
root.side = 'top';
root.boundary = 'viewport';
trigger.setAttribute('commandfor', root.id);
item.textContent = 'Auto';
view.append(item);
root.append(view);
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 10, 40, 20));
vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 60));
vi.spyOn(document.documentElement, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 300, 200));
Object.defineProperty(root, 'offsetWidth', { configurable: true, value: 100 });
Object.defineProperty(root, 'offsetHeight', { configurable: true, value: 5 });
Object.defineProperty(root, 'scrollHeight', { configurable: true, value: 60 });
root.style.setProperty('--media-popover-available-height', '5px');
document.body.append(trigger, root);
await root.updateComplete;
expect(root.getAttribute('data-side')).toBe('bottom');
});
it('scopes menu state data attributes to menu elements', async () => {
const root = createElement(MenuElement);
const label = createElement(MenuGroupLabelElement);
@@ -7,6 +7,7 @@ import {
getAnchorNameStyle,
getAnchorPositionStyle,
getPopupPositionRect,
getPositionedSide,
getPositioningBoundaryRect,
type PopoverApi,
type PopoverChangeDetails,
@@ -181,18 +182,24 @@ export class PopoverElement extends MediaElement {
}
// Apply positioning styles to self.
const posOpts = { side: state.side, align: state.align };
const preferredOpts = { side: state.side, align: state.align };
const boundaryElement = this.#getBoundaryElement();
const triggerRect = this.#currentTrigger?.getBoundingClientRect();
const boundaryRect = getPositioningBoundaryRect(boundaryElement);
const offsets = resolveOffsets(this);
const popupRect = getPopupPositionRect(this, preferredOpts.side);
if (!triggerRect) return;
const side = getPositionedSide(triggerRect, popupRect, boundaryRect, preferredOpts, offsets);
const posOpts = { ...preferredOpts, side };
this.setAttribute(PopoverDataAttrs.side, side);
if (supportsAnchorPositioning()) {
applyStyles(this, getAnchorPositionStyle(this.id, posOpts, triggerRect, undefined, boundaryRect, offsets));
} else {
// JS fallback: measure rects and resolve CSS var offsets.
const selfRect = getPopupPositionRect(this);
applyStyles(this, getAnchorPositionStyle(this.id, posOpts, triggerRect, selfRect, boundaryRect, offsets));
applyStyles(this, getAnchorPositionStyle(this.id, posOpts, triggerRect, popupRect, boundaryRect, offsets));
}
this.#position.sync(this.#currentTrigger, boundaryElement);
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PopoverElement } from '../popover-element';
let tagCounter = 0;
function createPopover(): PopoverElement {
const tag = `test-popover-${tagCounter++}`;
customElements.define(tag, class extends PopoverElement {});
return document.createElement(tag) as PopoverElement;
}
function makeDOMRect(x: number, y: number, width: number, height: number): DOMRect {
return new DOMRect(x, y, width, height);
}
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
describe('PopoverElement', () => {
it('exposes the positioned side on the popup', async () => {
const trigger = document.createElement('button');
const popover = createPopover();
popover.id = 'popover';
popover.open = true;
popover.side = 'top';
popover.boundary = 'viewport';
trigger.setAttribute('commandfor', popover.id);
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(makeDOMRect(100, 10, 40, 20));
vi.spyOn(popover, 'getBoundingClientRect').mockReturnValue(makeDOMRect(0, 0, 100, 60));
vi.spyOn(document.documentElement, 'getBoundingClientRect').mockReturnValue(makeDOMRect(0, 0, 300, 200));
Object.defineProperty(popover, 'offsetWidth', { configurable: true, value: 100 });
Object.defineProperty(popover, 'offsetHeight', { configurable: true, value: 60 });
document.body.append(trigger, popover);
await popover.updateComplete;
expect(popover.getAttribute('data-side')).toBe('bottom');
});
});
@@ -4,7 +4,7 @@ import { HOTKEY_SHORTCUT_CHANGE_EVENT, playbackFeature } from '@videojs/core/dom
import { registerI18n, resetI18nRegistry } from '@videojs/core/i18n';
import { ContextProvider } from '@videojs/element/context';
import { createState, createStore } from '@videojs/store';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MediaI18nProviderElement } from '../../../i18n';
import { playerContext } from '../../../player/context';
@@ -97,11 +97,30 @@ function setup() {
defineElement(TestPlayerProviderElement.tagName, TestPlayerProviderElement);
afterEach(() => {
vi.restoreAllMocks();
resetI18nRegistry();
document.body.innerHTML = '';
});
describe('TooltipElement', () => {
it('exposes the positioned side on the popup', async () => {
const { tooltip, trigger } = setup();
tooltip.open = true;
tooltip.side = 'top';
tooltip.boundary = 'viewport';
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 10, 40, 20));
vi.spyOn(tooltip, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 60));
vi.spyOn(document.documentElement, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 300, 200));
Object.defineProperty(tooltip, 'offsetWidth', { configurable: true, value: 100 });
Object.defineProperty(tooltip, 'offsetHeight', { configurable: true, value: 60 });
await tooltip.updateComplete;
expect(tooltip.getAttribute('data-side')).toBe('bottom');
});
it('creates default label and shortcut elements for empty tooltips', async () => {
const { tooltip } = setup();
@@ -14,6 +14,7 @@ import {
getAnchorNameStyle,
getAnchorPositionStyle,
getPopupPositionRect,
getPositionedSide,
getPositioningBoundaryRect,
HOTKEY_SHORTCUT_CHANGE_EVENT,
type PositioningBoundary,
@@ -205,11 +206,18 @@ export class TooltipElement extends MediaElement {
}
// Apply positioning styles to self.
const posOpts = { side: state.side, align: state.align };
const preferredOpts = { side: state.side, align: state.align };
const boundaryElement = this.#getBoundaryElement();
const triggerRect = this.#currentTrigger?.getBoundingClientRect();
const boundaryRect = getPositioningBoundaryRect(boundaryElement);
const offsets = resolveOffsets(this, TooltipCSSVars);
const popupRect = getPopupPositionRect(this, preferredOpts.side);
if (!triggerRect) return;
const side = getPositionedSide(triggerRect, popupRect, boundaryRect, preferredOpts, offsets);
const posOpts = { ...preferredOpts, side };
this.setAttribute(TooltipDataAttrs.side, side);
if (supportsAnchorPositioning()) {
applyStyles(
@@ -218,10 +226,9 @@ export class TooltipElement extends MediaElement {
);
} else {
// JS fallback: measure rects and resolve CSS var offsets.
const selfRect = getPopupPositionRect(this);
applyStyles(
this,
getAnchorPositionStyle(this.id, posOpts, triggerRect, selfRect, boundaryRect, offsets, TooltipCSSVars)
getAnchorPositionStyle(this.id, posOpts, triggerRect, popupRect, boundaryRect, offsets, TooltipCSSVars)
);
}