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
+8 -1
View File
@@ -18,7 +18,14 @@ export { mergeLocaleOverlays } from './locale/merge-locale-overlays';
export { resolveLangAttr } from './locale/resolve-lang-attr';
export { subscribeAmbientLang } from './locale/subscribe-ambient-lang';
export { isMacOS } from './platform';
export { tryHidePopover, tryShowPopover } from './popover';
export {
getPositionedSide,
type PositionSide,
type PositionSideOffsets,
type PositionSideOptions,
tryHidePopover,
tryShowPopover,
} from './popover';
export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './predicates';
export { type RafThrottled, rafThrottle } from './raf-throttle';
export { loadScript } from './script';
+59
View File
@@ -1,3 +1,62 @@
export type PositionSide = 'top' | 'bottom' | 'left' | 'right';
export interface PositionSideOptions {
side: PositionSide;
}
export interface PositionSideOffsets {
sideOffset: number;
boundaryOffset?: number;
}
const ZERO_OFFSETS: PositionSideOffsets = { sideOffset: 0, boundaryOffset: 0 };
const OPPOSITE_SIDE: Record<PositionSide, PositionSide> = {
top: 'bottom',
bottom: 'top',
left: 'right',
right: 'left',
};
function getSideAvailable(
triggerRect: DOMRect,
boundaryRect: DOMRect,
side: PositionSide,
offsets: PositionSideOffsets
): number {
const boundaryOffset = offsets.boundaryOffset ?? 0;
switch (side) {
case 'top':
return triggerRect.top - boundaryRect.top - boundaryOffset - offsets.sideOffset;
case 'bottom':
return boundaryRect.bottom - triggerRect.bottom - boundaryOffset - offsets.sideOffset;
case 'left':
return triggerRect.left - boundaryRect.left - boundaryOffset - offsets.sideOffset;
case 'right':
return boundaryRect.right - triggerRect.right - boundaryOffset - offsets.sideOffset;
}
}
/** Resolve the preferred side against a positioning boundary. */
export function getPositionedSide(
triggerRect: DOMRect,
positionedRect: DOMRect,
boundaryRect: DOMRect,
opts: PositionSideOptions,
offsets: PositionSideOffsets = ZERO_OFFSETS
): PositionSide {
const preferred = opts.side;
const opposite = OPPOSITE_SIDE[preferred];
const size = preferred === 'top' || preferred === 'bottom' ? positionedRect.height : positionedRect.width;
const preferredSpace = getSideAvailable(triggerRect, boundaryRect, preferred, offsets);
if (preferredSpace >= size) return preferred;
const oppositeSpace = getSideAvailable(triggerRect, boundaryRect, opposite, offsets);
return oppositeSpace > preferredSpace ? opposite : preferred;
}
export function tryShowPopover(el: HTMLElement | null): void {
try {
el?.showPopover?.();
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { getPositionedSide } from '../popover';
function makeDOMRect(x: number, y: number, width: number, height: number): DOMRect {
return {
x,
y,
width,
height,
top: y,
right: x + width,
bottom: y + height,
left: x,
toJSON: () => ({}),
};
}
describe('getPositionedSide', () => {
const boundary = makeDOMRect(0, 0, 300, 200);
const positioned = makeDOMRect(0, 0, 100, 60);
const opts = { side: 'top' } as const;
it('keeps the preferred side when it fits', () => {
const trigger = makeDOMRect(100, 100, 40, 20);
expect(getPositionedSide(trigger, positioned, boundary, opts)).toBe('top');
});
it('flips vertical sides when the opposite side fits better', () => {
const topTrigger = makeDOMRect(100, 10, 40, 20);
const bottomTrigger = makeDOMRect(100, 170, 40, 20);
expect(getPositionedSide(topTrigger, positioned, boundary, opts)).toBe('bottom');
expect(getPositionedSide(bottomTrigger, positioned, boundary, { side: 'bottom' })).toBe('top');
});
it('flips horizontal sides when the opposite side fits better', () => {
const leftTrigger = makeDOMRect(10, 80, 40, 20);
const rightTrigger = makeDOMRect(250, 80, 40, 20);
expect(getPositionedSide(leftTrigger, positioned, boundary, { side: 'left' })).toBe('right');
expect(getPositionedSide(rightTrigger, positioned, boundary, { side: 'right' })).toBe('left');
});
it('chooses the side with more space when neither side fits', () => {
const smallBoundary = makeDOMRect(0, 0, 300, 100);
const largePositioned = makeDOMRect(0, 0, 100, 80);
const trigger = makeDOMRect(100, 30, 40, 10);
expect(getPositionedSide(trigger, largePositioned, smallBoundary, opts)).toBe('bottom');
});
it('keeps the preferred side when neither side fits and available space is tied', () => {
const smallBoundary = makeDOMRect(0, 0, 300, 100);
const largePositioned = makeDOMRect(0, 0, 100, 80);
const trigger = makeDOMRect(100, 45, 40, 10);
expect(getPositionedSide(trigger, largePositioned, smallBoundary, opts)).toBe('top');
});
it('includes side and boundary offsets when checking available space', () => {
const tallBoundary = makeDOMRect(0, 0, 300, 180);
const trigger = makeDOMRect(100, 70, 40, 10);
expect(getPositionedSide(trigger, positioned, tallBoundary, opts)).toBe('top');
expect(
getPositionedSide(trigger, positioned, tallBoundary, opts, {
sideOffset: 8,
boundaryOffset: 4,
})
).toBe('bottom');
});
});