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
+2 -1
View File
@@ -7,7 +7,7 @@ import { getTransitionFlags } from '../transition';
export type { PopoverAlign, PopoverSide };
export interface MenuProps {
/** Which side of the trigger the menu appears on. Root menus only. */
/** Preferred side of the trigger for the menu. Root menus only. */
side?: PopoverSide | undefined;
/** Alignment along the trigger's edge. Root menus only. */
align?: PopoverAlign | undefined;
@@ -29,6 +29,7 @@ export interface MenuInput extends TransitionState {}
export interface MenuState extends TransitionFlags {
open: boolean;
status: TransitionStatus;
/** Preferred side of the trigger for the menu. Root menus only. */
side: PopoverSide | undefined;
align: PopoverAlign | undefined;
/** Whether this menu is nested inside another menu's content. */
@@ -6,7 +6,7 @@ import type { MenuState } from './menu-core';
export const MenuDataAttrs = {
/** Present when the menu is open. */
open: 'data-open',
/** Popover positioning side. Absent on submenus. */
/** Rendered positioning side after collision handling. Absent on submenus. */
side: 'data-side',
/** Popover positioning alignment. Absent on submenus. */
align: 'data-align',
@@ -9,7 +9,7 @@ export type PopoverSide = 'top' | 'bottom' | 'left' | 'right';
export type PopoverAlign = 'start' | 'center' | 'end';
export interface PopoverProps {
/** Which side of the trigger the popup appears on. */
/** Preferred side of the trigger for the popup. */
side?: PopoverSide | undefined;
/** Alignment of the popup along the trigger's edge. */
align?: PopoverAlign | undefined;
@@ -45,6 +45,7 @@ export interface PopoverInput extends TransitionState {}
export interface PopoverState extends TransitionFlags {
open: boolean;
status: TransitionStatus;
/** Preferred side of the trigger for the popup. */
side: PopoverSide;
align: PopoverAlign;
modal: boolean | 'trap-focus';
@@ -5,7 +5,7 @@ import type { PopoverState } from './popover-core';
export const PopoverDataAttrs = {
/** Present when the popover is open. */
open: 'data-open',
/** Indicates which side the popover is positioned relative to the trigger. */
/** Indicates the rendered side of the popover after collision handling. */
side: 'data-side',
/** Indicates how the popover is aligned relative to the specified side. */
align: 'data-align',
@@ -6,7 +6,7 @@ import type { TransitionFlags, TransitionState, TransitionStatus } from '../tran
import { getTransitionFlags } from '../transition';
export interface TooltipProps {
/** Which side of the trigger the tooltip appears on. */
/** Preferred side of the trigger for the tooltip. */
side?: PopoverSide | undefined;
/** Alignment of the tooltip along the trigger's edge. */
align?: PopoverAlign | undefined;
@@ -31,7 +31,7 @@ export interface TooltipState extends TransitionFlags {
open: boolean;
/** Current phase of the transition lifecycle. */
status: TransitionStatus;
/** Which side of the trigger the tooltip is positioned on. */
/** Preferred side of the trigger for the tooltip. */
side: PopoverSide;
/** How the tooltip is aligned relative to the specified side. */
align: PopoverAlign;
@@ -5,7 +5,7 @@ import type { TooltipState } from './tooltip-core';
export const TooltipDataAttrs = {
/** Present when the tooltip is open. */
open: 'data-open',
/** Indicates which side the tooltip is positioned relative to the trigger. */
/** Indicates the rendered side of the tooltip after collision handling. */
side: 'data-side',
/** Indicates how the tooltip is aligned relative to the specified side. */
align: 'data-align',
@@ -4,6 +4,8 @@ import type { PopoverAlign, PopoverSide } from '../../../core/ui/popover/popover
import { type PopoverCSSVarKey, PopoverCSSVars } from '../../../core/ui/popover/popover-css-vars';
import { createDOMRect } from '../../utils/layout';
export { getPositionedSide } from '@videojs/utils/dom';
export interface PositioningOptions {
side: PopoverSide;
align: PopoverAlign;
@@ -56,22 +58,6 @@ function formatPixels(value: number): string {
return `${clamp(value, 0, Infinity)}px`;
}
function getCrossAxisAvailable(
start: number,
end: number,
size: number,
boundaryStart: number,
boundaryEnd: number,
align: PopoverAlign,
alignOffset: number
): number {
if (align === 'start') return boundaryEnd - (start + alignOffset);
if (align === 'end') return end + alignOffset - boundaryStart;
const center = start + size / 2 + alignOffset;
return Math.min(center - boundaryStart, boundaryEnd - center) * 2;
}
function shiftCrossAxis(value: number, boundaryStart: number, boundaryEnd: number, size: number): number {
const max = boundaryEnd - size;
return max < boundaryStart ? boundaryStart : clamp(value, boundaryStart, max);
@@ -134,13 +120,10 @@ export function getAnchorPositionStyle(
if (triggerRect && popupRect) {
const resolved: ManualOffsets = offsets ?? ZERO_OFFSETS;
return {
position: 'fixed',
margin: '0',
...getManualPositionStyle(triggerRect, popupRect, opts, resolved, boundaryRect),
...(boundaryRect ? getPositioningCSSVars(triggerRect, boundaryRect, opts, resolved, cssVars) : {}),
position: 'fixed',
// Reset UA [popover] defaults (inset: 0; margin: auto) which would
// otherwise conflict with computed positioning.
inset: 'auto',
margin: '0',
};
}
@@ -266,7 +249,7 @@ export function getPositioningCSSVars(
cssVars: PositioningCSSVars = PopoverCSSVars
): Record<string, string> {
const vars: Record<string, string> = {};
const { side, align } = opts;
const { side } = opts;
const boundaryOffset = offsets.boundaryOffset ?? 0;
const boundaryStartX = boundaryRect.left + boundaryOffset;
const boundaryEndX = boundaryRect.right - boundaryOffset;
@@ -280,32 +263,12 @@ export function getPositioningCSSVars(
const sideSpace = side === 'top' ? triggerRect.top - boundaryStartY : boundaryEndY - triggerRect.bottom;
vars[cssVars.availableHeight] = formatPixels(sideSpace - offsets.sideOffset);
vars[cssVars.availableWidth] = formatPixels(
getCrossAxisAvailable(
triggerRect.left,
triggerRect.right,
triggerRect.width,
boundaryStartX,
boundaryEndX,
align,
offsets.alignOffset
)
);
vars[cssVars.availableWidth] = formatPixels(boundaryEndX - boundaryStartX);
} else {
const sideSpace = side === 'left' ? triggerRect.left - boundaryStartX : boundaryEndX - triggerRect.right;
vars[cssVars.availableWidth] = formatPixels(sideSpace - offsets.sideOffset);
vars[cssVars.availableHeight] = formatPixels(
getCrossAxisAvailable(
triggerRect.top,
triggerRect.bottom,
triggerRect.height,
boundaryStartY,
boundaryEndY,
align,
offsets.alignOffset
)
);
vars[cssVars.availableHeight] = formatPixels(boundaryEndY - boundaryStartY);
}
return vars;
@@ -355,16 +318,18 @@ export function getManualPositionStyle(
const { side, align } = opts;
const { sideOffset, alignOffset } = offsets;
let top = 0;
let bottom: string | undefined;
let left = 0;
let right: string | undefined;
// Side positioning in viewport coordinates.
// Positive sideOffset always increases distance from the trigger.
if (side === 'top') {
top = triggerRect.top - popupRect.height - sideOffset;
bottom = `calc(100% - ${triggerRect.top}px + ${sideOffset}px)`;
} else if (side === 'bottom') {
top = triggerRect.bottom + sideOffset;
} else if (side === 'left') {
left = triggerRect.left - popupRect.width - sideOffset;
right = `calc(100% - ${triggerRect.left}px + ${sideOffset}px)`;
} else {
left = triggerRect.right + sideOffset;
}
@@ -409,8 +374,10 @@ export function getManualPositionStyle(
}
return {
top: `${top}px`,
left: `${left}px`,
top: side === 'top' ? 'auto' : `${top}px`,
bottom: bottom ?? 'auto',
left: side === 'left' ? 'auto' : `${left}px`,
right: right ?? 'auto',
};
}
@@ -432,12 +399,18 @@ export function resolveOffsets(el: Element, cssVars: PositioningCSSVars = Popove
*
* `getBoundingClientRect()` includes active transforms, which causes the
* fallback position to drift while opening/closing animations scale the popup.
* Using `offsetWidth`/`offsetHeight` preserves the untransformed size.
* Using layout dimensions preserves the untransformed size, while the
* side-axis scroll dimension includes content clipped by size constraints.
*/
export function getPopupPositionRect(el: HTMLElement): DOMRect {
export function getPopupPositionRect(el: HTMLElement, side: PopoverSide): DOMRect {
const rect = el.getBoundingClientRect();
const width = el.offsetWidth || rect.width;
const height = el.offsetHeight || rect.height;
return createDOMRect(rect.left, rect.top, width, height);
return createDOMRect(
rect.left,
rect.top,
side === 'left' || side === 'right' ? Math.max(width, el.scrollWidth) : width,
side === 'top' || side === 'bottom' ? Math.max(height, el.scrollHeight) : height
);
}
@@ -41,8 +41,8 @@ describe('getManualPositionStyle', () => {
it('positions above trigger for side=top', () => {
const style = getManualPositionStyle(trigger, popup, { side: 'top', align: 'center' });
// top = trigger.top - popup.height = 200 - 80 = 120
expect(style.top).toBe('120px');
expect(style.bottom).toBe('calc(100% - 200px + 0px)');
expect(style.top).toBe('auto');
// left = trigger.left + (trigger.width - popup.width)/2 = 100 + (120-200)/2 = 60
expect(style.left).toBe('60px');
});
@@ -57,8 +57,8 @@ describe('getManualPositionStyle', () => {
it('positions to the left of trigger for side=left', () => {
const style = getManualPositionStyle(trigger, popup, { side: 'left', align: 'center' });
// left = trigger.left - popup.width = 100 - 200 = -100
expect(style.left).toBe('-100px');
expect(style.right).toBe('calc(100% - 100px + 0px)');
expect(style.left).toBe('auto');
});
it('positions to the right of trigger for side=right', () => {
@@ -68,12 +68,23 @@ describe('getManualPositionStyle', () => {
expect(style.left).toBe('220px');
});
it('positions top and left popups independently of their side-axis size', () => {
const shortPopup = makeDOMRect(0, 0, popup.width, 20);
const narrowPopup = makeDOMRect(0, 0, 20, popup.height);
expect(getManualPositionStyle(trigger, shortPopup, { side: 'top', align: 'center' }).bottom).toBe(
'calc(100% - 200px + 0px)'
);
expect(getManualPositionStyle(trigger, narrowPopup, { side: 'left', align: 'center' }).right).toBe(
'calc(100% - 100px + 0px)'
);
});
it('applies sideOffset from resolved CSS vars', () => {
const offsets: ManualOffsets = { sideOffset: 8, alignOffset: 0 };
const style = getManualPositionStyle(trigger, popup, { side: 'top', align: 'center' }, offsets);
// top = 200 - 80 - 8 = 112
expect(style.top).toBe('112px');
expect(style.bottom).toBe('calc(100% - 200px + 8px)');
});
it('applies sideOffset for bottom side', () => {
@@ -134,7 +145,7 @@ describe('getManualPositionStyle', () => {
boundary
);
expect(topStyle.top).toBe('50px');
expect(topStyle.bottom).toBe('calc(100% - 100px + 0px)');
expect(topStyle.left).toBe('200px');
expect(bottomStyle.top).toBe('120px');
expect(bottomStyle.left).toBe('0px');
@@ -164,7 +175,7 @@ describe('getManualPositionStyle', () => {
expect(rightStyle.top).toBe('120px');
expect(rightStyle.left).toBe('140px');
expect(leftStyle.top).toBe('0px');
expect(leftStyle.left).toBe('20px');
expect(leftStyle.right).toBe('calc(100% - 100px + 0px)');
});
it('respects boundary offset when shifting cross-axis overflow', () => {
@@ -181,6 +192,7 @@ describe('getManualPositionStyle', () => {
boundary
);
expect(style.bottom).toBe('auto');
expect(style.top).toBe('120px');
expect(style.left).toBe('188px');
});
@@ -248,7 +260,7 @@ describe('getPopoverCSSVars', () => {
describe('getPositioningCSSVars', () => {
const boundary = makeDOMRect(0, 0, 300, 200);
it('computes available size for center-aligned top and bottom popups', () => {
it('uses the boundary width for top and bottom popups', () => {
const trigger = makeDOMRect(250, 150, 40, 20);
const vars = getPositioningCSSVars(
trigger,
@@ -258,22 +270,22 @@ describe('getPositioningCSSVars', () => {
);
expect(vars[PopoverCSSVars.availableHeight]).toBe('22px');
expect(vars[PopoverCSSVars.availableWidth]).toBe('60px');
expect(vars[PopoverCSSVars.availableWidth]).toBe('300px');
});
it('applies align offset to start-aligned cross-axis size', () => {
it.each(['start', 'center', 'end'] as const)('does not reduce cross-axis size for %s alignment', (align) => {
const trigger = makeDOMRect(250, 150, 40, 20);
const vars = getPositioningCSSVars(
trigger,
boundary,
{ side: 'bottom', align: 'start' },
{ side: 'bottom', align },
{ sideOffset: 0, alignOffset: 10 }
);
expect(vars[PopoverCSSVars.availableWidth]).toBe('40px');
expect(vars[PopoverCSSVars.availableWidth]).toBe('300px');
});
it('computes available size for center-aligned left and right popups', () => {
it('uses the boundary height for left and right popups', () => {
const trigger = makeDOMRect(120, 160, 40, 20);
const vars = getPositioningCSSVars(
trigger,
@@ -283,7 +295,7 @@ describe('getPositioningCSSVars', () => {
);
expect(vars[PopoverCSSVars.availableWidth]).toBe('128px');
expect(vars[PopoverCSSVars.availableHeight]).toBe('60px');
expect(vars[PopoverCSSVars.availableHeight]).toBe('200px');
});
it('subtracts boundary offset from side-axis and cross-axis sizes', () => {
@@ -296,7 +308,7 @@ describe('getPositioningCSSVars', () => {
);
expect(vars[PopoverCSSVars.availableHeight]).toBe('12px');
expect(vars[PopoverCSSVars.availableWidth]).toBe('40px');
expect(vars[PopoverCSSVars.availableWidth]).toBe('280px');
});
});
@@ -321,7 +333,8 @@ describe('getAnchorPositionStyle', () => {
const style = getAnchorPositionStyle('my-anchor', { side: 'top', align: 'center' }, trigger, positioner, boundary);
expect(style.top).toBe('120px');
expect(style.bottom).toBe('calc(100% - 200px + 0px)');
expect(style.top).toBe('auto');
expect(style.left).toBe('60px');
expect(style.position).toBe('fixed');
// Also includes sizing CSS vars
@@ -359,7 +372,7 @@ describe('getPopupPositionRect', () => {
Object.defineProperty(el, 'offsetHeight', { configurable: true, value: 80 });
vi.spyOn(el, 'getBoundingClientRect').mockImplementation(() => makeDOMRect(20, 40, 100, 40));
const rect = getPopupPositionRect(el);
const rect = getPopupPositionRect(el, 'top');
expect(rect.left).toBe(20);
expect(rect.top).toBe(40);
@@ -376,7 +389,7 @@ describe('getPopupPositionRect', () => {
Object.defineProperty(el, 'offsetHeight', { configurable: true, value: 80 });
vi.spyOn(el, 'getBoundingClientRect').mockImplementation(() => makeDOMRect(20, 40, 100, 40));
const rect = getPopupPositionRect(el);
const rect = getPopupPositionRect(el, 'top');
expect(rect.toJSON()).toEqual(
expect.objectContaining({
@@ -389,6 +402,37 @@ describe('getPopupPositionRect', () => {
})
);
});
it.each([
['top', 100, 80],
['left', 120, 60],
] as const)('includes overflow on the %s side axis', (side, expectedWidth, expectedHeight) => {
const el = document.createElement('div');
vi.spyOn(el, 'getBoundingClientRect').mockImplementation(() => makeDOMRect(20, 40, 100, 60));
Object.defineProperty(el, 'offsetWidth', { configurable: true, value: 100 });
Object.defineProperty(el, 'offsetHeight', { configurable: true, value: 60 });
Object.defineProperty(el, 'scrollWidth', { configurable: true, value: 120 });
Object.defineProperty(el, 'scrollHeight', { configurable: true, value: 80 });
const rect = getPopupPositionRect(el, side);
expect(rect.width).toBe(expectedWidth);
expect(rect.height).toBe(expectedHeight);
});
it('does not change available-size styles while measuring', () => {
const el = document.createElement('div');
el.style.setProperty(PopoverCSSVars.availableHeight, '20px');
vi.spyOn(el, 'getBoundingClientRect').mockImplementation(() => {
expect(el.style.getPropertyValue(PopoverCSSVars.availableHeight)).toBe('20px');
return makeDOMRect(20, 40, 100, 60);
});
Object.defineProperty(el, 'offsetWidth', { configurable: true, value: 100 });
Object.defineProperty(el, 'offsetHeight', { configurable: true, value: 20 });
Object.defineProperty(el, 'scrollHeight', { configurable: true, value: 60 });
expect(getPopupPositionRect(el, 'top').height).toBe(60);
expect(el.style.getPropertyValue(PopoverCSSVars.availableHeight)).toBe('20px');
});
});
// Tests the CSS anchor positioning path via getAnchorPositionStyle with