mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): add flip functionality to popovers/tooltips/menus (#1857)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user