diff --git a/change/@fluentui-react-positioning-76925269-6201-44d2-ad51-c66d334c5418.json b/change/@fluentui-react-positioning-76925269-6201-44d2-ad51-c66d334c5418.json new file mode 100644 index 00000000000000..da166524a01be4 --- /dev/null +++ b/change/@fluentui-react-positioning-76925269-6201-44d2-ad51-c66d334c5418.json @@ -0,0 +1,6 @@ +{ + "type": "none", + "comment": "test(react-positioning): pin portable browser geometry and lifecycle contracts with exact engine output", + "packageName": "@fluentui/react-positioning", + "email": "dmytrokirpa@microsoft.com" +} diff --git a/packages/react-components/react-positioning/library/src/usePositioning.geometry-baseline.cy.ts b/packages/react-components/react-positioning/library/src/usePositioning.geometry-baseline.cy.ts new file mode 100644 index 00000000000000..0688e4fb1ad1ef --- /dev/null +++ b/packages/react-components/react-positioning/library/src/usePositioning.geometry-baseline.cy.ts @@ -0,0 +1,266 @@ +// Captured from unchanged Floating UI baseline f626209351609e43bc98eb8cc1296273faa679b7, +// Cypress 14.4.1 / Electron 130, viewport 800 x 600. Do not regenerate for an engine replacement. +type Overrides = Partial<{ + width: number; + height: number; + position: string; + transform: string; + left: string; + top: string; + maxWidth: string; + maxHeight: string; + arrowLeft: string; + arrowTop: string; + flags: string; + matchTargetSize: string; + overflowX: string; + overflowY: string; +}>; + +// This only expands literal expected values; it never calls the engine or reads actual geometry. +function at(x: number, y: number, placement = 'bottom', values: Overrides = {}) { + return [ + x, + y, + values.width ?? 120, + values.height ?? 60, + placement, + values.position ?? 'absolute', + values.transform ?? `translate(${x}px, ${y}px)`, + values.left ?? '0px', + values.top ?? '0px', + values.maxWidth ?? '', + values.maxHeight ?? '', + values.arrowLeft ?? '', + values.arrowTop ?? '', + values.flags ?? '', + values.matchTargetSize ?? '', + values.overflowX ?? '', + values.overflowY ?? '', + ]; +} + +export const geometryBaseline: Record = { + 'ltr-above-top': at(280, 180, 'top'), + 'ltr-above-bottom': at(280, 180, 'top'), + 'ltr-above-start': at(300, 180, 'top-start'), + 'ltr-above-end': at(260, 180, 'top-end'), + 'ltr-above-center': at(280, 180, 'top'), + 'ltr-below-top': at(280, 280), + 'ltr-below-bottom': at(280, 280), + 'ltr-below-start': at(300, 280, 'bottom-start'), + 'ltr-below-end': at(260, 280, 'bottom-end'), + 'ltr-below-center': at(280, 280), + 'ltr-before-top': at(180, 240, 'left-start'), + 'ltr-before-bottom': at(180, 220, 'left-end'), + 'ltr-before-start': at(180, 230, 'left'), + 'ltr-before-end': at(180, 230, 'left'), + 'ltr-before-center': at(180, 230, 'left'), + 'ltr-after-top': at(380, 240, 'right-start'), + 'ltr-after-bottom': at(380, 220, 'right-end'), + 'ltr-after-start': at(380, 230, 'right'), + 'ltr-after-end': at(380, 230, 'right'), + 'ltr-after-center': at(380, 230, 'right'), + 'rtl-above-top': at(280, 180, 'top'), + 'rtl-above-bottom': at(280, 180, 'top'), + 'rtl-above-start': at(260, 180, 'top-start'), + 'rtl-above-end': at(300, 180, 'top-end'), + 'rtl-above-center': at(280, 180, 'top'), + 'rtl-below-top': at(280, 280), + 'rtl-below-bottom': at(280, 280), + 'rtl-below-start': at(260, 280, 'bottom-start'), + 'rtl-below-end': at(300, 280, 'bottom-end'), + 'rtl-below-center': at(280, 280), + 'rtl-before-top': at(380, 240, 'right-start'), + 'rtl-before-bottom': at(380, 220, 'right-end'), + 'rtl-before-start': at(380, 230, 'right'), + 'rtl-before-end': at(380, 230, 'right'), + 'rtl-before-center': at(380, 230, 'right'), + 'rtl-after-top': at(180, 240, 'left-start'), + 'rtl-after-bottom': at(180, 220, 'left-end'), + 'rtl-after-start': at(180, 230, 'left'), + 'rtl-after-end': at(180, 230, 'left'), + 'rtl-after-center': at(180, 230, 'left'), + defaults: at(280, 280), + 'flip-bottom-edge': at(280, 500, 'top'), + 'flip-top-edge': at(300, 40, 'bottom-start'), + 'flip-left-edge': at(80, 240, 'right-start'), + 'flip-right-edge': at(620, 220, 'left-end'), + 'flip-alignment': at(680, 280, 'bottom-end'), + 'pinned-bottom': at(280, 600, 'bottom', { flags: 'data-popper-escaped' }), + 'pinned-still-shifts': at(680, 280, 'bottom-start'), + 'fallback-first-fit': at(300, 500, 'top-start'), + 'fallback-reversed': at(180, 540, 'left-end'), + 'fallback-best-fit': at(150, 320, 'bottom', { width: 500, height: 400, flags: 'data-popper-is-intersecting' }), + 'fallback-rtl': at(260, 500, 'top-start'), + 'shift-left': at(0, 280), + 'shift-right': at(680, 280), + 'tether-default': at(280, 690, 'bottom', { flags: 'data-popper-escaped,data-popper-reference-hidden' }), + 'tether-disabled': at(280, 690, 'bottom', { flags: 'data-popper-escaped,data-popper-reference-hidden' }), + 'tether-disabled-all': at(280, 540, 'bottom', { flags: 'data-popper-reference-hidden' }), + 'shift-to-cover': at(280, 540), + 'shift-to-cover-limit': at(280, 590, 'bottom', { flags: 'data-popper-reference-hidden,data-popper-is-intersecting' }), + 'offset-zero': at(280, 280), + 'offset-positive': at(280, 293), + 'offset-negative': at(280, 191, 'top'), + 'offset-object': at(317, 289, 'bottom-start'), + 'offset-object-rtl': at(243, 289, 'bottom-start'), + 'offset-horizontal': at(171, 213, 'left'), + 'offset-function': at(277, 167, 'top-end'), + 'offset-function-number': at(280, 290), + 'offset-flipped': at(280, 488, 'top'), + 'arrow-centered': at(280, 280, 'bottom', { arrowLeft: '55px' }), + 'arrow-start': at(300, 290, 'bottom-start', { arrowLeft: '35px' }), + 'arrow-horizontal': at(380, 240, 'right-start', { arrowTop: '15px' }), + 'arrow-padding': at(277, 280, 'bottom-start', { arrowLeft: '20px' }), + 'arrow-shift': at(680, 280, 'bottom', { arrowLeft: '110px' }), + 'arrow-flip': at(280, 500, 'top', { arrowLeft: '55px' }), + 'boundary-rect': at(380, 310, 'top'), + 'boundary-element': at(380, 310, 'top'), + 'boundary-array': at(330, 310, 'top'), + 'boundary-flip-only': at(450, 310, 'top'), + 'boundary-overflow-only': at(380, 410), + 'boundary-window': at(680, 500, 'top'), + 'boundary-scroll-parent': at(320, 220, 'top', { transform: 'translate(280px, 190px)' }), + 'boundary-clipping': at(320, 220, 'top', { transform: 'translate(280px, 190px)' }), + 'padding-number': at(664, 280), + 'padding-logical-ltr': at(653, 280), + 'padding-logical-rtl': at(669, 280), + 'padding-partial': at(23, 280), + 'match-width': at(280, 280, 'bottom', { matchTargetSize: '80px' }), + 'match-intrinsic-width': at(300, 280, 'bottom', { width: 80, matchTargetSize: '80px' }), + 'match-width-horizontal': at(380, 230, 'right', { matchTargetSize: '80px' }), + 'cover-above': at(280, 220, 'top'), + 'cover-below': at(280, 240), + 'cover-before': at(260, 230, 'left'), + 'cover-after': at(300, 230, 'right'), + 'auto-size-true': at(12, 280, 'bottom', { + width: 776, + height: 308, + maxWidth: '776px', + maxHeight: '308px', + overflowX: 'auto', + overflowY: 'auto', + }), + 'auto-size-height': at(12, 280, 'bottom', { width: 900, height: 308, maxHeight: '308px', overflowY: 'auto' }), + 'auto-size-width': at(12, 280, 'bottom', { + width: 776, + height: 700, + maxWidth: '776px', + overflowX: 'auto', + flags: 'data-popper-is-intersecting', + }), + 'auto-size-always': at(12, 280, 'bottom', { + width: 776, + height: 308, + maxWidth: '776px', + maxHeight: '308px', + overflowX: 'auto', + overflowY: 'auto', + }), + 'auto-size-height-always': at(12, 280, 'bottom', { width: 900, height: 308, maxHeight: '308px', overflowY: 'auto' }), + 'auto-size-width-always': at(12, 280, 'bottom', { + width: 776, + height: 700, + maxWidth: '776px', + overflowX: 'auto', + flags: 'data-popper-is-intersecting', + }), + 'auto-size-false': at(12, 280, 'bottom', { width: 900, height: 700, flags: 'data-popper-is-intersecting' }), + 'auto-size-boundary': at(100, 100, 'top', { + width: 400, + height: 140, + maxWidth: '400px', + maxHeight: '140px', + overflowX: 'auto', + overflowY: 'auto', + }), + 'auto-size-match': at(300, 280, 'bottom', { + width: 80, + maxWidth: '800px', + maxHeight: '320px', + matchTargetSize: '80px', + }), + // Baseline behavior: explicitly passing either strategy currently resolves to fixed. + 'strategy-absolute': at(280, 280, 'bottom', { position: 'fixed' }), + 'strategy-fixed': at(280, 280, 'bottom', { position: 'fixed' }), + 'strategy-deprecated-fixed': at(280, 280, 'bottom', { position: 'fixed' }), + 'no-transform-absolute': at(280, 280, 'bottom', { position: 'fixed', transform: '', left: '280px', top: '280px' }), + 'no-transform-fixed': at(280, 280, 'bottom', { position: 'fixed', transform: '', left: '280px', top: '280px' }), + 'no-transform-fixed-rtl': at(260, 280, 'bottom-start', { + position: 'fixed', + transform: '', + left: '260px', + top: '280px', + }), + 'fractional-dpr-1-transform-true': at(280, 281, 'bottom', { width: 120.25, height: 60.25 }), + 'fractional-dpr-1-transform-false': at(280, 281, 'bottom', { + width: 120.25, + height: 60.25, + transform: '', + left: '280px', + top: '281px', + }), + 'fractional-dpr-1.25-transform-true': at(280, 280.79998779296875, 'bottom', { + width: 120.25, + height: 60.25, + transform: 'translate3d(280px, 280.8px, 0px)', + }), + 'fractional-dpr-1.25-transform-false': at(280, 280.796875, 'bottom', { + width: 120.25, + height: 60.25, + transform: '', + left: '280px', + top: '280.8px', + }), + 'fractional-dpr-1.5-transform-true': at(280.6666564941406, 280.6666564941406, 'bottom', { + width: 120.25, + height: 60.25, + transform: 'translate3d(280.667px, 280.667px, 0px)', + }), + 'fractional-dpr-1.5-transform-false': at(280.6640625, 280.6640625, 'bottom', { + width: 120.25, + height: 60.25, + transform: '', + left: '280.667px', + top: '280.667px', + }), + 'fractional-dpr-2-transform-true': at(280.5, 281, 'bottom', { + width: 120.25, + height: 60.25, + transform: 'translate3d(280.5px, 281px, 0px)', + }), + 'fractional-dpr-2-transform-false': at(280.5, 281, 'bottom', { + width: 120.25, + height: 60.25, + transform: '', + left: '280.5px', + top: '281px', + }), + 'fractional-dpr-3-transform-true': at(280.3333435058594, 280.6666564941406, 'bottom', { + width: 120.25, + height: 60.25, + transform: 'translate3d(280.333px, 280.667px, 0px)', + }), + 'fractional-dpr-3-transform-false': at(280.328125, 280.6640625, 'bottom', { + width: 120.25, + height: 60.25, + transform: '', + left: '280.333px', + top: '280.667px', + }), + 'nested-scroll': at(320, 210, 'top', { transform: 'translate(280px, 180px)' }), + 'scaled-containing-block': at(390, 380, 'bottom', { width: 150, height: 75, transform: 'translate(280px, 280px)' }), + 'transformed-containing-block': at(345, 325, 'bottom', { transform: 'translate(280px, 280px)' }), + 'fixed-transformed-containing-block': at(345, 325, 'bottom', { + position: 'fixed', + transform: 'translate(280px, 280px)', + }), + 'virtual-target': at(280, 280), + 'virtual-zero-size': at(240, 240), + 'hidden-target': at(320, 470, 'top', { + transform: 'translate(280px, 440px)', + flags: 'data-popper-escaped,data-popper-reference-hidden', + }), + 'partially-clipped': at(320, 350, 'bottom', { transform: 'translate(280px, 320px)', flags: 'data-popper-escaped' }), +}; diff --git a/packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx b/packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx new file mode 100644 index 00000000000000..c8a7b6994a5635 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx @@ -0,0 +1,677 @@ +import * as React from 'react'; +import { mount } from '@fluentui/scripts-cypress'; +import { FluentProvider } from '@fluentui/react-provider'; +import { useFluent_unstable } from '@fluentui/react-shared-contexts'; +import { usePositioning } from './usePositioning'; +import type { + Alignment, + OnPositioningEndEventDetail, + Position, + PositioningBoundary, + PositioningImperativeRef, + PositioningOptions, + PositioningProps, + PositioningRect, +} from './types'; +import { geometryBaseline } from './usePositioning.geometry-baseline.cy'; + +type Scenario = { + name: string; + dir?: 'ltr' | 'rtl'; + options?: PositioningOptions; + target?: Partial; + floating?: { width: number; height: number }; + arrow?: boolean; + boundary?: 'rect' | 'element' | 'array' | 'window' | 'scrollParent' | 'clippingParents'; + boundaryMode?: 'flip' | 'overflow'; + host?: 'scroll' | 'scale' | 'transform'; + virtual?: boolean; + dpr?: number; + intrinsic?: boolean; + updateStateOnEnd?: boolean; +}; + +// All dimensions in this fixture are deliberate geometry inputs, not product styling. +const positions: Position[] = ['above', 'below', 'before', 'after']; +const alignments: Alignment[] = ['top', 'bottom', 'start', 'end', 'center']; +const scenarios: Scenario[] = []; +for (const dir of ['ltr', 'rtl'] as const) { + for (const position of positions) { + for (const align of alignments) { + scenarios.push({ name: `${dir}-${position}-${align}`, dir, options: { position, align } }); + } + } +} + +scenarios.push( + { name: 'defaults' }, + { name: 'flip-bottom-edge', target: { y: 560 } }, + { name: 'flip-top-edge', target: { y: 0 }, options: { position: 'above', align: 'start' } }, + { name: 'flip-left-edge', target: { x: 0 }, options: { position: 'before', align: 'top' } }, + { name: 'flip-right-edge', target: { x: 740 }, options: { position: 'after', align: 'bottom' } }, + { name: 'flip-alignment', target: { x: 760 }, options: { align: 'start' } }, + { name: 'pinned-bottom', target: { y: 560 }, options: { pinned: true } }, + { name: 'pinned-still-shifts', target: { x: 780 }, options: { pinned: true, align: 'start' } }, + { + name: 'fallback-first-fit', + target: { y: 560 }, + options: { fallbackPositions: ['after-top', 'above-start', 'before-bottom'] }, + }, + { + name: 'fallback-reversed', + target: { y: 560 }, + options: { fallbackPositions: ['before-bottom', 'above-start', 'after-top'] }, + }, + { + name: 'fallback-best-fit', + target: { x: 360, y: 280 }, + floating: { width: 500, height: 400 }, + options: { fallbackPositions: ['after-top', 'above', 'before-bottom'] }, + }, + { + name: 'fallback-rtl', + dir: 'rtl', + target: { y: 560 }, + options: { fallbackPositions: ['after-top', 'above-start'] }, + }, + { name: 'shift-left', target: { x: -40 } }, + { name: 'shift-right', target: { x: 780 } }, + { name: 'tether-default', target: { y: 650 }, options: { pinned: true } }, + // eslint-disable-next-line @typescript-eslint/naming-convention + { name: 'tether-disabled', target: { y: 650 }, options: { pinned: true, unstable_disableTether: true } }, + // eslint-disable-next-line @typescript-eslint/naming-convention + { name: 'tether-disabled-all', target: { y: 650 }, options: { pinned: true, unstable_disableTether: 'all' } }, + { name: 'shift-to-cover', target: { y: 570 }, options: { pinned: true, shiftToCoverTarget: true } }, + { name: 'shift-to-cover-limit', target: { y: 650 }, options: { pinned: true, shiftToCoverTarget: true } }, + { name: 'offset-zero', options: { offset: 0 } }, + { name: 'offset-positive', options: { offset: 13 } }, + { name: 'offset-negative', options: { position: 'above', offset: -11 } }, + { name: 'offset-object', options: { align: 'start', offset: { mainAxis: 9, crossAxis: 17 } } }, + { name: 'offset-object-rtl', dir: 'rtl', options: { align: 'start', offset: { mainAxis: 9, crossAxis: 17 } } }, + { name: 'offset-horizontal', options: { position: 'before', offset: { mainAxis: 9, crossAxis: -17 } } }, + { + name: 'offset-function', + options: { + position: 'above', + align: 'end', + offset: ({ targetRect, positionedRect, position, alignment }) => ({ + mainAxis: targetRect.height / 4 + (position === 'above' ? 3 : 0), + crossAxis: positionedRect.width / 10 + (alignment === 'end' ? 5 : 0), + }), + }, + }, + { name: 'offset-function-number', options: { offset: ({ targetRect }) => targetRect.width / 8 } }, + { name: 'offset-flipped', target: { y: 560 }, options: { offset: 12 } }, + { name: 'arrow-centered', arrow: true }, + { name: 'arrow-start', arrow: true, options: { align: 'start', offset: 10 } }, + { name: 'arrow-horizontal', arrow: true, options: { position: 'after', align: 'top' } }, + { name: 'arrow-padding', arrow: true, target: { width: 4 }, options: { align: 'start', arrowPadding: 20 } }, + { name: 'arrow-shift', arrow: true, target: { x: 780 }, options: { pinned: true } }, + { name: 'arrow-flip', arrow: true, target: { y: 560 } }, + { name: 'boundary-rect', boundary: 'rect', target: { x: 470, y: 370 } }, + { name: 'boundary-element', boundary: 'element', target: { x: 470, y: 370 } }, + { name: 'boundary-array', boundary: 'array', target: { x: 470, y: 370 } }, + { name: 'boundary-flip-only', boundary: 'rect', boundaryMode: 'flip', target: { x: 470, y: 370 } }, + { name: 'boundary-overflow-only', boundary: 'rect', boundaryMode: 'overflow', target: { x: 470, y: 370 } }, + { name: 'boundary-window', boundary: 'window', target: { x: 780, y: 560 } }, + { name: 'boundary-scroll-parent', boundary: 'scrollParent', host: 'scroll', target: { x: 350, y: 250 } }, + { name: 'boundary-clipping', boundary: 'clippingParents', host: 'scroll', target: { x: 350, y: 250 } }, + { name: 'padding-number', target: { x: 780 }, options: { overflowBoundaryPadding: 16 } }, + { + name: 'padding-logical-ltr', + target: { x: 780 }, + options: { overflowBoundaryPadding: { start: 11, end: 27, top: 13, bottom: 19 } }, + }, + { + name: 'padding-logical-rtl', + dir: 'rtl', + target: { x: 780 }, + options: { overflowBoundaryPadding: { start: 11, end: 27, top: 13, bottom: 19 } }, + }, + { name: 'padding-partial', target: { x: -40 }, options: { overflowBoundaryPadding: { start: 23 } } }, + { name: 'match-width', options: { matchTargetSize: 'width' } }, + { name: 'match-intrinsic-width', intrinsic: true, options: { matchTargetSize: 'width' } }, + { name: 'match-width-horizontal', options: { position: 'after', matchTargetSize: 'width' } }, + ...positions.map(position => ({ + name: `cover-${position}`, + options: { position, coverTarget: true }, + })), + ...([true, 'height', 'width', 'always', 'height-always', 'width-always', false] as const).map(autoSize => ({ + name: `auto-size-${autoSize}`, + floating: { width: 900, height: 700 }, + options: { autoSize, pinned: true, overflowBoundaryPadding: 12 }, + })), + { name: 'auto-size-boundary', boundary: 'rect', floating: { width: 600, height: 500 }, options: { autoSize: true } }, + { name: 'auto-size-match', options: { autoSize: true, matchTargetSize: 'width' } }, + { name: 'strategy-absolute', options: { strategy: 'absolute' } }, + { name: 'strategy-fixed', options: { strategy: 'fixed' } }, + { name: 'strategy-deprecated-fixed', options: { positionFixed: true } }, + { name: 'no-transform-absolute', options: { strategy: 'absolute', useTransform: false } }, + { name: 'no-transform-fixed', options: { strategy: 'fixed', useTransform: false } }, + { name: 'no-transform-fixed-rtl', dir: 'rtl', options: { strategy: 'fixed', useTransform: false, align: 'start' } }, + ...[1, 1.25, 1.5, 2, 3].flatMap(dpr => + [true, false].map(useTransform => ({ + name: `fractional-dpr-${dpr}-transform-${useTransform}`, + dpr, + target: { x: 300.25, y: 240.25, width: 80.5, height: 40.5 }, + floating: { width: 120.25, height: 60.25 }, + options: { useTransform }, + })), + ), + { name: 'nested-scroll', host: 'scroll' }, + { name: 'scaled-containing-block', host: 'scale' }, + { name: 'transformed-containing-block', host: 'transform' }, + { name: 'fixed-transformed-containing-block', host: 'transform', options: { strategy: 'fixed' } }, + { name: 'virtual-target', virtual: true }, + { name: 'virtual-zero-size', virtual: true, target: { width: 0, height: 0 } }, + { name: 'hidden-target', host: 'scroll', target: { x: 450, y: 500 } }, + { name: 'partially-clipped', host: 'scroll', target: { y: 280 }, options: { pinned: true } }, +); + +type Controls = { + positioning: PositioningImperativeRef | null; + setOptions: React.Dispatch>; + setVisible: React.Dispatch>; + rerender: () => void; + events: OnPositioningEndEventDetail[]; +}; + +const Fixture = ({ scenario, controls }: { scenario: Scenario; controls: Controls }) => { + const { targetDocument } = useFluent_unstable(); + const [options, setOptions] = React.useState(scenario.options ?? {}); + const [visible, setVisible] = React.useState(true); + const [endCount, setEndCount] = React.useState(0); + const [, forceRender] = React.useReducer(n => n + 1, 0); + const [boundary, setBoundary] = React.useState(null); + const target = { x: 300, y: 240, width: 80, height: 40, ...scenario.target }; + const floating = scenario.floating ?? { width: 120, height: 60 }; + const layoutBaseline = + scenario.dpr !== undefined && scenario.options?.useTransform === false + ? geometryBaseline[scenario.name] + : undefined; + const virtualTarget = React.useMemo( + () => ({ + getBoundingClientRect: () => ({ + ...target, + top: target.y, + left: target.x, + right: target.x + target.width, + bottom: target.y + target.height, + }), + contextElement: targetDocument?.body, + }), + // Geometry is fixed for the lifetime of a mounted scenario. + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + let resolvedBoundary: PositioningBoundary | undefined; + if (scenario.boundary === 'rect') { + resolvedBoundary = { x: 100, y: 100, width: 400, height: 300 }; + } else if (scenario.boundary === 'element' || scenario.boundary === 'array') { + const innerBoundary = boundary?.querySelector('[data-inner-boundary]'); + resolvedBoundary = boundary + ? scenario.boundary === 'array' && innerBoundary + ? [boundary, innerBoundary] + : boundary + : undefined; + } else { + resolvedBoundary = scenario.boundary; + } + const refs = usePositioning({ + position: 'below', + ...options, + ...(resolvedBoundary && scenario.boundaryMode !== 'overflow' && { flipBoundary: resolvedBoundary }), + ...(resolvedBoundary && scenario.boundaryMode !== 'flip' && { overflowBoundary: resolvedBoundary }), + ...(scenario.virtual && { target: virtualTarget }), + positioningRef: value => { + controls.positioning = value; + }, + onPositioningEnd: event => { + controls.events.push(event.detail); + // Bound a broken engine's feedback loop so the regression fails rather than hanging capture. + if (scenario.updateStateOnEnd && endCount < 32) { + setEndCount(endCount + 1); + } + options.onPositioningEnd?.(event); + }, + }); + controls.setOptions = setOptions; + controls.setVisible = setVisible; + controls.rerender = forceRender; + + return ( +
+ +
+
+
+
+
+ {layoutBaseline && ( +
+ )} +
+ {visible && ( +
+
+ {scenario.arrow && ( +
+ )} +
+ )} +
+
+
+ ); +}; + +function mountScenario(scenario: Scenario) { + const controls: Controls = { + positioning: null, + setOptions: () => undefined, + setVisible: () => undefined, + rerender: () => undefined, + events: [], + }; + cy.window().then(win => { + // Exercise the real DPR rounding branch without replacing any geometry APIs or engine code. + Object.defineProperty(win, 'devicePixelRatio', { configurable: true, value: scenario.dpr ?? 1 }); + }); + mount( + + + , + ); + cy.get('[data-floating]').should('have.attr', 'data-popper-placement'); + if (scenario.host === 'scroll') { + cy.get('[data-host]').should($host => { + expect($host[0].clientWidth, 'scrollbar-free fixture width').to.equal(400); + expect($host[0].scrollHeight, 'fixture remains scrollable').to.be.greaterThan($host[0].clientHeight); + }); + } + cy.window().then( + win => + new Cypress.Promise(resolve => { + win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve())); + }), + ); + return controls; +} + +function geometry(element: HTMLElement) { + const rect = element.getBoundingClientRect(); + const arrow = element.querySelector('[data-arrow]'); + return [ + rect.x, + rect.y, + rect.width, + rect.height, + element.getAttribute('data-popper-placement'), + element.style.position, + element.style.transform, + element.style.left, + element.style.top, + element.style.maxWidth, + element.style.maxHeight, + arrow?.style.left ?? '', + arrow?.style.top ?? '', + ['data-popper-escaped', 'data-popper-reference-hidden', 'data-popper-is-intersecting'] + .filter(attribute => element.hasAttribute(attribute)) + .join(','), + element.style.getPropertyValue('--fui-match-target-size'), + element.style.overflowX, + element.style.overflowY, + ]; +} + +function expectGeometry(element: HTMLElement, scenario: Scenario) { + const expected = [...geometryBaseline[scenario.name]]; + if (scenario.dpr !== undefined && scenario.options?.useTransform === false) { + const reference = element.parentElement?.querySelector('[data-layout-reference]'); + if (!reference) { + throw new Error('Fractional left/top geometry requires an engine-free layout reference'); + } + // Native layout quantization varies by host; the reference uses only committed CSS literals. + const rect = reference.getBoundingClientRect(); + expected[0] = rect.x; + expected[1] = rect.y; + } + expect(geometry(element)).to.deep.equal(expected); +} + +describe('usePositioning browser geometry contracts', () => { + let originalDpr: PropertyDescriptor | undefined; + beforeEach(() => { + cy.viewport(800, 600); + cy.window().then(win => { + originalDpr = Object.getOwnPropertyDescriptor(win, 'devicePixelRatio'); + }); + }); + afterEach(() => { + cy.window().then(win => { + if (originalDpr) { + Object.defineProperty(win, 'devicePixelRatio', originalDpr); + } else { + Reflect.deleteProperty(win, 'devicePixelRatio'); + } + }); + }); + + it('has exactly one committed baseline for every unique scenario', () => { + expect(new Set(scenarios.map(scenario => scenario.name)).size).to.equal(scenarios.length); + expect(Object.keys(geometryBaseline).sort()).to.deep.equal(scenarios.map(scenario => scenario.name).sort()); + }); + scenarios.forEach(scenario => { + it(scenario.name, () => { + const controls = mountScenario(scenario); + cy.get('[data-floating]').should($floating => { + expectGeometry($floating[0], scenario); + expect(controls.events[controls.events.length - 1]).to.deep.equal({ + placement: $floating.attr('data-popper-placement'), + escaped: $floating.is('[data-popper-escaped]'), + referenceHidden: $floating.is('[data-popper-reference-hidden]'), + }); + }); + }); + }); + + scenarios + .filter(scenario => [1.5, 3].includes(scenario.dpr ?? 0) && scenario.options?.useTransform === false) + .forEach(scenario => { + it(`${scenario.name} rejects style drift even within one native layout step`, () => { + mountScenario(scenario); + cy.get('[data-floating]').then($floating => { + const element = $floating[0]; + expectGeometry(element, scenario); + const rect = geometry(element).slice(0, 4); + for (const property of ['left', 'top'] as const) { + const original = element.style[property]; + element.style[property] = `${parseFloat(original) - 0.001}px`; + expect(geometry(element).slice(0, 4), 'sub-layout-step drift has the same rectangle').to.deep.equal(rect); + expect(() => expectGeometry(element, scenario), `reject changed ${property}`).to.throw(); + element.style[property] = original; + } + element.style.marginLeft = '1px'; + expect( + () => expectGeometry(element, scenario), + 'reject a displaced rectangle with unchanged left/top', + ).to.throw(); + element.style.marginLeft = ''; + expectGeometry(element, scenario); + }); + }); + }); + + function expectRect(x: number, y: number, width = 120, height = 60) { + cy.get('[data-floating]').should($floating => { + const rect = $floating[0].getBoundingClientRect(); + expect([rect.x, rect.y, rect.width, rect.height]).to.deep.equal([x, y, width, height]); + }); + } + + function expectIdle(controls: Controls) { + cy.then(() => { + const count = controls.events.length; + // A finite quiet interval also catches asynchronous resize/position feedback loops. + cy.wait(200, { log: false }).then(() => + expect(controls.events.length, 'idle positioning updates').to.equal(count), + ); + }); + } + + it('an inline end callback that updates state settles instead of recreating the manager', () => { + const controls = mountScenario({ name: 'stateful-end', updateStateOnEnd: true }); + expectRect(280, 280); + cy.wait(200, { log: false }).then(() => { + expect(controls.events.length, 'bounded initial positioning updates').to.be.within(1, 3); + }); + expectIdle(controls); + cy.then(() => controls.rerender()); + expectIdle(controls); + }); + + it('callback-only and imperative-ref-only rerenders do not initiate positioning', () => { + const controls = mountScenario({ name: 'callback-rerender' }); + expectIdle(controls); + cy.then(() => { + const count = controls.events.length; + controls.rerender(); + cy.wait(200, { log: false }).then(() => expect(controls.events.length).to.equal(count)); + }); + }); + + it('removing options restores default offset and placement', () => { + const controls = mountScenario({ name: 'remove-options', options: { position: 'above', offset: 19 } }); + expectRect(280, 161); + cy.then(() => controls.setOptions({})); + expectRect(280, 280); + cy.get('[data-floating]').should('have.attr', 'data-popper-placement', 'bottom'); + }); + + it('imperatively updates a moved target and retargets to virtual and original references', () => { + const controls = mountScenario({ name: 'imperative' }); + cy.get('[data-target]').invoke('css', { left: '400px', top: '200px' }); + cy.then(() => controls.positioning?.updatePosition()); + expectRect(380, 240); + cy.then(() => + controls.positioning?.setTarget({ + getBoundingClientRect: () => ({ + x: 100, + y: 120, + left: 100, + top: 120, + right: 120, + bottom: 130, + width: 20, + height: 10, + }), + }), + ); + expectRect(50, 130); + cy.then(() => controls.positioning?.setTarget(null)); + expectRect(380, 240); + }); + + it('supports the target prop and restores the DOM target when it is removed', () => { + const controls = mountScenario({ name: 'target-prop' }); + cy.then(() => + controls.setOptions({ + target: { + getBoundingClientRect: () => ({ + x: 100, + y: 120, + left: 100, + top: 120, + right: 120, + bottom: 130, + width: 20, + height: 10, + }), + }, + }), + ); + expectRect(50, 130); + cy.then(() => controls.setOptions({})); + expectRect(280, 280); + }); + + it('disables automatic updates and re-enables with current target geometry', () => { + const controls = mountScenario({ name: 'enabled' }); + expectIdle(controls); + let count = 0; + cy.then(() => { + count = controls.events.length; + controls.setOptions({ enabled: false }); + }); + cy.get('[data-enabled="false"]').should('exist'); + cy.get('[data-target]').invoke('css', { left: '400px', width: '100px' }); + expectIdle(controls); + cy.then(() => expect(controls.events.length).to.equal(count)); + expectRect(280, 280); + cy.then(() => controls.setOptions({ enabled: true })); + expectRect(390, 280); + }); + + it('tracks target and floating element resize without polling while idle', () => { + const controls = mountScenario({ name: 'resize' }); + cy.get('[data-target]').invoke('css', { width: '160px', height: '70px' }); + expectRect(320, 310); + cy.get('[data-floating]').invoke('css', { width: '100px', height: '50px' }); + expectRect(330, 310, 100, 50); + expectIdle(controls); + }); + + it('disableUpdateOnResize preserves coordinates until explicitly updated', () => { + const controls = mountScenario({ name: 'resize-disabled', options: { disableUpdateOnResize: true } }); + cy.get('[data-target]').invoke('css', { width: '160px', height: '70px' }); + expectIdle(controls); + expectRect(280, 280); + cy.then(() => controls.positioning?.updatePosition()); + expectRect(320, 310); + }); + + it('does not reposition for zero-sized resize observations while the target is display:none', () => { + const controls = mountScenario({ name: 'display-none' }); + expectIdle(controls); + let count = 0; + cy.then(() => { + count = controls.events.length; + }); + cy.get('[data-target]').invoke('css', 'display', 'none'); + expectIdle(controls); + cy.then(() => expect(controls.events.length).to.equal(count)); + expectRect(280, 280); + cy.get('[data-target]').invoke('css', { display: 'block', width: '160px', height: '70px' }); + expectRect(320, 310); + }); + + it('autoSize releases stale maximum sizes when the viewport grows', () => { + const controls = mountScenario({ + name: 'auto-size-grow', + floating: { width: 900, height: 700 }, + options: { autoSize: true, pinned: true, overflowBoundaryPadding: 12 }, + }); + expectRect(12, 280, 776, 308); + cy.viewport(1000, 1000); + expectRect(12, 280, 900, 700); + cy.get('[data-floating]').should($floating => { + expect([$floating[0].style.maxWidth, $floating[0].style.maxHeight]).to.deep.equal(['976px', '708px']); + }); + expectIdle(controls); + }); + + it('updates on nested scrolling and window resize', () => { + const controls = mountScenario({ name: 'scroll-update', host: 'scroll' }); + expectRect(320, 210); + cy.get('[data-host]').scrollTo(0, 100); + expectRect(320, 210); + cy.get('[data-floating]').should('have.attr', 'data-popper-placement', 'bottom'); + cy.get('[data-target]').invoke('css', 'left', '200px'); + cy.window().trigger('resize'); + expectRect(220, 210); + expectIdle(controls); + }); + + it('removes visibility flags when a clipped target returns into view', () => { + const controls = mountScenario({ name: 'visibility', host: 'scroll', target: { x: 450, y: 500 } }); + cy.get('[data-floating]').should('have.attr', 'data-popper-reference-hidden'); + cy.get('[data-floating]').should('have.attr', 'data-popper-escaped'); + cy.get('[data-target]').invoke('css', { left: '200px', top: '100px' }); + cy.then(() => controls.positioning?.updatePosition()); + expectRect(220, 170); + cy.get('[data-floating]').should('not.have.attr', 'data-popper-reference-hidden'); + cy.get('[data-floating]').should('not.have.attr', 'data-popper-escaped'); + expectIdle(controls); + }); + + it('dispatches a native positioning-end event with the final post-flip placement', () => { + const controls = mountScenario({ name: 'native-event', target: { y: 560 } }); + const details: OnPositioningEndEventDetail[] = []; + cy.get('[data-floating]').then($floating => { + $floating[0].addEventListener('fui-positioningend', event => { + details.push((event as CustomEvent).detail); + }); + }); + cy.then(() => controls.positioning?.updatePosition()); + cy.wrap(details).should('deep.equal', [{ placement: 'top', escaped: false, referenceHidden: false }]); + }); + + it('cleans up detached containers and remounts without stale updates', () => { + const controls = mountScenario({ name: 'cleanup' }); + expectIdle(controls); + let count = 0; + let detachedEvents = 0; + let detached: HTMLElement; + cy.get('[data-floating]').then($floating => { + detached = $floating[0]; + detached.addEventListener('fui-positioningend', () => detachedEvents++); + }); + cy.then(() => { + count = controls.events.length; + controls.setVisible(false); + }); + cy.get('[data-floating]').should('not.exist'); + cy.then(() => controls.positioning?.updatePosition()); + cy.get('[data-target]').invoke('css', { width: '160px', height: '70px' }); + cy.window().trigger('resize'); + cy.window().trigger('scroll'); + expectIdle(controls); + cy.then(() => { + expect(controls.events.length).to.equal(count); + expect(detachedEvents, 'events dispatched to detached container').to.equal(0); + expect(detached.style.transform).to.equal('translate(280px, 280px)'); + }); + cy.then(() => controls.setVisible(true)); + expectRect(320, 310); + expectIdle(controls); + }); +});