diff --git a/change/@fluentui-react-charts-1cc101ec-348b-4d7f-9b9f-687b8f694ade.json b/change/@fluentui-react-charts-1cc101ec-348b-4d7f-9b9f-687b8f694ade.json new file mode 100644 index 0000000000000..282a25cf2325e --- /dev/null +++ b/change/@fluentui-react-charts-1cc101ec-348b-4d7f-9b9f-687b8f694ade.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "Avoid creating a virtual popover target when no click position is provided.", + "packageName": "@fluentui/react-charts", + "email": "dmytrokirpa@microsoft.com" +} diff --git a/change/@fluentui-react-headless-components-preview-8a41d7c3-2b5f-4e68-a0d9-6c3f1e9b7a24.json b/change/@fluentui-react-headless-components-preview-8a41d7c3-2b5f-4e68-a0d9-6c3f1e9b7a24.json new file mode 100644 index 0000000000000..6d32871aecb02 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-8a41d7c3-2b5f-4e68-a0d9-6c3f1e9b7a24.json @@ -0,0 +1,6 @@ +{ + "type": "minor", + "comment": "feat(usePositioning): use CSS anchor positioning by default, and the engine of @fluentui/react-positioning when the browser doesn't support it or an option can't be handled by CSS", + "packageName": "@fluentui/react-headless-components-preview", + "email": "kirpadv@gmail.com" +} diff --git a/change/@fluentui-react-positioning-39add3c6-6961-4f67-8af0-e52dc9fb99d2.json b/change/@fluentui-react-positioning-39add3c6-6961-4f67-8af0-e52dc9fb99d2.json new file mode 100644 index 0000000000000..0ea856321c958 --- /dev/null +++ b/change/@fluentui-react-positioning-39add3c6-6961-4f67-8af0-e52dc9fb99d2.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "Preserve resolved positioning targets, pinned offsets, oversized popup placement, and fixed-position clipping.", + "packageName": "@fluentui/react-positioning", + "email": "dmytrokirpa@microsoft.com" +} diff --git a/change/@fluentui-react-positioning-3f6c1a52-7d0e-4b9a-9c1e-5a2b8d4e6f10.json b/change/@fluentui-react-positioning-3f6c1a52-7d0e-4b9a-9c1e-5a2b8d4e6f10.json new file mode 100644 index 0000000000000..319630c1efade --- /dev/null +++ b/change/@fluentui-react-positioning-3f6c1a52-7d0e-4b9a-9c1e-5a2b8d4e6f10.json @@ -0,0 +1,6 @@ +{ + "type": "minor", + "comment": "feat: compute positions with a built-in engine that follows the rules of CSS anchor positioning, replacing Floating UI to reduce the bundle size", + "packageName": "@fluentui/react-positioning", + "email": "kirpadv@gmail.com" +} 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 0000000000000..da166524a01be --- /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/change/@fluentui-react-positioning-c26c97e4-ca8e-4b26-9ea7-6d72ff5214d4.json b/change/@fluentui-react-positioning-c26c97e4-ca8e-4b26-9ea7-6d72ff5214d4.json new file mode 100644 index 0000000000000..b82d6c9de8540 --- /dev/null +++ b/change/@fluentui-react-positioning-c26c97e4-ca8e-4b26-9ea7-6d72ff5214d4.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "Replace the positioning runtime with a standalone parity engine and enforce the 15,000-byte minified budget.", + "packageName": "@fluentui/react-positioning", + "email": "dmytrokirpa@microsoft.com" +} diff --git a/packages/charts/react-charts/library/src/components/CommonComponents/ChartPopover.tsx b/packages/charts/react-charts/library/src/components/CommonComponents/ChartPopover.tsx index f45db303a7b91..b04d5f35d8f02 100644 --- a/packages/charts/react-charts/library/src/components/CommonComponents/ChartPopover.tsx +++ b/packages/charts/react-charts/library/src/components/CommonComponents/ChartPopover.tsx @@ -20,18 +20,21 @@ export const ChartPopover: React.FunctionComponent = React.fo HTMLDivElement, ChartPopoverProps >((props, forwardedRef) => { - const virtualElement: PositioningVirtualElement = { - getBoundingClientRect: () => ({ - top: props.clickPosition!.y, - left: props.clickPosition!.x, - right: props.clickPosition!.x, - bottom: props.clickPosition!.y, - x: props.clickPosition!.x, - y: props.clickPosition!.y, - width: 0, - height: 0, - }), - }; + const { clickPosition } = props; + const virtualElement: PositioningVirtualElement | undefined = clickPosition + ? { + getBoundingClientRect: () => ({ + top: clickPosition.y, + left: clickPosition.x, + right: clickPosition.x, + bottom: clickPosition.y, + x: clickPosition.x, + y: clickPosition.y, + width: 0, + height: 0, + }), + } + : undefined; const target = typeof props.positioning === 'object' && 'target' in props.positioning ? props.positioning.target !== null diff --git a/packages/charts/react-charts/library/src/components/GanttChart/__snapshots__/GanttChart.test.tsx.snap b/packages/charts/react-charts/library/src/components/GanttChart/__snapshots__/GanttChart.test.tsx.snap index 92f02c27fc2ef..ac84947d65031 100644 --- a/packages/charts/react-charts/library/src/components/GanttChart/__snapshots__/GanttChart.test.tsx.snap +++ b/packages/charts/react-charts/library/src/components/GanttChart/__snapshots__/GanttChart.test.tsx.snap @@ -443,7 +443,7 @@ exports[`GanttChart interaction and accessibility tests should render custom cal data-popper-placement="top" data-tabster="{\\"restorer\\":{\\"type\\":0}}" role="group" - style="position: absolute; left: 0px; top: 0px; margin: 0px; box-sizing: border-box; max-width: 0px; max-height: -20px; overflow-y: auto; transform: translate(-10px, -40px); --fui-positioning-slide-direction-x: 0px; --fui-positioning-slide-direction-y: 1px;" + style="position: absolute; left: 0px; top: 0px; margin: 0px; box-sizing: border-box; max-width: 0px; max-height: 0px; transform: translate(0px, -20px); --fui-positioning-slide-direction-x: 0px; --fui-positioning-slide-direction-y: 1px;" >
; +export { PositioningProps } + +export { PositioningRect } // @public (undocumented) export type PositioningReturn = { targetRef: React_2.RefCallback; containerRef: React_2.RefCallback; + arrowRef: React_2.RefCallback; }; -// @public (undocumented) -export type PositioningShorthand = PositioningProps | PositioningShorthandValue; +export { PositioningShorthand } export { PositioningShorthandValue } +export { PositioningVirtualElement } + // @public (undocumented) export const POSITIONS: { readonly above: "above"; @@ -52,8 +80,15 @@ export const POSITIONS: { // @public (undocumented) export const resolvePositioningShorthand: ResolvePositioningShorthand; -// @public (undocumented) -export function usePositioning(options: PositioningProps): PositioningReturn; +export { SetVirtualMouseTarget } + +// @public +export function usePositioning(options: UsePositioningOptions): PositioningReturn; + +// @public +export type UsePositioningOptions = PositioningProps & { + enabled?: boolean; +}; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md b/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md index 55d35401ca159..ab755eaa060ec 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md @@ -11,8 +11,7 @@ import type { JSXElement } from '@fluentui/react-utilities'; import { ListboxProps } from '@fluentui/react-combobox'; import { OptionSlots } from '@fluentui/react-combobox'; import type { OptionState as OptionState_2 } from '@fluentui/react-combobox'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; -import type { PositioningShorthandValue } from '@fluentui/react-positioning'; +import { PositioningShorthand } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; import { renderTagPickerButton_unstable as renderTagPickerButton } from '@fluentui/react-tag-picker'; import { renderTagPickerControl_unstable as renderTagPickerControl } from '@fluentui/react-tag-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md b/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md index 36364e77a9828..bafa77fc36e5e 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/teaching-popover.api.md @@ -13,8 +13,7 @@ import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import type { PopoverContextValue as PopoverContextValue_2 } from '@fluentui/react-popover'; import type { PopoverTriggerChildProps } from '@fluentui/react-popover'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; -import type { PositioningShorthandValue } from '@fluentui/react-positioning'; +import { PositioningShorthand } from '@fluentui/react-positioning'; import * as React_2 from 'react'; import { renderTeachingPopoverBody_unstable as renderTeachingPopoverBody } from '@fluentui/react-teaching-popover'; import { renderTeachingPopoverCarouselCard_unstable as renderTeachingPopoverCarouselCard } from '@fluentui/react-teaching-popover'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/tooltip.api.md b/packages/react-components/react-headless-components-preview/library/etc/tooltip.api.md index 3df83006766ff..eaf962c7448ae 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/tooltip.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/tooltip.api.md @@ -6,8 +6,7 @@ import type { JSXElement } from '@fluentui/react-utilities'; import { OnVisibleChangeData } from '@fluentui/react-tooltip'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; -import type { PositioningShorthandValue } from '@fluentui/react-positioning'; +import { PositioningShorthand } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; import type { TooltipBaseProps } from '@fluentui/react-tooltip'; import type { TooltipBaseState } from '@fluentui/react-tooltip'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.cy.tsx index 5d39d5b0ad4b4..51fd875369e20 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.cy.tsx @@ -281,26 +281,31 @@ describe('Dialog', () => { }); it('should not close when clicking inside a portaled Popover descendant', () => { - mount( - - - - - - - Dialog title - - - - - - - - - - - , - ); + const Example = () => { + const [mountNode, setMountNode] = React.useState(null); + return ( + + + + + + + Dialog title + {/* Legacy portaled surfaces must stay inside the native modal's non-inert subtree. */} + + + + + + + + + + + + ); + }; + mount(); cy.get(dialogTriggerOpenSelector).realClick(); cy.get('#popover-trigger-btn').realClick(); cy.get('#popover-inner-btn').should('be.visible').realClick(); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.test.tsx index 12cff439f018b..017eed043c553 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.test.tsx @@ -35,6 +35,15 @@ const renderTagPicker = (props: Partial { + Object.defineProperty(globalThis, 'CSS', { value: { supports: () => true }, configurable: true, writable: true }); +}); + +afterAll(() => { + Reflect.deleteProperty(globalThis, 'CSS'); +}); + describe('TagPicker', () => { it('renders the input trigger and the options list when open', () => { const { getByRole, getAllByRole } = renderTagPicker(); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts index 14bbafd814b9b..16758bc534376 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts @@ -1,11 +1,22 @@ export { usePositioning } from './usePositioning'; export type { - Position, Alignment, + AutoSize, + Offset, + OffsetFunction, + OffsetFunctionParam, + OffsetObject, + OffsetShorthand, + Position, + PositioningBoundary, + PositioningImperativeRef, PositioningProps, + PositioningRect, PositioningReturn, - PositioningImperativeRef, PositioningShorthand, PositioningShorthandValue, + PositioningVirtualElement, + SetVirtualMouseTarget, + UsePositioningOptions, } from './usePositioning'; export { POSITIONS, ALIGNMENTS, getPlacementString, resolvePositioningShorthand } from './usePositioning'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/constants.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/constants.ts index 9a8424d4457a8..201d112e8359f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/constants.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/constants.ts @@ -1,6 +1,3 @@ -import type { Position } from '@fluentui/react-positioning'; -import type { LogicalAlignment } from './types'; - export const GAP = 8; export const POSITIONS = { @@ -15,26 +12,3 @@ export const ALIGNMENTS = { center: 'center', end: 'end', } as const; - -export const POSITION_AREA_MAP: Record> = { - above: { - center: 'block-start', - start: 'block-start span-inline-end', - end: 'block-start span-inline-start', - }, - below: { - center: 'block-end', - start: 'block-end span-inline-end', - end: 'block-end span-inline-start', - }, - before: { - center: 'inline-start', - start: 'inline-start span-block-end', - end: 'inline-start span-block-start', - }, - after: { - center: 'inline-end', - start: 'inline-end span-block-end', - end: 'inline-end span-block-start', - }, -}; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts index fbc862d4f13a4..90d78f1330233 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts @@ -2,11 +2,23 @@ export { resolvePositioningShorthand } from './resolvePositioningShorthand'; export { usePositioning } from './usePositioning'; export { getPlacementString } from './utils'; export { POSITIONS, ALIGNMENTS } from './constants'; -export type { PositioningProps, PositioningShorthand, PositioningReturn } from './types'; - export type { Alignment, + AutoSize, + Offset, + OffsetFunction, + OffsetFunctionParam, + OffsetObject, + OffsetShorthand, Position, + PositioningBoundary, PositioningImperativeRef, + PositioningProps, + PositioningRect, + PositioningReturn, + PositioningShorthand, PositioningShorthandValue, -} from '@fluentui/react-positioning'; + PositioningVirtualElement, + SetVirtualMouseTarget, + UsePositioningOptions, +} from './types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.test.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.test.ts new file mode 100644 index 0000000000000..3afc6f91be2cb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.test.ts @@ -0,0 +1,24 @@ +import type * as Canonical from '@fluentui/react-positioning'; +import type * as Headless from './types'; + +/** + * The public types are the ones of `@fluentui/react-positioning`, this fails to compile when they diverge. + */ +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +const assertEqual = (_equal: Equal) => undefined; + +describe('positioning types', () => { + it('are the types of @fluentui/react-positioning', () => { + assertEqual(true); + assertEqual(true); + assertEqual(true); + assertEqual(true); + assertEqual(true); + assertEqual(true); + assertEqual(true); + assertEqual(true); + assertEqual(true); + assertEqual(true); + assertEqual(true); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts index 3f92a6d60b922..6da479a19e77c 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts @@ -1,7 +1,23 @@ import type * as React from 'react'; -import type { - PositioningProps as CanonicalPositioningProps, +import type { PositioningProps, PositioningVirtualElement } from '@fluentui/react-positioning'; + +export type { + Alignment, + AutoSize, + Offset, + OffsetFunction, + OffsetFunctionParam, + OffsetObject, + OffsetShorthand, + Position, + PositioningBoundary, + PositioningImperativeRef, + PositioningProps, + PositioningRect, + PositioningShorthand, PositioningShorthandValue, + PositioningVirtualElement, + SetVirtualMouseTarget, } from '@fluentui/react-positioning'; export type LogicalAlignment = 'start' | 'center' | 'end'; @@ -9,20 +25,18 @@ export type LogicalAlignment = 'start' | 'center' | 'end'; export type PositioningReturn = { targetRef: React.RefCallback; containerRef: React.RefCallback; + arrowRef: React.RefCallback; }; -export type PositioningProps = Pick< - CanonicalPositioningProps, - | 'align' - | 'coverTarget' - | 'fallbackPositions' - | 'matchTargetSize' - | 'offset' - | 'pinned' - | 'position' - | 'positioningRef' - | 'strategy' - | 'target' ->; +/** + * Options of the hook: the public `PositioningProps` of `@fluentui/react-positioning` plus `enabled`. + */ +export type UsePositioningOptions = PositioningProps & { + /** + * Disables positioning. + * @default true + */ + enabled?: boolean; +}; -export type PositioningShorthand = PositioningProps | PositioningShorthandValue; +export type PositioningTarget = HTMLElement | PositioningVirtualElement; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementObserver.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementObserver.ts deleted file mode 100644 index c58fed88c5056..0000000000000 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementObserver.ts +++ /dev/null @@ -1,73 +0,0 @@ -'use client'; - -import { useIsomorphicLayoutEffect, useEventCallback } from '@fluentui/react-utilities'; -import { computePosition, debounce } from './utils'; - -/** - * Mirrors the placement that the browser actually resolves for an - * anchored element into its `data-placement` attribute. Useful when CSS - * `position-try-fallbacks` flips the surface after a layout shift, scroll, - * or content reflow — consumers can style the surface (arrows, animations) - * via `[data-placement^="above"]` and friends and stay in sync. - * - */ -export function usePlacementObserver( - containerEl: HTMLElement | null, - targetEl: HTMLElement | null, - targetDocument: Document | undefined, - disabled = false, -): () => void { - const update = useEventCallback(() => { - if (!containerEl || !targetEl) { - return; - } - - const result = computePosition(targetEl, containerEl); - if (!result) { - return; - } - - if (containerEl.getAttribute('data-placement') !== result.placement) { - containerEl.setAttribute('data-placement', result.placement); - } - }); - - useIsomorphicLayoutEffect(() => { - if (disabled || !containerEl || !targetEl) { - return; - } - - const win = targetDocument?.defaultView; - if (!win) { - return; - } - - const debouncedUpdate = debounce(update); - - const ResizeObserverCtor = win.ResizeObserver; - const resizeObserver = ResizeObserverCtor - ? new ResizeObserverCtor(entries => { - const allLaidOut = entries.every(entry => entry.contentRect.width > 0 && entry.contentRect.height > 0); - if (allLaidOut) { - debouncedUpdate(); - } - }) - : null; - - resizeObserver?.observe(containerEl); - resizeObserver?.observe(targetEl); - - win.addEventListener('scroll', debouncedUpdate, { capture: true, passive: true }); - win.addEventListener('resize', debouncedUpdate); - - debouncedUpdate(); - - return () => { - resizeObserver?.disconnect(); - win.removeEventListener('scroll', debouncedUpdate, { capture: true }); - win.removeEventListener('resize', debouncedUpdate); - }; - }, [containerEl, targetEl, targetDocument, disabled, update]); - - return update; -} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx index cadfd8afc62d6..892d322674cd9 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx @@ -2,9 +2,18 @@ import * as React from 'react'; import { act, render } from '@testing-library/react'; import { usePositioning } from './usePositioning'; import { getPlacementString } from './utils/placement'; -import type { PositioningProps, PositioningReturn } from './types'; +import type { PositioningReturn, UsePositioningOptions } from './types'; -function mountHook(options: PositioningProps = {}) { +// jsdom doesn't implement `CSS.supports()`, these tests cover the CSS anchor positioning path +beforeAll(() => { + Object.defineProperty(globalThis, 'CSS', { value: { supports: () => true }, configurable: true, writable: true }); +}); + +afterAll(() => { + Reflect.deleteProperty(globalThis, 'CSS'); +}); + +function mountHook(options: UsePositioningOptions = {}) { const resultRef = React.createRef<{ current: PositioningReturn }>(); const Capture = () => { const result = usePositioning(options); @@ -223,7 +232,7 @@ describe('usePositioning', () => { setTarget: (el: HTMLElement | null) => void; }>(); mountHook({ - positioningRef: positioningRef as unknown as PositioningProps['positioningRef'], + positioningRef: positioningRef as unknown as UsePositioningOptions['positioningRef'], }); expect(positioningRef.current).not.toBeNull(); @@ -232,6 +241,294 @@ describe('usePositioning', () => { }); }); +const flushMicrotasks = async () => { + for (let i = 0; i < 5; i++) { + await new Promise(process.nextTick); + } +}; + +const mockLayout = ( + target: { getBoundingClientRect: () => unknown }, + container: HTMLElement, + anchor = { left: 450, top: 300, width: 100, height: 40 }, + size = { width: 200, height: 100 }, +) => { + target.getBoundingClientRect = () => ({ + ...anchor, + x: anchor.left, + y: anchor.top, + right: anchor.left + anchor.width, + bottom: anchor.top + anchor.height, + }); + Object.defineProperty(container, 'offsetWidth', { value: size.width, configurable: true }); + Object.defineProperty(container, 'offsetHeight', { value: size.height, configurable: true }); + jest.spyOn(container, 'getBoundingClientRect').mockImplementation(() => { + const [, x = 0, y = 0] = /translate(?:3d)?\((-?[\d.]+)px, (-?[\d.]+)px/.exec(container.style.transform) ?? []; + const left = Number(x); + const top = Number(y); + return { left, top, x: left, y: top, ...size, right: left + size.width, bottom: top + size.height } as DOMRect; + }); + jest.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1000); + jest.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(700); +}; + +describe('usePositioning with JavaScript positioning', () => { + afterEach(() => { + jest.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + const mountElements = (_result: { current: PositioningReturn }) => { + const target = document.createElement('div'); + const container = document.createElement('div'); + document.body.append(target, container); + + return { target, container }; + }; + + describe('without CSS anchor positioning', () => { + beforeAll(() => { + // `anchor-name` and `position-area` are not supported + Object.defineProperty(globalThis, 'CSS', { + value: { supports: () => false }, + configurable: true, + writable: true, + }); + }); + + afterAll(() => { + Object.defineProperty(globalThis, 'CSS', { value: { supports: () => true }, configurable: true, writable: true }); + }); + + it('does not write anchor positioning styles', async () => { + const result = mountHook({ position: 'below', align: 'start' }); + const { target, container } = mountElements(result); + mockLayout(target, container); + + act(() => { + result.current.targetRef(target); + result.current.containerRef(container); + }); + await act(flushMicrotasks); + + expect(target.style.getPropertyValue('anchor-name')).toBe(''); + expect(container.style.getPropertyValue('position-anchor')).toBe(''); + expect(container.style.getPropertyValue('position-area')).toBe(''); + expect(container.style.getPropertyValue('position-try-fallbacks')).toBe(''); + expect(container).toHaveStyle({ position: 'fixed', margin: '0px' }); + }); + + it('positions the container next to the target', async () => { + const result = mountHook({ position: 'below', align: 'start', offset: 8 }); + const { target, container } = mountElements(result); + mockLayout(target, container); + + act(() => { + result.current.targetRef(target); + result.current.containerRef(container); + }); + await act(flushMicrotasks); + + expect(container.style.transform).toBe('translate(450px, 348px)'); + expect(container).toHaveAttribute('data-placement', 'below-start'); + }); + + it('flips when there is no room and mirrors the computed placement in data-placement', async () => { + const result = mountHook({ position: 'above', align: 'center' }); + const { target, container } = mountElements(result); + mockLayout(target, container, { left: 450, top: 60, width: 100, height: 40 }); + + act(() => { + result.current.targetRef(target); + result.current.containerRef(container); + }); + await act(flushMicrotasks); + + expect(container.style.transform).toBe('translate(400px, 100px)'); + expect(container).toHaveAttribute('data-placement', 'below'); + }); + + it('matches the width of the target', async () => { + const result = mountHook({ position: 'below', align: 'start', matchTargetSize: 'width' }); + const { target, container } = mountElements(result); + mockLayout(target, container, { left: 450, top: 300, width: 120, height: 40 }); + + act(() => { + result.current.targetRef(target); + result.current.containerRef(container); + }); + await act(flushMicrotasks); + + expect(container.style.getPropertyValue('--fui-match-target-size')).toBe('120px'); + }); + + it('updates the position with updatePosition()', async () => { + const positioningRef = React.createRef<{ updatePosition: () => void }>(); + const result = mountHook({ + position: 'below', + align: 'start', + positioningRef: positioningRef as unknown as UsePositioningOptions['positioningRef'], + }); + const { target, container } = mountElements(result); + mockLayout(target, container); + + act(() => { + result.current.targetRef(target); + result.current.containerRef(container); + }); + await act(flushMicrotasks); + expect(container.style.transform).toBe('translate(450px, 340px)'); + + mockLayout(target, container, { left: 450, top: 200, width: 100, height: 40 }); + act(() => { + positioningRef.current?.updatePosition(); + }); + await act(flushMicrotasks); + + expect(container.style.transform).toBe('translate(450px, 240px)'); + }); + + it('does not position when it is disabled', async () => { + const result = mountHook({ position: 'below', enabled: false }); + const { target, container } = mountElements(result); + mockLayout(target, container); + + act(() => { + result.current.targetRef(target); + result.current.containerRef(container); + }); + await act(flushMicrotasks); + + expect(container.style.getPropertyValue('position-area')).toBe(''); + expect(container.style.getPropertyValue('position')).toBe(''); + expect(container.style.transform).toBe(''); + }); + }); + + describe('with CSS anchor positioning', () => { + it('keeps using CSS for options that CSS can handle', async () => { + const result = mountHook({ position: 'below', offset: 4, fallbackPositions: ['above'] }); + const { target, container } = mountElements(result); + mockLayout(target, container); + + act(() => { + result.current.targetRef(target); + result.current.containerRef(container); + }); + await act(flushMicrotasks); + + expect(container).toHaveStyle({ positionArea: 'block-end' }); + expect(container.style.transform).toBe(''); + }); + + it.each([ + ['a function offset', { offset: () => 4 }], + ['a flip boundary', { flipBoundary: 'window' as const }], + ['an overflow boundary', { overflowBoundary: 'window' as const }], + ['an overflow padding', { overflowBoundaryPadding: 8 }], + ])('positions with JavaScript using %s', async (_name, extra) => { + const result = mountHook({ position: 'below', align: 'start', ...extra }); + const { target, container } = mountElements(result); + mockLayout(target, container); + + act(() => { + result.current.targetRef(target); + result.current.containerRef(container); + }); + await act(flushMicrotasks); + + expect(container.style.getPropertyValue('position-area')).toBe(''); + expect(target.style.getPropertyValue('anchor-name')).toBe(''); + expect(container.style.transform).toMatch(/^translate/); + expect(container).toHaveAttribute('data-placement', 'below-start'); + }); + + it('positions next to a virtual element', async () => { + const virtualElement = { + getBoundingClientRect: () => + ({ left: 300, top: 200, width: 0, height: 0, x: 300, y: 200, right: 300, bottom: 200 } as DOMRect), + }; + const result = mountHook({ position: 'below', align: 'start', target: virtualElement }); + const { container } = mountElements(result); + mockLayout({ getBoundingClientRect: () => undefined }, container); + + act(() => { + result.current.containerRef(container); + }); + await act(flushMicrotasks); + + // CSS can't anchor to a virtual element + expect(container.style.getPropertyValue('position-area')).toBe(''); + expect(container.style.transform).toBe('translate(300px, 200px)'); + }); + + describe('when the browser positions the container', () => { + /** The container is below the target, aligned to its start */ + const mockBrowserPosition = (container: HTMLElement) => { + jest + .spyOn(container, 'getBoundingClientRect') + .mockImplementation( + () => + ({ left: 450, top: 340, x: 450, y: 340, width: 200, height: 100, right: 650, bottom: 440 } as DOMRect), + ); + }; + + const mountWithCss = async (options: Parameters[0], arrow?: HTMLElement) => { + const result = mountHook(options); + const { target, container } = mountElements(result); + if (arrow) { + container.append(arrow); + } + mockLayout(target, container); + mockBrowserPosition(container); + + act(() => { + result.current.targetRef(target); + result.current.containerRef(container); + if (arrow) { + result.current.arrowRef(arrow); + } + }); + // React calls the ref again when its identity changes + act(() => { + result.current.containerRef(container); + }); + await act(flushMicrotasks); + + return { target, container }; + }; + + it('positions the arrow', async () => { + const arrow = document.createElement('div'); + Object.defineProperty(arrow, 'offsetWidth', { value: 10 }); + const { container } = await mountWithCss({ position: 'below', align: 'start' }, arrow); + + expect(container).toHaveStyle({ positionArea: 'block-end span-inline-end' }); + expect(container.style.transform).toBe(''); + expect(arrow).toHaveStyle({ left: '45px' }); + }); + + it('limits the size with autoSize', async () => { + const { container } = await mountWithCss({ position: 'below', align: 'start', autoSize: true }); + + expect(container).toHaveStyle({ positionArea: 'block-end span-inline-end', maxHeight: '360px' }); + }); + + it('calls onPositioningEnd and mirrors the placement in data-placement', async () => { + const onPositioningEnd = jest.fn(); + const { container } = await mountWithCss({ position: 'below', align: 'start', onPositioningEnd }); + + expect(onPositioningEnd).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ placement: 'bottom-start' }) }), + ); + expect(container).toHaveAttribute('data-placement', 'below-start'); + expect(container).not.toHaveAttribute('data-positioning-hidden'); + expect(container).not.toHaveAttribute('data-positioning-escaped'); + }); + }); + }); +}); + describe('getPlacementString', () => { it('returns the bare position for center alignment', () => { expect(getPlacementString('above', 'center')).toBe('above'); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts index e577ae1f96993..e375dc33d36d0 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts @@ -1,21 +1,47 @@ 'use client'; import * as React from 'react'; -import { useId, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; -import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { createPositionManager } from '@fluentui/react-positioning'; import type { + PositionManager, + PositionManagerOptions, PositioningImperativeRef, PositioningShorthandValue, PositioningVirtualElement, } from '@fluentui/react-positioning'; -import type { PositioningProps, PositioningReturn } from './types'; -import { POSITIONS, ALIGNMENTS, POSITION_AREA_MAP } from './constants'; +import type { PositioningReturn, UsePositioningOptions } from './types'; +import { POSITIONS, ALIGNMENTS } from './constants'; import { getPlacementString, normalizeAlign } from './utils/placement'; -import { applyOffset, getCoverSelfAlignment, resolveElementRef, resolveOffset, shorthandToPositionArea } from './utils'; -import { usePlacementObserver } from './usePlacementObserver'; +import { + applyOffset, + computePosition, + getCoverSelfAlignment, + getPositionArea, + resolveElementRef, + resolveOffset, + shorthandToPositionArea, +} from './utils'; + +let nextAnchorId = 0; export type TargetElement = HTMLElement | PositioningVirtualElement; +/** + * CSS anchor positioning with the features that are used by the hook. + */ +const supportsAnchorPositioning = (): boolean => + !!globalThis.CSS?.supports?.('(anchor-name: --a) and (position-area: top)'); + +const CSS_ANCHOR_PROPERTIES = [ + 'position-anchor', + 'position-area', + 'position-try-fallbacks', + 'place-self', + 'align-self', + 'justify-self', +]; + const DEFAULT_FLIP = ['flip-block', 'flip-inline', 'flip-block flip-inline']; const EMPTY_FALLBACK_POSITIONS: PositioningShorthandValue[] = []; @@ -32,7 +58,14 @@ const readAnchorNames = (element: HTMLElement): string[] => { .filter(Boolean); }; -export function usePositioning(options: PositioningProps): PositioningReturn { +/** + * Positions an element next to a target using CSS anchor positioning. + * + * The position is computed in JavaScript, with `@fluentui/react-positioning`, when the browser doesn't support CSS + * anchor positioning or when an option can't be handled by CSS: function offsets, boundaries and virtual element + * targets. `autoSize`, the arrow and `onPositioningEnd` are handled for both. + */ +export function usePositioning(options: UsePositioningOptions): PositioningReturn { const { pinned, target: customTarget = null, @@ -44,6 +77,7 @@ export function usePositioning(options: PositioningProps): PositioningReturn { strategy = 'fixed', matchTargetSize, positioningRef, + enabled = true, } = options; const align = normalizeAlign(alignInput); @@ -56,32 +90,106 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const [triggerEl, setTriggerEl] = React.useState(null); const [containerEl, setContainerEl] = React.useState(null); - const [imperativeTarget, setImperativeTarget] = React.useState(null); - const effectiveTarget = imperativeTarget ?? resolveElementRef(customTarget) ?? triggerEl; + const [arrowEl, setArrowEl] = React.useState(null); + const [imperativeTarget, setImperativeTarget] = React.useState(null); + const positioningTarget: TargetElement | null = imperativeTarget ?? customTarget ?? triggerEl; + // CSS can only anchor to elements + const effectiveTarget = resolveElementRef(positioningTarget); - const anchorName = `--${useId('popover-anchor-')}`; - const positionArea = POSITION_AREA_MAP[position][align]; - const placement = getPlacementString(position, align); + // The result doesn't change during the lifetime of the component + const [supportsAnchors] = React.useState(supportsAnchorPositioning); + // CSS needs an element to anchor to, and can't handle some of the options + const javaScriptPositioning = + !supportsAnchors || + (!!positioningTarget && !effectiveTarget) || + typeof offset === 'function' || + !!(options.flipBoundary || options.overflowBoundary || options.overflowBoundaryPadding); - const { targetDocument } = useFluent(); + const [anchorName] = React.useState(() => `--popover-anchor-${++nextAnchorId}`); + const positionArea = getPositionArea(position, align); + const placement = getPlacementString(position, align); const fallbackAreas = React.useMemo(() => fallbackPositions.map(shorthandToPositionArea), [fallbackPositions]); - const requestPlacementUpdate = usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget); + // Read by the manager on every update + const [settings] = React.useState>({}); + const managerRef = React.useRef(null); + const nextSettings: Record = { ...options, position, align: alignInput, strategy }; + useIsomorphicLayoutEffect(() => { + const changed = Object.keys({ ...settings, ...nextSettings }).filter(key => settings[key] !== nextSettings[key]); + changed.forEach(key => { + settings[key] = nextSettings[key]; + }); + + if (changed.length > 0) { + managerRef.current?.updatePosition(); + } + }); + + const disableUpdateOnResize = options.disableUpdateOnResize; + useIsomorphicLayoutEffect(() => { + if (!enabled || !containerEl || !positioningTarget) { + return; + } + + const onPositioningEndEvent = (e: Event) => + (settings.onPositioningEnd as UsePositioningOptions['onPositioningEnd'])?.( + e as Parameters>[0], + ); + containerEl.addEventListener('fui-positioningend', onPositioningEndEvent); + + const manager = createPositionManager( + Object.assign(settings, { + container: containerEl, + target: positioningTarget, + arrow: arrowEl, + // The browser picks the placement, it's detected from the rects + detectPlacement: javaScriptPositioning + ? undefined + : (target: TargetElement, container: HTMLElement) => + !settings.coverTarget && 'nodeType' in target ? computePosition(target, container) : null, + onPlacement: ({ + position: resolvedPosition, + align: resolvedAlign, + }: Parameters>[0]) => + containerEl.setAttribute('data-placement', getPlacementString(resolvedPosition, resolvedAlign)), + attributes: { + placement: '', + hidden: 'data-positioning-hidden', + escaped: 'data-positioning-escaped', + intersecting: '', + }, + }) as unknown as PositionManagerOptions, + ); + managerRef.current = manager; + + return () => { + containerEl.removeEventListener('fui-positioningend', onPositioningEndEvent); + manager.dispose(); + managerRef.current = null; + }; + }, [ + enabled, + javaScriptPositioning, + containerEl, + positioningTarget, + arrowEl, + strategy, + disableUpdateOnResize, + settings, + ]); React.useImperativeHandle( positioningRef, () => ({ - setTarget: (el: TargetElement | null) => { - setImperativeTarget(resolveElementRef(el)); - }, - updatePosition: requestPlacementUpdate, + setTarget: setImperativeTarget, + updatePosition: () => managerRef.current?.updatePosition(), }), - [requestPlacementUpdate], + [], ); useIsomorphicLayoutEffect(() => { - if (!effectiveTarget) { + if (!effectiveTarget || !enabled || javaScriptPositioning) { return; } @@ -107,23 +215,30 @@ export function usePositioning(options: PositioningProps): PositioningReturn { } } }; - }, [effectiveTarget, anchorName]); - - const targetRef: React.RefCallback = React.useCallback(node => { - setTriggerEl(node); - }, []); + }, [effectiveTarget, anchorName, enabled, javaScriptPositioning]); const containerRef: React.RefCallback = React.useCallback( node => { setContainerEl(node); - if (!node) { + if (!node || !enabled) { + return; + } + + if (javaScriptPositioning) { + // The position is written by `@fluentui/react-positioning`, only the styles of the other mode are removed + CSS_ANCHOR_PROPERTIES.forEach(property => node.style.removeProperty(property)); + if (matchTargetSize !== 'width') { + node.style.removeProperty('width'); + } + node.setAttribute('data-placement', placement); return; } node.style.setProperty('position', strategy); node.style.setProperty('inset', 'auto'); node.style.setProperty('margin', '0'); + node.style.removeProperty('transform'); applyOffset(node, position, mainAxis, crossAxis); @@ -172,6 +287,8 @@ export function usePositioning(options: PositioningProps): PositioningReturn { } }, [ + enabled, + javaScriptPositioning, anchorName, positionArea, placement, @@ -187,5 +304,5 @@ export function usePositioning(options: PositioningProps): PositioningReturn { ], ); - return { targetRef, containerRef }; + return { targetRef: setTriggerEl, containerRef, arrowRef: setArrowEl }; } diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/debounce.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/debounce.ts deleted file mode 100644 index cc80d606118e2..0000000000000 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/debounce.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Microtask debouncer: coalesces multiple synchronous calls within a task into - * a single invocation, fired before the next paint. Same shape as the helper - * `@fluentui/react-positioning` uses internally (originally from Popper.js v2). - */ -export function debounce(fn: () => void): () => void { - let pending = false; - return () => { - if (pending) { - return; - } - pending = true; - Promise.resolve().then(() => { - pending = false; - fn(); - }); - }; -} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts index 8506fa430c612..77177cb989cb3 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts @@ -1,6 +1,5 @@ export { computePosition } from './computePosition'; export type { ComputePositionConfig, ComputePositionReturn } from './computePosition'; -export { debounce } from './debounce'; export { applyOffset, resolveOffset } from './offset'; -export { getCoverSelfAlignment, getPlacementString, shorthandToPositionArea } from './placement'; +export { getCoverSelfAlignment, getPlacementString, getPositionArea, shorthandToPositionArea } from './placement'; export { resolveElementRef } from './resolveElementRef'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts index 812e5919fd123..3f4aecbb39572 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts @@ -1,7 +1,6 @@ -import { resolvePositioningShorthand } from '@fluentui/react-positioning'; import type { Alignment, Position, PositioningShorthandValue } from '@fluentui/react-positioning'; import type { LogicalAlignment } from '../types'; -import { ALIGNMENTS, POSITIONS, POSITION_AREA_MAP } from '../constants'; +import { ALIGNMENTS, POSITIONS } from '../constants'; const ALIGN_ALIASES: Record = { top: ALIGNMENTS.start, @@ -37,8 +36,19 @@ export function getPlacementString(position: Position, align: Alignment): Positi } export function shorthandToPositionArea(shorthand: PositioningShorthandValue): string { - const { position = POSITIONS.above, align = ALIGNMENTS.center } = resolvePositioningShorthand(shorthand); - return POSITION_AREA_MAP[position][normalizeAlign(align)]; + const [position, align = ALIGNMENTS.center] = shorthand.split('-'); + return getPositionArea(position as Position, normalizeAlign(align)); +} + +export function getPositionArea(position: Position, align: LogicalAlignment): string { + const block = position === POSITIONS.above || position === POSITIONS.below; + const area = `${block ? 'block' : 'inline'}-${ + position === POSITIONS.above || position === POSITIONS.before ? 'start' : 'end' + }`; + + return align === ALIGNMENTS.center + ? area + : `${area} span-${block ? 'inline' : 'block'}-${align === ALIGNMENTS.start ? 'end' : 'start'}`; } export function getCoverSelfAlignment( diff --git a/packages/react-components/react-headless-components-preview/library/src/positioning.ts b/packages/react-components/react-headless-components-preview/library/src/positioning.ts index 3c24fa062d54b..d63077b31168d 100644 --- a/packages/react-components/react-headless-components-preview/library/src/positioning.ts +++ b/packages/react-components/react-headless-components-preview/library/src/positioning.ts @@ -1,11 +1,22 @@ export type { - Position, Alignment, + AutoSize, + Offset, + OffsetFunction, + OffsetFunctionParam, + OffsetObject, + OffsetShorthand, + Position, + PositioningBoundary, + PositioningImperativeRef, PositioningProps, + PositioningRect, PositioningReturn, - PositioningImperativeRef, PositioningShorthand, PositioningShorthandValue, + PositioningVirtualElement, + SetVirtualMouseTarget, + UsePositioningOptions, } from './hooks/usePositioning'; export { usePositioning, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogDescription.md index 0703952ee7879..500c1357cc20e 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogDescription.md +++ b/packages/react-components/react-headless-components-preview/stories/src/Dialog/DialogDescription.md @@ -1,5 +1,7 @@ Headless Dialog provides native dialog behavior without built-in styles. Modal dialogs make the background inert; non-modal dialogs allow interaction with the page. +When composing a modal dialog with a legacy portaled overlay, such as the v9 `Popover`, set the overlay's `mountNode` to the `DialogSurface` element. Portaling to the document body places the overlay outside the native modal's non-inert subtree, preventing interaction with it. + ### Exit animations Native close restores focus immediately. With `unmountOnClose` (the default), removal waits for running, finite surface and backdrop animations to finish or cancel—not descendant, infinite, or already-paused animations. diff --git a/packages/react-components/react-positioning/library/README.md b/packages/react-components/react-positioning/library/README.md index c5e5f78fb9052..2cea1d60771cc 100644 --- a/packages/react-components/react-positioning/library/README.md +++ b/packages/react-components/react-positioning/library/README.md @@ -1,6 +1,8 @@ # @fluentui/react-positioning -A React utilities built on top of [Floating UI](https://floating-ui.com/) for positioning elements in the DOM. +React utilities for positioning elements in the DOM, using a standalone JavaScript engine. +The fallback preserves the existing Floating UI-based hook's placement and configuration semantics +without importing the Floating UI engine at runtime. ## Usage @@ -23,3 +25,78 @@ const PopupExample: React.FC = ({ children }) => { ); }; ``` + +## Browser behavior contracts + +`src/usePositioning.geometry.cy.tsx` exercises the public hook with real DOM layout and the +unmocked positioning engine. Run it through the existing component-test target: + +```sh +yarn nx run react-positioning:e2e --spec=src/usePositioning.geometry.cy.tsx --browser=electron --config=retries=0 +``` + +The committed expectations in `src/usePositioning.geometry-baseline.cy.ts` were captured from +master baseline `f626209351609e43bc98eb8cc1296273faa679b7` using Cypress 14.4.1 / Electron 130 +at an 800-by-600 viewport. They pin exact CSS-pixel rectangles, placement, strategy, transform +serialization, left/top, maximum sizes, arrow coordinates, visibility attributes, match-target +CSS variable, and overflow styles. No tolerance or engine-derived expected coordinates are used. +The baseline helper only expands literal expected values. Engine replacements must pass these +expectations, not regenerate them. + +For fractional DPR cases with transforms disabled, only rectangle x/y are compared to an +engine-free sibling positioned with the committed literal left/top values. Native layout +quantization is host-dependent: Electron 130 lays out `280.667px` at `280.6640625` on the +original macOS host but at `280.65625` on Linux CI. Both are distinct from device-pixel rounding +performed by the engine. The sibling runs no positioning logic and never reads engine output; +all inline styles, dimensions, placement, and other fields remain exact literal assertions. +The original rectangle snapshots are retained as provenance, not regenerated for each host. +Negative-control tests at DPR 1.5 and 3 reject a `0.001px` left/top change even when native +layout produces the same rectangle, and reject displaced rectangles with unchanged left/top. + +Coverage includes the finite 4-position by 5-alignment by 2-direction matrix (including invalid +axis combinations), edge flips, ordered fallbacks and best fit, pinned shifting, tether options, +shift-to-cover limits, number/object/function offsets, arrows, rectangular/element/intersected +element-array/window/scroll/clipping boundaries, independent flip/overflow boundaries, logical +padding, auto-size aliases, target-width matching, target covering, containing-block scaling and +transforms, virtual targets, and fractional rectangles at DPR 1, 1.25, 1.5, 2, and 3. DPR is +controlled independently of the host display; geometry and the engine are never mocked. +Scrollbars are hidden without disabling scrolling so native scrollbar widths do not alter the fixture. +The fixture includes a scoped `::-webkit-scrollbar` rule for the older Chromium bundled with +Cypress 13 in React 17 compatibility tests, which does not support `scrollbar-width`. + +Lifecycle cases cover imperative updates and retargeting, target prop removal, option removal, +enable/disable, resize and scroll updates, visibility recovery, native end events, detached +container cleanup, idle behavior, and inline end callbacks that update React state. A safety cap +on the state-updating callback makes an update loop fail without hanging the browser. + +These are geometry contracts, not screenshot rasterization baselines or a claim of every +possible combination. Font rasterization, browser zoom, native high-DPI capture, cross-browser +layout differences, iframe/shadow-root targets, and visual-viewport/mobile behavior are not +covered. The explicit `strategy: 'absolute'` case deliberately records the existing baseline's +`fixed` result; changing that behavior requires a separate intentional compatibility decision. + +## Engine parity and size gates + +The engine measures in containing-block CSS pixels and resolves matching, offsets, covering, +best-fit flipping, shifting/tethering, available-size constraints, arrows, and visibility in order. +Positioning callbacks and imperative refs do not recreate the manager on callback-only rerenders. + +```sh +yarn nx run react-positioning:bundle-size --skip-nx-cache +yarn nx run react-positioning:test-bundle-size +yarn nx run react-positioning:test-raster +``` + +The bundle gate runs the repository's monosize/webpack measurement for the existing +`bundle-size/usePopper.fixture.js` entry, labeled `usePositioning`, and rejects an actual +minified output larger than **15,000 bytes**. This is an absolute, uncompressed limit; gzip +is reported separately. Gate tests cover the inclusive limit and invalid or stale measurements. + +The raster target extracts the unchanged lower-PR source from +`788e3b739fe79246a2f8850d1c575cf4018fd744` into a temporary directory, then runs the same +contracts against that implementation and the replacement in Electron. A paint-only wrapper +makes otherwise transparent fixtures visible without changing their layout. It compares every +decoded RGBA pixel, including antialiasing, and writes its report to +`dist/positioning-raster/comparison.json`. It does not update the pinned geometry expectations. +This is bounded fixture coverage on the same browser and host display, not a guarantee of all +option combinations, fonts, browsers, iframe/shadow-root targets, or mobile visual viewports. diff --git a/packages/react-components/react-positioning/library/bundle-size/check-budget.mjs b/packages/react-components/react-positioning/library/bundle-size/check-budget.mjs new file mode 100644 index 0000000000000..0d635f3662ec8 --- /dev/null +++ b/packages/react-components/react-positioning/library/bundle-size/check-budget.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { readFile, stat } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +export function checkBudget(report, actualBytes) { + const entries = report.filter(entry => entry.name === 'usePositioning'); + assert.equal(entries.length, 1, 'Expected exactly one usePositioning monosize result'); + const [entry] = entries; + assert.equal(entry.path, 'bundle-size/usePopper.fixture.js'); + assert.equal(entry.minifiedSize, actualBytes, 'Monosize result must match the emitted fixture'); + assert(Number.isSafeInteger(actualBytes) && actualBytes > 0, 'Expected a nonempty minified bundle'); + assert(actualBytes <= 15000, `usePositioning: ${actualBytes} minified bytes exceeds the 15000-byte budget`); + return actualBytes; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const report = JSON.parse(await readFile(new URL('../dist/bundle-size/monosize.json', import.meta.url), 'utf8')); + const { size } = await stat(new URL('../dist/bundle-size/usePopper.output/index.js', import.meta.url)); + console.log(`usePositioning: ${checkBudget(report, size)} / 15000 minified bytes`); +} diff --git a/packages/react-components/react-positioning/library/bundle-size/check-budget.test.mjs b/packages/react-components/react-positioning/library/bundle-size/check-budget.test.mjs new file mode 100644 index 0000000000000..dceb01d6062b2 --- /dev/null +++ b/packages/react-components/react-positioning/library/bundle-size/check-budget.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { checkBudget } from './check-budget.mjs'; + +const entry = bytes => ({ + name: 'usePositioning', + path: 'bundle-size/usePopper.fixture.js', + minifiedSize: bytes, + gzippedSize: 1, +}); + +test('uses an inclusive absolute minified-byte limit, not gzip or a relative threshold', () => { + assert.equal(checkBudget([entry(15000)], 15000), 15000); + assert.throws(() => checkBudget([entry(15001)], 15001), /exceeds/); +}); + +test('rejects absent, duplicate, mislabelled, stale and empty measurements', () => { + assert.throws(() => checkBudget([], 1)); + assert.throws(() => checkBudget([entry(1), entry(1)], 1)); + assert.throws(() => checkBudget([{ ...entry(1), path: 'other.fixture.js' }], 1)); + assert.throws(() => checkBudget([entry(1)], 2)); + assert.throws(() => checkBudget([entry(0)], 0)); +}); diff --git a/packages/react-components/react-positioning/library/cypress.config.ts b/packages/react-components/react-positioning/library/cypress.config.ts index ca52cf041bbf2..5cef22acf9529 100644 --- a/packages/react-components/react-positioning/library/cypress.config.ts +++ b/packages/react-components/react-positioning/library/cypress.config.ts @@ -1,3 +1,47 @@ import { baseConfig } from '@fluentui/scripts-cypress'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Compiler } from 'webpack'; + +const source = process.env.FLUENT_POSITIONING_ORACLE_SOURCE; +if (source) { + const webpackConfig = baseConfig.component.devServer.webpackConfig; + webpackConfig.resolve = { + ...webpackConfig.resolve, + alias: { + './usePositioning$': path.join(source, 'usePositioning.ts'), + '@floating-ui/devtools$': path.join( + fileURLToPath(new URL('.', import.meta.url)), + 'scripts/floating-ui-devtools-shim.ts', + ), + }, + }; + webpackConfig.plugins = [ + ...(webpackConfig.plugins || []), + { + apply(compiler: Compiler) { + compiler.hooks.compilation.tap('PositioningOracle', compilation => { + compilation.hooks.finishModules.tap('PositioningOracle', modules => { + const resources = Array.from(modules).filter(module => 'resource' in module); + const includesContracts = resources.some( + module => + module.resource === fileURLToPath(new URL('./src/usePositioning.geometry.cy.tsx', import.meta.url)), + ); + const loaded = resources.some( + module => 'resource' in module && module.resource === path.join(source, 'usePositioning.ts'), + ); + if (includesContracts && !loaded) { + compilation.errors.push( + new compiler.webpack.WebpackError( + 'Raster baseline did not load the unchanged oracle usePositioning implementation', + ), + ); + } + }); + }); + }, + }, + ]; +} export default baseConfig; diff --git a/packages/react-components/react-positioning/library/etc/react-positioning.api.md b/packages/react-components/react-positioning/library/etc/react-positioning.api.md index 9a2e65951c1e1..f7cfbfe9ff2c6 100644 --- a/packages/react-components/react-positioning/library/etc/react-positioning.api.md +++ b/packages/react-components/react-positioning/library/etc/react-positioning.api.md @@ -32,6 +32,9 @@ export type CreateArrowStylesOptions = { borderColor?: GriffelStyle['borderBottomColor']; }; +// @internal (undocumented) +export function createPositionManager(options: PositionManagerOptions): PositionManager; + // @public @deprecated export function createSlideStyles(mainAxis: number): GriffelStyle; @@ -130,6 +133,38 @@ export type PositioningVirtualElement = { contextElement?: Element; }; +// @internal (undocumented) +export interface PositionManager { + // (undocumented) + dispose: () => void; + // (undocumented) + updatePosition: () => void; +} + +// @internal (undocumented) +export interface PositionManagerOptions extends Pick { + // (undocumented) + arrow: HTMLElement | null; + // (undocumented) + attributes?: Partial>; + // (undocumented) + container: HTMLElement; + // (undocumented) + detectPlacement?: (target: TargetElement, container: HTMLElement) => HeadlessPlacement | null; + // (undocumented) + isRtl?: boolean; + // (undocumented) + onPlacement?: (placement: HeadlessPlacement) => void; + // (undocumented) + placement?: PositioningPlacement; + // (undocumented) + positioningOptions?: PositioningOptions; + // (undocumented) + strategy: 'absolute' | 'fixed'; + // (undocumented) + target: TargetElement; +} + // @public (undocumented) export function resolvePositioningShorthand(shorthand: PositioningShorthand | undefined | null): Readonly; diff --git a/packages/react-components/react-positioning/library/package.json b/packages/react-components/react-positioning/library/package.json index d94eed1bd37fa..babc904ee4613 100644 --- a/packages/react-components/react-positioning/library/package.json +++ b/packages/react-components/react-positioning/library/package.json @@ -12,8 +12,6 @@ }, "license": "MIT", "dependencies": { - "@floating-ui/devtools": "^0.2.3", - "@floating-ui/dom": "^1.6.12", "@fluentui/react-shared-contexts": "^9.26.4", "@fluentui/react-theme": "^9.2.2", "@fluentui/react-utilities": "^9.27.0", diff --git a/packages/react-components/react-positioning/library/project.json b/packages/react-components/react-positioning/library/project.json index 4e7e9301e1a9e..934d3694fcf87 100644 --- a/packages/react-components/react-positioning/library/project.json +++ b/packages/react-components/react-positioning/library/project.json @@ -3,6 +3,40 @@ "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "projectType": "library", "sourceRoot": "packages/react-components/react-positioning/library/src", + "targets": { + "bundle-size": { + "inputs": [ + "production", + "^production", + "{workspaceRoot}/monosize.config.mjs", + "{projectRoot}/bundle-size/**", + { "externalDependencies": ["monosize", "monosize-bundler-webpack"] } + ], + "options": { + "command": "yarn run -T monosize measure && node bundle-size/check-budget.mjs" + }, + "configurations": { + "debug": { + "command": "yarn run -T monosize measure --debug && node bundle-size/check-budget.mjs" + } + } + }, + "test-bundle-size": { + "command": "node --test packages/react-components/react-positioning/library/bundle-size/check-budget.test.mjs" + }, + "test-raster": { + "cache": false, + "dependsOn": ["build", { "target": "build", "projects": "scripts-cypress" }], + "options": { + "cwd": "packages/react-components/react-positioning/library", + "command": "node scripts/positioning-raster.mjs" + }, + "executor": "nx:run-commands" + }, + "e2e": { + "inputs": ["default", "^production", "{projectRoot}/cypress.config.ts"] + } + }, "tags": ["vNext", "platform:web"], "implicitDependencies": [] } diff --git a/packages/react-components/react-positioning/library/scripts/floating-ui-devtools-shim.ts b/packages/react-components/react-positioning/library/scripts/floating-ui-devtools-shim.ts new file mode 100644 index 0000000000000..8b83c08edfc1b --- /dev/null +++ b/packages/react-components/react-positioning/library/scripts/floating-ui-devtools-shim.ts @@ -0,0 +1 @@ +export const devtools = () => undefined; diff --git a/packages/react-components/react-positioning/library/scripts/positioning-raster.mjs b/packages/react-components/react-positioning/library/scripts/positioning-raster.mjs new file mode 100644 index 0000000000000..2f4e17184a0ee --- /dev/null +++ b/packages/react-components/react-positioning/library/scripts/positioning-raster.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import cypress from 'cypress'; + +const project = fileURLToPath(new URL('..', import.meta.url)); +const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: project, encoding: 'utf8' }).trim(); +// Reuse the PNG decoder already owned by the repository's visual-regression tooling. +const { PNG } = createRequire(path.join(root, 'tools/visual-regression-assert/package.json'))('pngjs'); +const baseline = '788e3b739fe79246a2f8850d1c575cf4018fd744'; +const sourcePath = 'packages/react-components/react-positioning/library/src'; +const output = path.join(project, 'dist/positioning-raster'); +const knownHostFailures = ['fractional-dpr-1.5-transform-false', 'fractional-dpr-3-transform-false']; +await mkdir(path.join(root, 'node_modules/.cache'), { recursive: true }); +const source = await mkdtemp(path.join(root, 'node_modules/.cache/positioning-oracle-')); + +try { + const archive = execFileSync('git', ['archive', baseline, sourcePath], { cwd: root }); + execFileSync('tar', ['-x', '--strip-components=5', '-C', source], { input: archive }); + for (const engine of ['master', 'replacement']) { + process.env.FLUENT_POSITIONING_ORACLE_SOURCE = engine === 'master' ? source : ''; + console.log( + `Capturing ${engine}; oracle source: ${process.env.FLUENT_POSITIONING_ORACLE_SOURCE || '(current source)'}`, + ); + const result = await cypress.run({ + project, + component: true, + browser: 'electron', + spec: 'src/usePositioning.raster.tsx', + config: { + component: { specPattern: 'src/usePositioning.raster.tsx' }, + retries: 0, + screenshotsFolder: path.join(output, engine), + }, + }); + assert('totalFailed' in result, `${engine}: Cypress did not finish`); + assert.equal(result.totalTests, 143, `${engine}: expected the complete lower-PR contract suite`); + const failedTests = result.runs.flatMap(run => + run.tests.filter(test => test.state === 'failed').map(test => test.title.at(-1)), + ); + const sortedFailures = failedTests.sort(); + const sortedKnownHostFailures = [...knownHostFailures].sort(); + const onlyKnownHostFailures = + sortedFailures.length === sortedKnownHostFailures.length && + sortedFailures.every((test, index) => test === sortedKnownHostFailures[index]); + assert.ok( + sortedFailures.length === 0 || onlyKnownHostFailures, + `${engine}: only the two oracle-reproduced fractional-DPR contracts may fail during raster capture`, + ); + console.log(`${engine}: ran all ${result.totalTests} contracts; ${sortedFailures.length} host failures`); + } + const screenshots = async engine => { + const directory = path.join(output, engine, 'usePositioning.raster.tsx'); + const files = (await readdir(directory)) + .filter( + file => + file.endsWith('.png') && + !file.endsWith('(failed).png') && + !file.includes('rejects style drift even within one native layout step'), + ) + .sort(); + return { directory, files }; + }; + const master = await screenshots('master'); + const replacement = await screenshots('replacement'); + assert.deepEqual(replacement.files, master.files, 'Both engines must render every captured case'); + assert.equal(master.files.length, 140, 'Expected every geometry/lifecycle case except baseline integrity'); + const results = []; + for (const file of master.files) { + const expected = PNG.sync.read(await readFile(path.join(master.directory, file))); + const actual = PNG.sync.read(await readFile(path.join(replacement.directory, file))); + assert.equal(actual.width, expected.width, `${file}: width`); + assert.equal(actual.height, expected.height, `${file}: height`); + let differentPixels = 0; + for (let i = 0; i < expected.data.length; i += 4) { + if (!expected.data.subarray(i, i + 4).equals(actual.data.subarray(i, i + 4))) { + differentPixels++; + } + } + results.push({ file, width: actual.width, height: actual.height, differentPixels }); + } + await writeFile(path.join(output, 'comparison.json'), JSON.stringify({ baseline, results }, null, 2)); + const failed = results.filter(result => result.differentPixels !== 0); + assert.deepEqual(failed, [], 'Raster parity requires zero changed RGBA pixels, including antialiasing'); + console.log(`${results.length} raster comparisons passed with zero differing pixels; baseline ${baseline}`); +} finally { + await rm(source, { recursive: true, force: true }); +} diff --git a/packages/react-components/react-positioning/library/src/constants.ts b/packages/react-components/react-positioning/library/src/constants.ts index 9f2ef12574bec..8e2c711c83780 100644 --- a/packages/react-components/react-positioning/library/src/constants.ts +++ b/packages/react-components/react-positioning/library/src/constants.ts @@ -3,6 +3,7 @@ export const DATA_POSITIONING_ESCAPED = 'data-popper-escaped'; export const DATA_POSITIONING_HIDDEN = 'data-popper-reference-hidden'; export const DATA_POSITIONING_PLACEMENT = 'data-popper-placement'; export const POSITIONING_END_EVENT = 'fui-positioningend'; +export const MATCH_TARGET_SIZE_CSS_VAR = '--fui-match-target-size'; /** * CSS custom properties used to encode the slide direction for positioning-aware enter animations. diff --git a/packages/react-components/react-positioning/library/src/createComputedPositionManager.ts b/packages/react-components/react-positioning/library/src/createComputedPositionManager.ts new file mode 100644 index 0000000000000..5d3b6a241f3b3 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/createComputedPositionManager.ts @@ -0,0 +1,114 @@ +import { isHTMLElement } from '@fluentui/react-utilities'; +import type { PositionManagerOptions } from './createPositionManager'; +import type { OnPositioningEndEventDetail, PositionManager } from './types'; +import { computePosition } from './engine/computePosition'; +import { getScrollAncestors } from './engine/geometry'; +import { debounce, writeContainerUpdates } from './utils'; +import { writeArrowUpdates } from './utils/writeArrowUpdates'; +import { POSITIONING_END_EVENT } from './constants'; +import { createResizeObserver } from './utils/createResizeObserver'; + +function isLayoutViewportUnavailable(container: HTMLElement): boolean { + const { clientWidth, clientHeight } = container.ownerDocument.documentElement; + return clientWidth === 0 && clientHeight === 0; +} + +export function createComputedPositionManager(options: PositionManagerOptions): PositionManager { + let isDestroyed = false; + const { container, target, arrow, strategy, useTransform = true, disableUpdateOnResize = false } = options; + const targetWindow = container.ownerDocument.defaultView; + if (!target || !container || !targetWindow) { + return { + updatePosition: () => undefined, + dispose: () => undefined, + }; + } + + const resizeObserver = disableUpdateOnResize + ? null + : createResizeObserver(targetWindow, entries => { + if (entries.every(entry => entry.contentRect.width > 0 && entry.contentRect.height > 0)) { + updatePosition(); + } + }); + + let isFirstUpdate = true; + const scrollParents = new Set(); + Object.assign(container.style, { position: 'fixed', left: 0, top: 0, margin: 0 }); + + const forceUpdate = () => { + if (isDestroyed) { + return; + } + + if (isFirstUpdate) { + getScrollAncestors(container).forEach(scrollParent => scrollParents.add(scrollParent)); + if (isHTMLElement(target)) { + getScrollAncestors(target).forEach(scrollParent => scrollParents.add(scrollParent)); + } + scrollParents.forEach(scrollParent => scrollParent.addEventListener('scroll', updatePosition, { passive: true })); + resizeObserver?.observe(container); + if (isHTMLElement(target)) { + resizeObserver?.observe(target); + } + isFirstUpdate = false; + } + + container.style.position = strategy; + const positioningOptions = options.positioningOptions ?? options; + const rtl = options.isRtl ?? targetWindow.getComputedStyle(container).direction === 'rtl'; + computePosition(target, container, { + placement: options.placement, + strategy, + positioningOptions, + isRtl: rtl, + arrow, + }) + .then(({ x, y, middlewareData, placement }) => { + if (isDestroyed) { + return; + } + + const resolvedMiddlewareData = isLayoutViewportUnavailable(container) + ? { ...middlewareData, hide: { escaped: false, referenceHidden: false } } + : middlewareData; + const { escaped = false, referenceHidden = false } = resolvedMiddlewareData.hide || {}; + writeArrowUpdates({ arrow, middlewareData: resolvedMiddlewareData }); + writeContainerUpdates({ + container, + middlewareData: resolvedMiddlewareData, + placement, + coordinates: { x, y }, + lowPPI: (targetWindow.devicePixelRatio || 1) <= 1, + useTransform, + }); + container.dispatchEvent( + new CustomEvent(POSITIONING_END_EVENT, { + detail: { placement, escaped, referenceHidden }, + }), + ); + }) + .catch(err => { + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.error('[usePositioning]: Failed to calculate position', err); + } + }); + }; + + const updatePosition = debounce(forceUpdate); + const dispose = () => { + isDestroyed = true; + targetWindow.removeEventListener('scroll', updatePosition); + targetWindow.removeEventListener('resize', updatePosition); + scrollParents.forEach(scrollParent => scrollParent.removeEventListener('scroll', updatePosition)); + scrollParents.clear(); + resizeObserver?.disconnect(); + }; + + targetWindow.addEventListener('scroll', updatePosition, { passive: true }); + targetWindow.addEventListener('resize', updatePosition); + updatePosition(); + + return { updatePosition, dispose }; +} diff --git a/packages/react-components/react-positioning/library/src/createPositionManager.test.ts b/packages/react-components/react-positioning/library/src/createPositionManager.test.ts index f9b874813c343..d617f569d9b78 100644 --- a/packages/react-components/react-positioning/library/src/createPositionManager.test.ts +++ b/packages/react-components/react-positioning/library/src/createPositionManager.test.ts @@ -1,20 +1,14 @@ -import { computePosition } from '@floating-ui/dom'; -import type { Placement } from '@floating-ui/dom'; import { createPositionManager } from './createPositionManager'; -import { DATA_POSITIONING_ESCAPED, DATA_POSITIONING_HIDDEN, POSITIONING_END_EVENT } from './constants'; -import type { OnPositioningEndEvent } from './types'; - -jest.mock('@floating-ui/dom', () => ({ - computePosition: jest.fn(), -})); - -const computePositionMock = computePosition as jest.MockedFunction; +import { + DATA_POSITIONING_ESCAPED, + DATA_POSITIONING_HIDDEN, + DATA_POSITIONING_PLACEMENT, + POSITIONING_END_EVENT, +} from './constants'; +import type { OnPositioningEndEvent, PositioningPlacement } from './types'; /** - * Flush the microtask queue. - * createPositionManager uses debounce (Promise.resolve().then → forceUpdate) - * followed by computePosition(...).then → dispatch event, requiring multiple - * microtask cycles to fully resolve. + * Flush the microtask queue, `createPositionManager` debounces updates with a promise. */ const flushMicrotasks = async () => { for (let i = 0; i < 5; i++) { @@ -22,24 +16,41 @@ const flushMicrotasks = async () => { } }; -function createTestElements() { +type Rect = { left: number; top: number; width: number; height: number }; + +const toDOMRect = ({ left, top, width, height }: Rect) => + ({ left, top, width, height, x: left, y: top, right: left + width, bottom: top + height } as DOMRect); + +function createTestElements({ + target: targetRect = { left: 450, top: 300, width: 100, height: 40 }, + size = { width: 200, height: 100 }, +}: { target?: Rect; size?: { width: number; height: number } } = {}) { const container = document.createElement('div'); const target = document.createElement('button'); document.body.appendChild(container); document.body.appendChild(target); + jest.spyOn(target, 'getBoundingClientRect').mockImplementation(() => toDOMRect(targetRect)); + // The container is moved by the manager, the rect follows the transform + jest.spyOn(container, 'getBoundingClientRect').mockImplementation(() => { + const [, x = 0, y = 0] = /translate(?:3d)?\((-?[\d.]+)px, (-?[\d.]+)px/.exec(container.style.transform) ?? []; + return toDOMRect({ left: Number(x), top: Number(y), ...size }); + }); + Object.defineProperty(container, 'offsetWidth', { value: size.width, configurable: true }); + Object.defineProperty(container, 'offsetHeight', { value: size.height, configurable: true }); + return { container, target }; } -const mockMiddlewareData = { - intersectionObserver: { intersecting: false }, - hide: { escaped: false, referenceHidden: false }, +const mockViewport = (width = 1000, height = 700) => { + jest.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(width); + jest.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(height); }; describe('createPositionManager', () => { beforeEach(() => { - computePositionMock.mockReset(); + mockViewport(); }); afterEach(() => { @@ -48,27 +59,49 @@ describe('createPositionManager', () => { }); it.each([ - 'top', - 'top-start', - 'top-end', - 'right', - 'right-start', - 'right-end', - 'bottom', - 'bottom-start', - 'bottom-end', - 'left', - 'left-start', - 'left-end', - ] as Placement[])('dispatches POSITIONING_END_EVENT with placement "%s"', async (placement: Placement) => { - computePositionMock.mockResolvedValue({ - x: 10, - y: 20, - placement, - strategy: 'absolute', - middlewareData: mockMiddlewareData, - }); - + ['above', 'center', 'top'], + ['above', 'start', 'top-start'], + ['above', 'end', 'top-end'], + ['below', 'center', 'bottom'], + ['below', 'start', 'bottom-start'], + ['below', 'end', 'bottom-end'], + ['before', 'center', 'left'], + ['before', 'top', 'left-start'], + ['before', 'bottom', 'left-end'], + ['after', 'center', 'right'], + ['after', 'top', 'right-start'], + ['after', 'bottom', 'right-end'], + ] as const)( + 'dispatches POSITIONING_END_EVENT for position "%s" and align "%s" as "%s"', + async (position, align, placement: PositioningPlacement) => { + const { container, target } = createTestElements(); + const listener = jest.fn(); + container.addEventListener(POSITIONING_END_EVENT, listener); + + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position, + align, + disableUpdateOnResize: true, + }); + + await flushMicrotasks(); + + expect(listener).toHaveBeenCalledTimes(1); + + const event: OnPositioningEndEvent = listener.mock.calls[0][0]; + + expect(event).toBeInstanceOf(CustomEvent); + expect(event.type).toBe(POSITIONING_END_EVENT); + expect(event.detail).toEqual({ placement, escaped: false, referenceHidden: false }); + expect(container.getAttribute(DATA_POSITIONING_PLACEMENT)).toBe(placement); + }, + ); + + it('swaps the sides of before and after in rtl', async () => { const { container, target } = createTestElements(); const listener = jest.fn(); container.addEventListener(POSITIONING_END_EVENT, listener); @@ -77,34 +110,83 @@ describe('createPositionManager', () => { container, target, arrow: null, - strategy: 'absolute', - middleware: [], - placement, + strategy: 'fixed', + position: 'before', + isRtl: true, disableUpdateOnResize: true, }); await flushMicrotasks(); - expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0].detail.placement).toBe('right'); + // The container is on the right side of the target + expect(container.style.transform).toBe('translate(550px, 270px)'); + }); - const event: OnPositioningEndEvent = listener.mock.calls[0][0]; + it('positions the container next to the target', async () => { + const { container, target } = createTestElements(); - expect(event).toBeInstanceOf(CustomEvent); - expect(event.type).toBe(POSITIONING_END_EVENT); - expect(event.detail).toEqual({ placement, escaped: false, referenceHidden: false }); + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position: 'below', + align: 'start', + offset: 8, + disableUpdateOnResize: true, + }); + + await flushMicrotasks(); + + expect(container).toHaveStyle({ position: 'fixed', left: '0px', top: '0px' }); + expect(container.style.transform).toBe('translate(450px, 348px)'); }); - it('dispatches event with computed placement when middleware changes it', async () => { - // Request 'top' but middleware flips to 'bottom' - computePositionMock.mockResolvedValue({ - x: 10, - y: 20, - placement: 'bottom', + it('uses the initial containing block origin for absolute positioning', async () => { + const { container, target } = createTestElements(); + jest + .spyOn(document.documentElement, 'getBoundingClientRect') + .mockReturnValue(toDOMRect({ left: 10, top: 20, width: 1000, height: 700 })); + + createPositionManager({ + container, + target, + arrow: null, strategy: 'absolute', - middlewareData: mockMiddlewareData, + position: 'below', + align: 'start', + offset: 8, + disableUpdateOnResize: true, }); + await flushMicrotasks(); + + expect(container.style.transform).toBe('translate(450px, 348px)'); + }); + + it('writes left and top without useTransform', async () => { const { container, target } = createTestElements(); + + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position: 'below', + align: 'start', + useTransform: false, + disableUpdateOnResize: true, + }); + + await flushMicrotasks(); + + expect(container).toHaveStyle({ left: '450px', top: '340px' }); + expect(container.style.transform).toBe(''); + }); + + it('flips when there is no room and reports the computed placement', async () => { + const { container, target } = createTestElements({ target: { left: 450, top: 60, width: 100, height: 40 } }); const listener = jest.fn(); container.addEventListener(POSITIONING_END_EVENT, listener); @@ -112,80 +194,206 @@ describe('createPositionManager', () => { container, target, arrow: null, - strategy: 'absolute', - middleware: [], - placement: 'top', + strategy: 'fixed', + position: 'above', disableUpdateOnResize: true, }); await flushMicrotasks(); - expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0].detail.placement).toBe('bottom'); + expect(container.style.transform).toBe('translate(400px, 100px)'); + }); - const event: OnPositioningEndEvent = listener.mock.calls[0][0]; + it('does not flip when it is pinned', async () => { + const { container, target } = createTestElements({ target: { left: 450, top: 60, width: 100, height: 40 } }); + const listener = jest.fn(); + container.addEventListener(POSITIONING_END_EVENT, listener); - expect(event.detail.placement).toBe('bottom'); - expect(event.detail.escaped).toBe(false); - expect(event.detail.referenceHidden).toBe(false); + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position: 'above', + pinned: true, + disableUpdateOnResize: true, + }); + + await flushMicrotasks(); + + expect(listener.mock.calls[0][0].detail.placement).toBe('top'); }); - it('dispatches event with hide middleware visibility flags', async () => { - computePositionMock.mockResolvedValue({ - x: 10, - y: 20, - placement: 'bottom', - strategy: 'absolute', - middlewareData: { - ...mockMiddlewareData, - hide: { escaped: true, referenceHidden: true }, - }, + it('tries the fallback positions before the default ones', async () => { + const { container, target } = createTestElements({ target: { left: 450, top: 60, width: 100, height: 40 } }); + const listener = jest.fn(); + container.addEventListener(POSITIONING_END_EVENT, listener); + + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position: 'above', + fallbackPositions: ['after-top'], + disableUpdateOnResize: true, }); + await flushMicrotasks(); + + expect(listener.mock.calls[0][0].detail.placement).toBe('right-start'); + }); + + it('calls function offsets with the placement that is tried', async () => { const { container, target } = createTestElements(); - const measurableRect = { - top: 0, - right: 10, - bottom: 10, - left: 0, - width: 10, - height: 10, - } as DOMRect; - jest.spyOn(container, 'getBoundingClientRect').mockReturnValue(measurableRect); - jest.spyOn(target, 'getBoundingClientRect').mockReturnValue(measurableRect); - jest.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(10); - jest.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(10); - const listener = jest.fn(); - container.addEventListener(POSITIONING_END_EVENT, listener); + const offset = jest.fn(() => 12); createPositionManager({ container, target, arrow: null, - strategy: 'absolute', - middleware: [], - placement: 'bottom', + strategy: 'fixed', + position: 'below', + align: 'start', + offset, disableUpdateOnResize: true, }); await flushMicrotasks(); - const event: OnPositioningEndEvent = listener.mock.calls[0][0]; - expect(event.detail).toEqual({ placement: 'bottom', escaped: true, referenceHidden: true }); + expect(offset).toHaveBeenCalledWith({ + positionedRect: { x: 0, y: 0, width: 200, height: 100 }, + targetRect: { x: 450, y: 300, width: 100, height: 40 }, + position: 'below', + alignment: 'start', + }); + expect(container.style.transform).toBe('translate(450px, 352px)'); }); - it('does not report hide flags when the layout viewport is unavailable', async () => { - computePositionMock.mockResolvedValue({ - x: 0, - y: 0, - placement: 'bottom', + it('positions next to a virtual element', async () => { + const { container } = createTestElements(); + const target = { + getBoundingClientRect: () => ({ ...toDOMRect({ left: 300, top: 200, width: 0, height: 0 }) }), + }; + + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position: 'below', + align: 'start', + disableUpdateOnResize: true, + }); + + await flushMicrotasks(); + + expect(container.style.transform).toBe('translate(300px, 200px)'); + }); + + it('positions relative to the document with the absolute strategy', async () => { + const { container, target } = createTestElements(); + Object.defineProperty(window, 'scrollY', { value: 100, configurable: true }); + + createPositionManager({ + container, + target, + arrow: null, strategy: 'absolute', - middlewareData: { - ...mockMiddlewareData, - hide: { escaped: true, referenceHidden: true }, - }, + position: 'below', + align: 'start', + disableUpdateOnResize: true, + }); + + await flushMicrotasks(); + + expect(container.style.transform).toBe('translate(450px, 440px)'); + Object.defineProperty(window, 'scrollY', { value: 0, configurable: true }); + }); + + it('matches the width of the target', async () => { + const { container, target } = createTestElements({ size: { width: 120, height: 100 } }); + + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position: 'below', + align: 'start', + matchTargetSize: 'width', + disableUpdateOnResize: true, }); + await flushMicrotasks(); + + expect(container.style.getPropertyValue('--fui-match-target-size')).toBe('100px'); + expect(container.style.width).toBe('var(--fui-match-target-size)'); + }); + + it('limits the size of the container with autoSize', async () => { const { container, target } = createTestElements(); + + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position: 'below', + autoSize: 'height', + disableUpdateOnResize: true, + }); + + await flushMicrotasks(); + + expect(container.style.maxHeight).toBe('360px'); + expect(container.style.boxSizing).toBe('border-box'); + }); + + it('positions the arrow', async () => { + const { container, target } = createTestElements(); + const arrow = document.createElement('div'); + Object.defineProperty(arrow, 'offsetWidth', { value: 10 }); + container.appendChild(arrow); + + createPositionManager({ + container, + target, + arrow, + strategy: 'fixed', + position: 'below', + align: 'start', + disableUpdateOnResize: true, + }); + + await flushMicrotasks(); + + // The container starts where the target does, the arrow points to the center of the target + expect(arrow).toHaveStyle({ left: '45px' }); + }); + + it('sets the visibility attributes', async () => { + const { container, target } = createTestElements({ target: { left: 450, top: -100, width: 100, height: 40 } }); + + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position: 'above', + pinned: true, + disableUpdateOnResize: true, + }); + + await flushMicrotasks(); + + expect(container.hasAttribute(DATA_POSITIONING_HIDDEN)).toBe(true); + expect(container.hasAttribute(DATA_POSITIONING_ESCAPED)).toBe(true); + }); + + it('dispatches event with the visibility flags', async () => { + const { container, target } = createTestElements({ target: { left: 450, top: -100, width: 100, height: 40 } }); const listener = jest.fn(); container.addEventListener(POSITIONING_END_EVENT, listener); @@ -193,31 +401,65 @@ describe('createPositionManager', () => { container, target, arrow: null, - strategy: 'absolute', - middleware: [], - placement: 'bottom', + strategy: 'fixed', + position: 'above', + pinned: true, + disableUpdateOnResize: true, + }); + + await flushMicrotasks(); + + expect(listener.mock.calls[0][0].detail).toEqual({ placement: 'top', escaped: true, referenceHidden: true }); + }); + + it('does not report hide flags when the layout viewport is unavailable', async () => { + mockViewport(0, 0); + const { container, target } = createTestElements({ target: { left: 450, top: -100, width: 100, height: 40 } }); + const listener = jest.fn(); + container.addEventListener(POSITIONING_END_EVENT, listener); + + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position: 'above', disableUpdateOnResize: true, }); await flushMicrotasks(); const event: OnPositioningEndEvent = listener.mock.calls[0][0]; - expect(event.detail).toEqual({ placement: 'bottom', escaped: false, referenceHidden: false }); + expect(event.detail.escaped).toBe(false); + expect(event.detail.referenceHidden).toBe(false); expect(container.hasAttribute(DATA_POSITIONING_ESCAPED)).toBe(false); expect(container.hasAttribute(DATA_POSITIONING_HIDDEN)).toBe(false); }); - it('does not dispatch event after dispose', async () => { - // Use a deferred promise so we can control when computePosition resolves - let resolveCompute!: (value: Awaited>) => void; + it('updates the position with updatePosition()', async () => { + const targetRect = { left: 450, top: 300, width: 100, height: 40 }; + const { container, target } = createTestElements({ target: targetRect }); - computePositionMock.mockImplementation( - () => - new Promise(resolve => { - resolveCompute = resolve; - }), - ); + const manager = createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position: 'below', + align: 'start', + disableUpdateOnResize: true, + }); + await flushMicrotasks(); + expect(container.style.transform).toBe('translate(450px, 340px)'); + + targetRect.top = 200; + manager.updatePosition(); + await flushMicrotasks(); + + expect(container.style.transform).toBe('translate(450px, 240px)'); + }); + it('does not dispatch event after dispose', async () => { const { container, target } = createTestElements(); const listener = jest.fn(); container.addEventListener(POSITIONING_END_EVENT, listener); @@ -226,30 +468,141 @@ describe('createPositionManager', () => { container, target, arrow: null, - strategy: 'absolute', - middleware: [], - placement: 'bottom', + strategy: 'fixed', + position: 'below', disableUpdateOnResize: true, }); - // Let debounce microtask fire so computePosition is called + // The first update is scheduled but didn't run yet + manager.dispose(); await flushMicrotasks(); - // Dispose before the promise resolves - manager.dispose(); + expect(listener).not.toHaveBeenCalled(); + }); + it('uses the direction of the containing block when isRtl is not set', async () => { + const { container, target } = createTestElements(); + document.body.setAttribute('dir', 'rtl'); + jest.spyOn(window, 'getComputedStyle').mockReturnValue({ direction: 'rtl' } as CSSStyleDeclaration); + const listener = jest.fn(); + container.addEventListener(POSITIONING_END_EVENT, listener); - // Now resolve the pending computePosition - resolveCompute({ - x: 10, - y: 20, - placement: 'bottom', - strategy: 'absolute', - middlewareData: mockMiddlewareData, + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position: 'before', + disableUpdateOnResize: true, + }); + await flushMicrotasks(); + + expect(listener.mock.calls[0][0].detail.placement).toBe('right'); + document.body.removeAttribute('dir'); + }); + + it('writes the attributes with the given names', async () => { + const { container, target } = createTestElements({ target: { left: 450, top: -500, width: 100, height: 40 } }); + + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position: 'below', + disableUpdateOnResize: true, + attributes: { placement: '', hidden: 'data-hidden', escaped: 'data-escaped', intersecting: '' }, }); + await flushMicrotasks(); + + expect(container.hasAttribute(DATA_POSITIONING_PLACEMENT)).toBe(false); + expect(container.hasAttribute(DATA_POSITIONING_HIDDEN)).toBe(false); + expect(container.hasAttribute('data-hidden')).toBe(true); + }); + + it('calls onPlacement with the placement', async () => { + const { container, target } = createTestElements(); + const onPlacement = jest.fn(); - // Allow the .then() to run + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position: 'below', + align: 'end', + disableUpdateOnResize: true, + detectPlacement: () => ({ position: 'below', align: 'end' }), + onPlacement, + }); await flushMicrotasks(); - expect(listener).not.toHaveBeenCalled(); + expect(onPlacement).toHaveBeenCalledWith(expect.objectContaining({ position: 'below', align: 'end' })); + }); + + describe('when the browser positions the container', () => { + it('does not move the container, it reads the placement and handles the arrow and the end event', async () => { + const { container, target } = createTestElements(); + const arrow = document.createElement('div'); + Object.defineProperty(arrow, 'offsetWidth', { value: 10 }); + container.append(arrow); + const listener = jest.fn(); + container.addEventListener(POSITIONING_END_EVENT, listener); + jest + .spyOn(container, 'getBoundingClientRect') + .mockReturnValue(toDOMRect({ left: 450, top: 340, width: 200, height: 100 })); + + createPositionManager({ + container, + target, + arrow, + strategy: 'fixed', + position: 'below', + disableUpdateOnResize: true, + detectPlacement: () => ({ position: 'below', align: 'start' }), + }); + await flushMicrotasks(); + + expect(container.style.position).toBe(''); + expect(container.style.transform).toBe(''); + expect(container.hasAttribute(DATA_POSITIONING_PLACEMENT)).toBe(false); + expect(arrow.style.left).toBe('45px'); + expect(listener.mock.calls[0][0].detail.placement).toBe('bottom-start'); + }); + + it('does not dispatch the end event when the placement can not be detected', async () => { + const { container, target } = createTestElements(); + const listener = jest.fn(); + container.addEventListener(POSITIONING_END_EVENT, listener); + + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + disableUpdateOnResize: true, + detectPlacement: () => null, + }); + await flushMicrotasks(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('limits the size with autoSize', async () => { + const { container, target } = createTestElements(); + + createPositionManager({ + container, + target, + arrow: null, + strategy: 'fixed', + position: 'below', + autoSize: 'height', + disableUpdateOnResize: true, + detectPlacement: () => ({ position: 'below', align: 'center' }), + }); + await flushMicrotasks(); + + expect(container.style.maxHeight).toBe('360px'); + }); }); }); diff --git a/packages/react-components/react-positioning/library/src/createPositionManager.ts b/packages/react-components/react-positioning/library/src/createPositionManager.ts index baf08850d9b2e..2f5e212435563 100644 --- a/packages/react-components/react-positioning/library/src/createPositionManager.ts +++ b/packages/react-components/react-positioning/library/src/createPositionManager.ts @@ -1,52 +1,108 @@ -import { computePosition } from '@floating-ui/dom'; -import type { Middleware, Placement, Strategy } from '@floating-ui/dom'; import { isHTMLElement } from '@fluentui/react-utilities'; -import type { OnPositioningEndEventDetail, PositionManager, PositioningPlacement, TargetElement } from './types'; -import { debounce, writeArrowUpdates, writeContainerUpdates } from './utils'; -import { listScrollParents } from './utils/listScrollParents'; +import type { + OnPositioningEndEventDetail, + Position, + PositionManager, + PositioningOptions, + PositioningPlacement, + TargetElement, +} from './types'; +import type { PlacementRect } from './engine/computePlacement'; +import { applyArrow } from './engine/arrow'; +import { applyAutoSize, resetAutoSize } from './engine/autoSize'; +import { getScrollAncestors } from './engine/geometry'; +import { getClippingRect } from './engine/platform'; +import { toPlacement, toPositioningPlacement } from './engine/placement'; +import { resolveOffset } from './engine/offset'; +import { debounce, normalizeAutoSize, writeVisibilityUpdates } from './utils'; import { POSITIONING_END_EVENT } from './constants'; import { createResizeObserver } from './utils/createResizeObserver'; +import { createComputedPositionManager } from './createComputedPositionManager'; +import { toFloatingUIPlacement } from './utils/toFloatingUIPlacement'; +import { fromFloatingUIPlacement } from './utils/fromFloatingUIPlacement'; +import { + DATA_POSITIONING_ESCAPED, + DATA_POSITIONING_HIDDEN, + DATA_POSITIONING_INTERSECTING, + DATA_POSITIONING_PLACEMENT, +} from './constants'; -interface PositionManagerOptions { - /** - * The positioned element - */ +type HeadlessPlacement = { position: Position; align: 'start' | 'center' | 'end' }; + +/** + * @internal + */ +export interface PositionManagerOptions + extends Pick< + PositioningOptions, + | 'align' + | 'arrowPadding' + | 'autoSize' + | 'coverTarget' + | 'disableUpdateOnResize' + | 'fallbackPositions' + | 'flipBoundary' + | 'matchTargetSize' + | 'offset' + | 'overflowBoundary' + | 'overflowBoundaryPadding' + | 'pinned' + | 'position' + | 'useTransform' + > { container: HTMLElement; - /** - * Element that the container will be anchored to - */ target: TargetElement; - /** - * Arrow that points from the container to the target - */ arrow: HTMLElement | null; - /** - * The value of the css `position` property - * @default absolute - */ - strategy: Strategy; - /** - * [Floating UI middleware](https://floating-ui.com/docs/middleware) - */ - middleware: Middleware[]; - /** - * [Floating UI placement](https://floating-ui.com/docs/computePosition#placement) - */ - placement?: Placement; - /** - * Modifies whether popover is positioned using transform. - * @default true - */ - useTransform?: boolean; - /** - * Disables the resize observer that updates position on target or dimension change - */ - disableUpdateOnResize?: boolean; + strategy: 'absolute' | 'fixed'; + positioningOptions?: PositioningOptions; + isRtl?: boolean; + placement?: PositioningPlacement; + detectPlacement?: (target: TargetElement, container: HTMLElement) => HeadlessPlacement | null; + onPlacement?: (placement: HeadlessPlacement) => void; + attributes?: Partial>; +} + +type Visibility = { + referenceHidden: boolean; + escaped: boolean; + intersecting: boolean; +}; + +function rectOf(rect: Pick): PlacementRect { + return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; +} + +function isOutside(rect: PlacementRect, bounds: PlacementRect): boolean { + return ( + rect.left + rect.width <= bounds.left || + rect.left >= bounds.left + bounds.width || + rect.top + rect.height <= bounds.top || + rect.top >= bounds.top + bounds.height + ); +} + +function getVisibility( + container: HTMLElement, + target: TargetElement, + anchor: PlacementRect, + popup: PlacementRect, +): Visibility { + const element = ('nodeType' in target ? target : target.contextElement) ?? container; + const clip = getClippingRect(element, 'clippingAncestors'); + const bounds = { left: clip.x, top: clip.y, width: clip.width, height: clip.height }; + const overflowTop = bounds.top - popup.top; + const overflowBottom = popup.top + popup.height - (bounds.top + bounds.height); + + return { + referenceHidden: isOutside(anchor, bounds), + escaped: isOutside(popup, bounds), + intersecting: + (overflowTop > 0 && overflowTop < popup.height) || (overflowBottom > 0 && overflowBottom < popup.height), + }; } function isLayoutViewportUnavailable(container: HTMLElement): boolean { const { clientWidth, clientHeight } = container.ownerDocument.documentElement; - return clientWidth === 0 && clientHeight === 0; } @@ -55,17 +111,77 @@ function isLayoutViewportUnavailable(container: HTMLElement): boolean { * @returns manager that handles positioning out of the react lifecycle */ export function createPositionManager(options: PositionManagerOptions): PositionManager { + if (!options.detectPlacement) { + const managerContainer = options.container; + const attributes = options.attributes; + const onPlacement = options.onPlacement; + const positioningOptions = options.positioningOptions ?? options; + const rtl = + options.isRtl ?? + managerContainer.ownerDocument.defaultView?.getComputedStyle(managerContainer).direction === 'rtl'; + const manager = createComputedPositionManager({ + ...options, + placement: options.placement ?? toFloatingUIPlacement(positioningOptions.align, positioningOptions.position, rtl), + isRtl: rtl, + }); + if (!attributes && !onPlacement) { + return manager; + } + + const onPositioningEnd = (event: Event) => { + const { placement } = (event as CustomEvent).detail; + const syncBooleanAttribute = (name: string | undefined, fallback: string, value: boolean) => { + if (name === '') { + managerContainer.removeAttribute(fallback); + } else { + managerContainer.toggleAttribute(name ?? fallback, value); + if (name && name !== fallback) { + managerContainer.removeAttribute(fallback); + } + } + }; + const placementAttribute = attributes?.placement; + if (placementAttribute === '') { + managerContainer.removeAttribute(DATA_POSITIONING_PLACEMENT); + } else { + managerContainer.setAttribute(placementAttribute ?? DATA_POSITIONING_PLACEMENT, placement); + if (placementAttribute && placementAttribute !== DATA_POSITIONING_PLACEMENT) { + managerContainer.removeAttribute(DATA_POSITIONING_PLACEMENT); + } + } + syncBooleanAttribute( + attributes?.intersecting, + DATA_POSITIONING_INTERSECTING, + managerContainer.hasAttribute(DATA_POSITIONING_INTERSECTING), + ); + syncBooleanAttribute( + attributes?.escaped, + DATA_POSITIONING_ESCAPED, + managerContainer.hasAttribute(DATA_POSITIONING_ESCAPED), + ); + syncBooleanAttribute( + attributes?.hidden, + DATA_POSITIONING_HIDDEN, + managerContainer.hasAttribute(DATA_POSITIONING_HIDDEN), + ); + if (onPlacement) { + const { position, alignment } = fromFloatingUIPlacement(placement); + onPlacement(toPlacement(position, alignment)); + } + }; + + managerContainer.addEventListener(POSITIONING_END_EVENT, onPositioningEnd); + return { + updatePosition: manager.updatePosition, + dispose: () => { + managerContainer.removeEventListener(POSITIONING_END_EVENT, onPositioningEnd); + manager.dispose(); + }, + }; + } + let isDestroyed = false; - const { - container, - target, - arrow, - strategy, - middleware, - placement, - useTransform = true, - disableUpdateOnResize = false, - } = options; + const { container, target, arrow, disableUpdateOnResize = false, detectPlacement } = options; const targetWindow = container.ownerDocument.defaultView; if (!target || !container || !targetWindow) { return { @@ -74,136 +190,89 @@ export function createPositionManager(options: PositionManagerOptions): Position }; } - // When the dimensions of the target or the container change - trigger a position update const resizeObserver = disableUpdateOnResize ? null : createResizeObserver(targetWindow, entries => { - // If content rect dimensions to go 0 -> very likely that `display: none` is being used to hide the element - // In this case don't update and let users update imperatively - const shouldUpdateOnResize = entries.every(entry => { - return entry.contentRect.width > 0 && entry.contentRect.height > 0; - }); - - if (shouldUpdateOnResize) { + if (entries.every(entry => entry.contentRect.width > 0 && entry.contentRect.height > 0)) { updatePosition(); } }); let isFirstUpdate = true; - const scrollParents: Set = new Set(); - - // When the container is first resolved, set position `fixed` to avoid scroll jumps. - // Without this scroll jumps can occur when the element is rendered initially and receives focus - Object.assign(container.style, { position: 'fixed', left: 0, top: 0, margin: 0 }); + const scrollParents = new Set(); const forceUpdate = () => { - // debounced update can still occur afterwards - // early return to avoid memory leaks if (isDestroyed) { return; } if (isFirstUpdate) { - listScrollParents(container).forEach(scrollParent => scrollParents.add(scrollParent)); + getScrollAncestors(container).forEach(scrollParent => scrollParents.add(scrollParent)); if (isHTMLElement(target)) { - listScrollParents(target).forEach(scrollParent => scrollParents.add(scrollParent)); + getScrollAncestors(target).forEach(scrollParent => scrollParents.add(scrollParent)); } - - scrollParents.forEach(scrollParent => { - scrollParent.addEventListener('scroll', updatePosition, { passive: true }); - }); - + scrollParents.forEach(scrollParent => scrollParent.addEventListener('scroll', updatePosition, { passive: true })); resizeObserver?.observe(container); if (isHTMLElement(target)) { resizeObserver?.observe(target); } - isFirstUpdate = false; } - Object.assign(container.style, { position: strategy }); - computePosition(target, container, { placement, middleware, strategy }) - .then(({ x, y, middlewareData, placement: computedPlacement }) => { - // Promise can still resolve after destruction - // early return to avoid applying outdated position - if (isDestroyed) { - return; - } + const positioningOptions = options.positioningOptions ?? options; + const requested = toPlacement(positioningOptions.position, positioningOptions.align); + const placement = detectPlacement ? detectPlacement(target, container) : requested; + const resolved = placement ?? requested; + const autoSize = normalizeAutoSize(positioningOptions.autoSize); + const targetRect = rectOf(target.getBoundingClientRect()); + const boundsRect = getClippingRect(container, 'clippingAncestors'); + const bounds = { left: boundsRect.x, top: boundsRect.y, width: boundsRect.width, height: boundsRect.height }; + const [mainAxis] = resolveOffset(positioningOptions.offset); + const isRtl = options.isRtl ?? targetWindow.getComputedStyle(container).direction === 'rtl'; - const positioningMiddlewareData = isLayoutViewportUnavailable(container) - ? { - ...middlewareData, - hide: { ...middlewareData.hide, escaped: false, referenceHidden: false }, - } - : middlewareData; - - writeArrowUpdates({ arrow, middlewareData: positioningMiddlewareData }); - writeContainerUpdates({ - container, - middlewareData: positioningMiddlewareData, - placement: computedPlacement, - coordinates: { x, y }, - lowPPI: (targetWindow?.devicePixelRatio || 1) <= 1, - strategy, - useTransform, - }); - - container.dispatchEvent( - new CustomEvent(POSITIONING_END_EVENT, { - detail: { - // Cast from Floating UI's Placement to the Fluent-owned PositioningPlacement. - // These are equivalent string unions; the cast avoids leaking @floating-ui/dom - // types into the public API surface. - placement: computedPlacement satisfies PositioningPlacement, - escaped: positioningMiddlewareData.hide?.escaped ?? false, - referenceHidden: positioningMiddlewareData.hide?.referenceHidden ?? false, - }, - }), - ); - }) - .catch(err => { - // https://github.com/floating-ui/floating-ui/issues/1845 - // FIXME for node > 14 - // node 15 introduces promise rejection which means that any components - // tests need to be `it('', async () => {})` otherwise there can be race conditions with - // JSDOM being torn down before this promise is resolved so globals like `window` and `document` don't exist - // Unless all tests that ever use `usePositioning` are turned into async tests, any logging during testing - // will actually be counter productive - if (process.env.NODE_ENV === 'development') { - // eslint-disable-next-line no-console - console.error('[usePositioning]: Failed to calculate position', err); - } - }); - }; + if (autoSize) { + resetAutoSize(container, autoSize); + applyAutoSize(container, autoSize, resolved, targetRect, bounds, isRtl, mainAxis); + } - const updatePosition = debounce(() => forceUpdate()); + const popup = rectOf(container.getBoundingClientRect()); + const visibility = isLayoutViewportUnavailable(container) + ? { escaped: false, referenceHidden: false, intersecting: false } + : getVisibility(container, target, targetRect, popup); - const dispose = () => { - isDestroyed = true; + if (placement) { + applyArrow(arrow, placement, targetRect, popup, positioningOptions.arrowPadding); + options.onPlacement?.(placement); + } + writeVisibilityUpdates({ container, visibility, attributes: options.attributes }); - if (targetWindow) { - targetWindow.removeEventListener('scroll', updatePosition); - targetWindow.removeEventListener('resize', updatePosition); + if (placement) { + container.dispatchEvent( + new CustomEvent(POSITIONING_END_EVENT, { + detail: { + placement: toPositioningPlacement(placement, isRtl), + escaped: visibility.escaped, + referenceHidden: visibility.referenceHidden, + }, + }), + ); } + }; - scrollParents.forEach(scrollParent => { - scrollParent.removeEventListener('scroll', updatePosition); - }); - scrollParents.clear(); + const updatePosition = debounce(forceUpdate); + const dispose = () => { + isDestroyed = true; + targetWindow.removeEventListener('scroll', updatePosition); + targetWindow.removeEventListener('resize', updatePosition); + scrollParents.forEach(scrollParent => scrollParent.removeEventListener('scroll', updatePosition)); + scrollParents.clear(); resizeObserver?.disconnect(); }; - if (targetWindow) { - targetWindow.addEventListener('scroll', updatePosition, { passive: true }); - targetWindow.addEventListener('resize', updatePosition); - } - - // Update the position on initialization + targetWindow.addEventListener('scroll', updatePosition, { passive: true }); + targetWindow.addEventListener('resize', updatePosition); updatePosition(); - return { - updatePosition, - dispose, - }; + return { updatePosition, dispose }; } diff --git a/packages/react-components/react-positioning/library/src/engine/arrow.ts b/packages/react-components/react-positioning/library/src/engine/arrow.ts new file mode 100644 index 0000000000000..d82e7141634e1 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/arrow.ts @@ -0,0 +1,33 @@ +import type { Placement, PlacementRect } from './computePlacement'; + +/** + * Positions the arrow: it points to the center of the target, and it stays inside the container. + * Handles `arrowPadding`. + */ +export function applyArrow( + arrow: HTMLElement | null, + placement: Placement, + anchor: PlacementRect, + popup: PlacementRect, + arrowPadding = 0, +): void { + if (!arrow) { + return; + } + + const alongX = placement.position === 'above' || placement.position === 'below'; + const arrowSize = alongX ? arrow.offsetWidth : arrow.offsetHeight; + const [anchorStart, anchorSize, popupStart, popupSize] = alongX + ? [anchor.left, anchor.width, popup.left, popup.width] + : [anchor.top, anchor.height, popup.top, popup.height]; + + // The padding can't make the arrow leave the center when the container is small + const padding = Math.min(arrowPadding, popupSize / 2 - arrowSize / 2 - 1); + const offset = Math.min( + Math.max(anchorStart + anchorSize / 2 - popupStart - arrowSize / 2, padding), + popupSize - arrowSize - padding, + ); + + arrow.style.setProperty(alongX ? 'left' : 'top', `${offset}px`); + arrow.style.removeProperty(alongX ? 'top' : 'left'); +} diff --git a/packages/react-components/react-positioning/library/src/engine/autoSize.ts b/packages/react-components/react-positioning/library/src/engine/autoSize.ts new file mode 100644 index 0000000000000..a7d785ed6612d --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/autoSize.ts @@ -0,0 +1,65 @@ +import type { NormalizedAutoSize } from '../types'; +import type { Placement, PlacementRect } from './computePlacement'; + +/** + * Removes the styles of the previous update, so the container is measured with its natural size. + */ +export function resetAutoSize(container: HTMLElement, { applyMaxWidth, applyMaxHeight }: NormalizedAutoSize): void { + if (applyMaxWidth) { + container.style.removeProperty('max-width'); + } + if (applyMaxHeight) { + container.style.removeProperty('max-height'); + } + container.style.removeProperty('box-sizing'); +} + +/** + * Handles `autoSize`: the container doesn't get bigger than the space that is available. + * @param mainAxis - offset between the target and the container, it's on both sides of the container + */ +export function applyAutoSize( + container: HTMLElement, + { applyMaxWidth, applyMaxHeight }: NormalizedAutoSize, + placement: Placement, + anchor: PlacementRect, + bounds: PlacementRect, + rtl: boolean, + mainAxis: number, +): void { + const { position } = placement; + const blockMain = position === 'above' || position === 'below'; + const boundsRight = bounds.left + bounds.width; + const boundsBottom = bounds.top + bounds.height; + const margin = mainAxis * 2; + // The space between the target (and the offset) and the edge of the bounds on the side of the placement + const mainSpace = + position === 'above' + ? anchor.top - margin - bounds.top + : position === 'below' + ? boundsBottom - (anchor.top + anchor.height + margin) + : (position === 'before') !== rtl + ? anchor.left - margin - bounds.left + : boundsRight - (anchor.left + anchor.width + margin); + const space = { + width: Math.max(0, blockMain ? bounds.width : mainSpace), + height: Math.max(0, blockMain ? mainSpace : bounds.height), + }; + + (['width', 'height'] as const).forEach(dimension => { + if (!(dimension === 'width' ? applyMaxWidth : applyMaxHeight)) { + return; + } + + container.style.setProperty('box-sizing', 'border-box'); + container.style.setProperty(`max-${dimension}`, `${space[dimension]}px`); + + const overflow = dimension === 'width' ? 'overflow-x' : 'overflow-y'; + if ( + (dimension === 'width' ? container.scrollWidth : container.scrollHeight) > space[dimension] && + !container.style.getPropertyValue(overflow) + ) { + container.style.setProperty(overflow, 'auto'); + } + }); +} diff --git a/packages/react-components/react-positioning/library/src/engine/boundary.ts b/packages/react-components/react-positioning/library/src/engine/boundary.ts new file mode 100644 index 0000000000000..679829d504ab7 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/boundary.ts @@ -0,0 +1,72 @@ +import type { PositioningBoundary, PositioningOptions } from '../types'; +import type { PlacementInput, PlacementRect } from './computePlacement'; +import { getClippingRect, getPaddingBox, getScrollAncestors, getViewportRect, intersect } from './geometry'; + +type Padding = NonNullable; + +function resolveBoundary(boundary: PositioningBoundary, container: HTMLElement): PlacementRect { + if (boundary === 'window') { + return getViewportRect(container); + } + + if (boundary === 'clippingParents') { + return getClippingRect(container); + } + + if (boundary === 'scrollParent') { + const [scrollParent] = getScrollAncestors(container); + return scrollParent ? getPaddingBox(scrollParent) : getViewportRect(container); + } + + if (Array.isArray(boundary)) { + return boundary.map(element => getPaddingBox(element)).reduce(intersect, getViewportRect(container)); + } + + if ('nodeType' in boundary) { + return getPaddingBox(boundary); + } + + return { left: boundary.x, top: boundary.y, width: boundary.width, height: boundary.height }; +} + +const shrink = (rect: PlacementRect, padding: Padding, rtl: boolean): PlacementRect => { + const { + top = 0, + bottom = 0, + start = 0, + end = 0, + } = typeof padding === 'number' ? { top: padding, bottom: padding, start: padding, end: padding } : padding; + const left = rtl ? end : start; + const right = rtl ? start : end; + + return { + left: rect.left + left, + top: rect.top + top, + width: Math.max(0, rect.width - left - right), + height: Math.max(0, rect.height - top - bottom), + }; +}; + +/** + * Handles `flipBoundary`, `overflowBoundary` and `overflowBoundaryPadding`. + */ +export function applyBoundary( + input: PlacementInput, + container: HTMLElement, + rtl: boolean, + options: Pick, +): void { + const { flipBoundary, overflowBoundary, overflowBoundaryPadding } = options; + const viewport = getViewportRect(container); + let bounds = overflowBoundary ? intersect(resolveBoundary(overflowBoundary, container), viewport) : input[1]; + + if (overflowBoundaryPadding) { + bounds = shrink(bounds, overflowBoundaryPadding, rtl); + } + + const flipBounds = flipBoundary + ? intersect(resolveBoundary(flipBoundary, container), viewport) + : input[8] ?? input[1]; + input[1] = bounds; + input[8] = flipBounds; +} diff --git a/packages/react-components/react-positioning/library/src/engine/computePlacement.test.ts b/packages/react-components/react-positioning/library/src/engine/computePlacement.test.ts new file mode 100644 index 0000000000000..987f65b610cf6 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/computePlacement.test.ts @@ -0,0 +1,538 @@ +import type { Position } from '../types'; +import { computePlacement } from './computePlacement'; +import type { LogicalAlignment, Placement } from './computePlacement'; + +interface Scenario { + pos: Position; + align: LogicalAlignment; + anchor: { left: number; top: number; width: number; height: number }; + popup: { width: number; height: number }; + offset?: { main: number; cross: number }; + rtl?: boolean; + fallbacks?: string[]; + pinned?: boolean; + matchWidth?: boolean; + cover?: boolean; + expected: { left: number; top: number }; +} + +const VIEWPORT = { left: 0, top: 0, width: 1000, height: 700 }; + +const toPlacement = (shorthand: string): Placement => { + const [position, align = 'center'] = shorthand.split('-'); + return { + position: position as Position, + align: (align === 'top' ? 'start' : align === 'bottom' ? 'end' : align) as LogicalAlignment, + }; +}; + +/** + * Unpinned `expected` values were recorded from Chrome 154 resolving the equivalent CSS (`position-area`, `position-try-fallbacks`, margins, + * `place-self: anchor-center` for centered placements) in a 1000x700 viewport. + */ +const scenarios: Array<[string, Scenario]> = [ + [ + 'above-start with room', + { + pos: 'above', + align: 'start', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 450, top: 200 }, + }, + ], + [ + 'above-center with room', + { + pos: 'above', + align: 'center', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 400, top: 200 }, + }, + ], + [ + 'above-end with room', + { + pos: 'above', + align: 'end', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 350, top: 200 }, + }, + ], + [ + 'below-start with room', + { + pos: 'below', + align: 'start', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 450, top: 340 }, + }, + ], + [ + 'below-center with room', + { + pos: 'below', + align: 'center', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 400, top: 340 }, + }, + ], + [ + 'below-end with room', + { + pos: 'below', + align: 'end', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 350, top: 340 }, + }, + ], + [ + 'before-start with room', + { + pos: 'before', + align: 'start', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 250, top: 300 }, + }, + ], + [ + 'before-center with room', + { + pos: 'before', + align: 'center', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 250, top: 270 }, + }, + ], + [ + 'before-end with room', + { + pos: 'before', + align: 'end', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 250, top: 240 }, + }, + ], + [ + 'after-start with room', + { + pos: 'after', + align: 'start', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 550, top: 300 }, + }, + ], + [ + 'after-center with room', + { + pos: 'after', + align: 'center', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 550, top: 270 }, + }, + ], + [ + 'after-end with room', + { + pos: 'after', + align: 'end', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 550, top: 240 }, + }, + ], + [ + 'flips below when there is no room above', + { + pos: 'above', + align: 'center', + anchor: { left: 450, top: 60, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 400, top: 100 }, + }, + ], + [ + 'flips above when there is no room below', + { + pos: 'below', + align: 'center', + anchor: { left: 450, top: 620, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 400, top: 520 }, + }, + ], + [ + 'flips after when there is no room before', + { + pos: 'before', + align: 'center', + anchor: { left: 100, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 200, top: 270 }, + }, + ], + [ + 'flips before when there is no room after', + { + pos: 'after', + align: 'center', + anchor: { left: 800, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 600, top: 270 }, + }, + ], + [ + 'flips the alignment when it overflows the inline end', + { + pos: 'above', + align: 'start', + anchor: { left: 900, top: 300, width: 60, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 760, top: 200 }, + }, + ], + [ + 'flips both axes in a corner', + { + pos: 'above', + align: 'start', + anchor: { left: 900, top: 40, width: 60, height: 40 }, + popup: { width: 200, height: 100 }, + expected: { left: 760, top: 80 }, + }, + ], + [ + 'flips the block alignment of an inline position', + { + pos: 'after', + align: 'start', + anchor: { left: 300, top: 640, width: 60, height: 40 }, + popup: { width: 100, height: 200 }, + expected: { left: 360, top: 480 }, + }, + ], + [ + 'keeps a centered popup inside the viewport (right)', + { + pos: 'below', + align: 'center', + anchor: { left: 940, top: 300, width: 40, height: 40 }, + popup: { width: 300, height: 100 }, + expected: { left: 700, top: 340 }, + }, + ], + [ + 'keeps a centered popup inside the viewport (left)', + { + pos: 'above', + align: 'center', + anchor: { left: 10, top: 300, width: 40, height: 40 }, + popup: { width: 300, height: 100 }, + expected: { left: 0, top: 200 }, + }, + ], + [ + 'keeps a centered popup inside the viewport (block axis)', + { + pos: 'after', + align: 'center', + anchor: { left: 300, top: 10, width: 40, height: 40 }, + popup: { width: 100, height: 300 }, + expected: { left: 340, top: 0 }, + }, + ], + [ + 'offset', + { + pos: 'below', + align: 'start', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + offset: { main: 8, cross: 4 }, + expected: { left: 454, top: 348 }, + }, + ], + [ + 'offset keeps the gap when flipping', + { + pos: 'below', + align: 'center', + anchor: { left: 450, top: 620, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + offset: { main: 8, cross: 0 }, + expected: { left: 400, top: 512 }, + }, + ], + [ + 'offset of an inline position', + { + pos: 'after', + align: 'end', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + offset: { main: 6, cross: 3 }, + expected: { left: 556, top: 237 }, + }, + ], + [ + 'rtl before', + { + pos: 'before', + align: 'center', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + rtl: true, + expected: { left: 550, top: 270 }, + }, + ], + [ + 'rtl above start', + { + pos: 'above', + align: 'start', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + rtl: true, + expected: { left: 350, top: 200 }, + }, + ], + [ + 'rtl flips the inline alignment', + { + pos: 'above', + align: 'start', + anchor: { left: 10, top: 300, width: 60, height: 40 }, + popup: { width: 200, height: 100 }, + rtl: true, + expected: { left: 10, top: 200 }, + }, + ], + [ + 'custom fallbacks', + { + pos: 'above', + align: 'center', + anchor: { left: 450, top: 60, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + fallbacks: ['after', 'below'], + expected: { left: 550, top: 30 }, + }, + ], + [ + 'custom fallbacks of other axis', + { + pos: 'below', + align: 'start', + anchor: { left: 450, top: 640, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + fallbacks: ['before-top', 'above'], + expected: { left: 400, top: 540 }, + }, + ], + [ + 'pinned does not flip', + { + pos: 'above', + align: 'center', + anchor: { left: 450, top: 60, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + pinned: true, + expected: { left: 400, top: -40 }, + }, + ], + [ + 'match target width', + { + pos: 'below', + align: 'start', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + matchWidth: true, + expected: { left: 450, top: 340 }, + }, + ], + [ + 'anchor sticks out of the viewport', + { + pos: 'above', + align: 'center', + anchor: { left: 944, top: 260, width: 119, height: 57 }, + popup: { width: 448, height: 150 }, + offset: { main: 0, cross: 4 }, + expected: { left: 611, top: 110 }, + }, + ], + [ + 'cover above-center', + { + pos: 'above', + align: 'center', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 60, height: 20 }, + cover: true, + expected: { left: 470, top: 320 }, + }, + ], + [ + 'cover below-start', + { + pos: 'below', + align: 'start', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 60, height: 20 }, + cover: true, + expected: { left: 450, top: 300 }, + }, + ], + [ + 'cover before-end', + { + pos: 'before', + align: 'end', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 60, height: 20 }, + cover: true, + expected: { left: 490, top: 320 }, + }, + ], + [ + 'cover after-center', + { + pos: 'after', + align: 'center', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 60, height: 20 }, + cover: true, + expected: { left: 450, top: 310 }, + }, + ], + [ + 'cover larger than the anchor', + { + pos: 'above', + align: 'start', + anchor: { left: 730, top: 97, width: 118, height: 80 }, + popup: { width: 404, height: 149 }, + cover: true, + expected: { left: 596, top: 28 }, + }, + ], + [ + 'cover with offset', + { + pos: 'below', + align: 'center', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 60, height: 20 }, + offset: { main: 4, cross: 2 }, + cover: true, + expected: { left: 470, top: 304 }, + }, + ], + [ + 'a centered base also centers aligned fallbacks', + { + pos: 'below', + align: 'center', + anchor: { left: 80, top: 457, width: 187, height: 72 }, + popup: { width: 168, height: 215 }, + offset: { main: 8, cross: 4 }, + fallbacks: ['above-end', 'above'], + expected: { left: 89.5, top: 234 }, + }, + ], + [ + 'a pinned popup can overflow the start of the viewport', + { + pos: 'above', + align: 'center', + anchor: { left: 450, top: 20, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + pinned: true, + expected: { left: 400, top: -80 }, + }, + ], + [ + 'a pinned popup can overflow the end of the viewport', + { + pos: 'below', + align: 'center', + anchor: { left: 450, top: 640, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + pinned: true, + expected: { left: 400, top: 680 }, + }, + ], + [ + 'a pinned popup preserves a large offset', + { + pos: 'above', + align: 'center', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 200, height: 100 }, + pinned: true, + offset: { main: 1000, cross: 0 }, + expected: { left: 400, top: -800 }, + }, + ], + [ + 'an oversized popup stays above the anchor', + { + pos: 'above', + align: 'center', + anchor: { left: 450, top: 300, width: 100, height: 40 }, + popup: { width: 1200, height: 800 }, + expected: { left: 0, top: -500 }, + }, + ], +]; + +describe('computePlacement', () => { + it.each(scenarios)('%s', (_name, scenario) => { + const { pos, align, anchor, popup, offset, rtl, fallbacks, pinned, matchWidth, cover, expected } = scenario; + const isBlockMain = pos === 'above' || pos === 'below'; + + const { left, top } = computePlacement([ + anchor, + VIEWPORT, + matchWidth ? anchor.width : popup.width, + popup.height, + !!rtl, + { position: pos, align }, + (isBlockMain ? offset?.main : offset?.cross) ?? 0, + (isBlockMain ? offset?.cross : offset?.main) ?? 0, + undefined, + fallbacks?.map(toPlacement), + pinned, + cover, + ]); + + expect(left).toBeCloseTo(expected.left, 1); + expect(top).toBeCloseTo(expected.top, 1); + }); + + it('returns the resolved placement', () => { + const result = computePlacement([ + { left: 450, top: 60, width: 100, height: 40 }, + VIEWPORT, + 200, + 100, + false, + { position: 'above', align: 'center' }, + 0, + 0, + ]); + + expect(result).toMatchObject({ position: 'below', align: 'center' }); + }); +}); diff --git a/packages/react-components/react-positioning/library/src/engine/computePlacement.ts b/packages/react-components/react-positioning/library/src/engine/computePlacement.ts new file mode 100644 index 0000000000000..dba43f91b73aa --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/computePlacement.ts @@ -0,0 +1,303 @@ +import type { Position } from '../types'; + +const ABOVE = 'above'; +const BELOW = 'below'; +const BEFORE = 'before'; +const AFTER = 'after'; +const START = 'start'; +const CENTER = 'center'; +const END = 'end'; + +export type LogicalAlignment = 'start' | 'center' | 'end'; + +export interface PlacementRect { + left: number; + top: number; + width: number; + height: number; +} + +export interface Placement { + position: Position; + align: LogicalAlignment; +} + +export type PlacementInput = [ + /** Rect of the anchor, in the same coordinate space as `bounds` */ + anchor: PlacementRect, + /** Rect of the containing block of the positioned element, the box is kept inside of it */ + bounds: PlacementRect, + width: number, + height: number, + /** Direction of the containing block */ + rtl: boolean, + placement: Placement, + /** Margin on the block axis (physical `top` and `bottom`) */ + marginBlock: number, + /** Margin on the inline axis (physical `left` and `right`) */ + marginInline: number, + /** Rect used to know if a placement fits, defaults to `bounds` */ + flipBounds?: PlacementRect, + /** Placements to try when `placement` doesn't fit, `undefined` uses the default CSS fallbacks */ + fallbacks?: Placement[], + pinned?: boolean, + coverTarget?: boolean, + /** Margins that depend on the placement, replacing `marginBlock` and `marginInline` */ + getMargins?: (placement: Placement) => [marginBlock: number, marginInline: number], +]; + +export interface PlacementOutput extends Placement { + left: number; + top: number; +} + +type Region = [start: number, end: number]; + +/** One physical axis (`x` or `y`) of the anchor, the containing block and the box */ +type Axis = [ + anchorStart: number, + anchorSize: number, + anchorEnd: number, + bounds: Region, + flipBounds: Region, + extended: Region, + size: number, + margin: number, +]; + +const createAxis = ( + anchorStart: number, + anchorSize: number, + start: number, + size: number, + flipStart: number, + flipSize: number, + boxSize: number, + margin: number, +): Axis => { + const end = start + size; + const anchorEnd = anchorStart + anchorSize; + + return [ + anchorStart, + anchorSize, + anchorEnd, + [start, end], + [flipStart, flipStart + flipSize], + [Math.min(start, anchorStart), Math.max(end, anchorEnd)], + boxSize, + margin, + ]; +}; + +const isBlockAxis = (position: Position): boolean => position === ABOVE || position === BELOW; + +const flipPlacement = ({ position, align }: Placement, block: boolean): Placement => { + const flippedAlign = align === START ? END : align === END ? START : align; + + return { + position: block + ? position === ABOVE + ? BELOW + : position === BELOW + ? ABOVE + : position + : position === BEFORE + ? AFTER + : position === AFTER + ? BEFORE + : position, + align: isBlockAxis(position) === block ? align : flippedAlign, + }; +}; + +const clamp = (value: number, [start, end]: Region, size: number): number => + Math.min(Math.max(value, start), end - size); + +/** + * `anchor-center`: centers on the anchor and keeps the box inside the region when it fits. When it doesn't fit the box + * is kept inside the containing block instead (it can't overflow its start, and it's aligned to the start when it is larger). + */ +const anchorCenter = ([anchorStart, anchorSize, , , , extended, size, margin]: Axis, region: Region): number => { + const outer = size + 2 * margin; + const centered = anchorStart + anchorSize / 2 - outer / 2; + const fitsRegion = outer <= region[1] - region[0]; + + return ( + (fitsRegion || outer <= extended[1] - extended[0] + ? clamp(centered, fitsRegion ? region : extended, outer) + : extended[0]) + margin + ); +}; + +/** + * Position on the axis of the position (`above`, `before`, ...): next to the anchor, `low` is the side before the anchor. + */ +function placeMain(axis: Axis, low: boolean, centerSelf: boolean): [start: number, area: Region] { + const [anchorStart, , anchorEnd, , flipBounds, extended, size, margin] = axis; + const area: Region = low ? [flipBounds[0], anchorStart] : [anchorEnd, flipBounds[1]]; + + return [ + centerSelf + ? anchorCenter(axis, low ? [extended[0], anchorStart] : [anchorEnd, extended[1]]) + : low + ? anchorStart - margin - size + : anchorEnd + margin, + area, + ]; +} + +/** + * Position on the other axis, aligned to the `low` or `high` edge of the anchor or centered. + */ +function placeCross(axis: Axis, to: 'low' | 'high' | 'center', centerSelf: boolean): [start: number, area: Region] { + const [anchorStart, anchorSize, anchorEnd, , flipBounds, extended, size, margin] = axis; + + if (to === 'center') { + return [centerSelf ? anchorCenter(axis, extended) : anchorStart + anchorSize / 2 - size / 2, flipBounds]; + } + + const area: Region = to === 'low' ? [anchorStart, flipBounds[1]] : [flipBounds[0], anchorEnd]; + + return [ + // `place-self: anchor-center` also centers the aligned areas + centerSelf ? anchorCenter(axis, area) : to === 'low' ? anchorStart + margin : anchorEnd - margin - size, + area, + ]; +} + +/** + * Safe alignment: the box doesn't overflow the containing block when it fits inside it. + */ +const keepInside = ([, , , , , extended, size, margin]: Axis, start: number): number => { + const outer = size + 2 * margin; + return outer <= extended[1] - extended[0] + ? clamp(start - margin, extended, outer) + margin + : Math.max(start, extended[0] + margin); +}; + +const fitsInside = (start: number, area: Region, [, , , , , , size, margin]: Axis) => + start - margin >= area[0] - 0.01 && start + size + margin <= area[1] + 0.01; + +function place( + x: Axis, + y: Axis, + { position, align }: Placement, + rtl: boolean, + centerSelf: boolean, + pinned: boolean, +): [left: number, top: number, fits: boolean] { + const blockMain = isBlockAxis(position); + // `low` is the left or top side + const low = blockMain ? position === ABOVE : (position === BEFORE) !== rtl; + const mainAxis = blockMain ? y : x; + const crossAxis = blockMain ? x : y; + const mainFits = mainAxis[6] + 2 * mainAxis[7] <= mainAxis[5][1] - mainAxis[5][0]; + const main = placeMain(mainAxis, low, centerSelf && !pinned && mainFits); + // Left and right are the physical sides, `start` of the inline axis is on the right for `rtl` + const cross = blockMain + ? placeCross( + crossAxis, + align === CENTER ? 'center' : (align === START) !== rtl ? 'low' : 'high', + centerSelf && !pinned, + ) + : placeCross(crossAxis, align === CENTER ? 'center' : align === START ? 'low' : 'high', centerSelf && !pinned); + + // Oversized popups must stay beside the anchor rather than covering it. + if (!pinned) { + if (mainFits) { + main[0] = keepInside(mainAxis, main[0]); + } + cross[0] = keepInside(crossAxis, cross[0]); + } + const fits = fitsInside(main[0], main[1], mainAxis) && fitsInside(cross[0], cross[1], crossAxis); + + return blockMain ? [cross[0], main[0], fits] : [main[0], cross[0], fits]; +} + +/** + * Cover the target: the box is aligned inside the anchor and kept inside the containing block. + */ +function placeOverTarget(x: Axis, y: Axis, { position, align }: Placement, rtl: boolean) { + const alignInside = ([anchorStart, anchorSize, , , , extended, size, margin]: Axis, to: LogicalAlignment) => { + const start = + to === START + ? anchorStart + margin + : to === END + ? anchorStart + anchorSize - margin - size + : anchorStart + anchorSize / 2 - size / 2; + const outer = size + 2 * margin; + + return outer <= extended[1] - extended[0] ? clamp(start - margin, extended, outer) + margin : start; + }; + const blockMain = isBlockAxis(position); + // Left and right are the physical sides, `start` of the inline axis is on the right for `rtl` + const inline = align === CENTER ? align : (align === START) !== rtl ? START : END; + + return [ + alignInside(x, blockMain ? inline : (position === BEFORE) !== rtl ? END : START), + alignInside(y, blockMain ? (position === ABOVE ? END : START) : align), + ] as const; +} + +/** + * Computes the position of an element the way CSS anchor positioning (`position-area` + `position-try-fallbacks`) does: + * the requested placement is used when it fits, otherwise the fallbacks are tried in order (first fit wins). + */ +export function computePlacement(input: PlacementInput): PlacementOutput { + const [ + anchor, + bounds, + width, + height, + rtl, + placement, + marginBlock, + marginInline, + flipBounds, + fallbacks, + pinned, + coverTarget, + getMargins, + ] = input; + const flip = flipBounds ?? bounds; + const createAxes = ([blockMargin, inlineMargin] = [marginBlock, marginInline]) => [ + createAxis(anchor.left, anchor.width, bounds.left, bounds.width, flip.left, flip.width, width, inlineMargin), + createAxis(anchor.top, anchor.height, bounds.top, bounds.height, flip.top, flip.height, height, blockMargin), + ]; + + if (coverTarget) { + const [x, y] = createAxes(getMargins?.(placement)); + const [left, top] = placeOverTarget(x, y, placement, rtl); + return { ...placement, left, top }; + } + + // Only the requested placement is centered on the anchor on both axes (`place-self: anchor-center`) + const centerSelf = placement.align === CENTER; + const candidates = pinned + ? [placement] + : [ + placement, + ...(fallbacks ?? [ + flipPlacement(placement, true), + flipPlacement(placement, false), + flipPlacement(flipPlacement(placement, true), false), + ]), + ]; + + let first: PlacementOutput | undefined; + + for (const candidate of candidates) { + const [x, y] = createAxes(getMargins?.(candidate)); + const [left, top, fits] = place(x, y, candidate, rtl, centerSelf, !!pinned); + const output = { ...candidate, left, top }; + first ??= output; + + if (fits) { + return output; + } + } + + // Nothing fits, CSS keeps the requested placement + return first as PlacementOutput; +} diff --git a/packages/react-components/react-positioning/library/src/engine/computePosition.ts b/packages/react-components/react-positioning/library/src/engine/computePosition.ts new file mode 100644 index 0000000000000..bb244cebe5568 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/computePosition.ts @@ -0,0 +1,290 @@ +import type { ComputePositionReturn, ElementRects, Padding, Placement, Rect, Side, Strategy } from '@floating-ui/dom'; +import type { Alignment, Position, PositioningBoundary, PositioningOptions, TargetElement } from '../types'; +import { fromFloatingUIPlacement, getBoundary, toFloatingUIPadding, toFloatingUIPlacement } from '../utils'; +import { getAutoSizeDimensions } from '../utils/normalizeAutoSize'; +import { getClippingRect, getCoordinateSpace, getDimensions, getElementRects, getOffsetParent } from './platform'; + +type Options = { + placement?: Placement; + strategy: Strategy; + positioningOptions?: PositioningOptions; + isRtl?: boolean; + arrow?: HTMLElement | null; +}; +const opposite: Record = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }; +const sideOf = (placement: Placement) => placement.split('-')[0] as Side; +const alignOf = (placement: Placement) => placement.split('-')[1]; +const vertical = (placement: Placement) => /^(top|bottom)/.test(placement); +const clamp = (min: number, value: number, max: number) => Math.max(min, Math.min(value, max)); +const paddingOf = (padding: Padding = 0) => + typeof padding === 'number' + ? { top: padding, right: padding, bottom: padding, left: padding } + : { top: 0, right: 0, bottom: 0, left: 0, ...padding }; + +function coordinates({ reference: r, floating: f }: ElementRects, placement: Placement, rtl: boolean) { + const side = sideOf(placement); + const coords = { x: r.x + r.width / 2 - f.width / 2, y: r.y + r.height / 2 - f.height / 2 }; + if (side === 'top') { + coords.y = r.y - f.height; + } else if (side === 'bottom') { + coords.y = r.y + r.height; + } else { + coords.x = side === 'left' ? r.x - f.width : r.x + r.width; + } + const axis = vertical(placement) ? 'x' : 'y'; + const length = axis === 'x' ? 'width' : 'height'; + if (alignOf(placement)) { + coords[axis] += + (r[length] / 2 - f[length] / 2) * (rtl && axis === 'x' ? -1 : 1) * (alignOf(placement) === 'start' ? -1 : 1); + } + return coords; +} + +/** + * Fluent-specific pipeline. Measurements and all coordinates use the floating + * containing block's CSS pixels; clipping rectangles use viewport pixels. + */ +export async function computePosition( + target: TargetElement, + container: HTMLElement, + { placement: initial = 'bottom', strategy, positioningOptions: options = {}, isRtl = false, arrow }: Options, +): Promise { + const { + autoSize: rawAutoSize, + offset, + matchTargetSize, + coverTarget, + pinned, + flipBoundary, + overflowBoundary, + overflowBoundaryPadding, + fallbackPositions, + arrowPadding = 0, + shiftToCoverTarget, + unstable_disableTether: disableTether, + } = options; + const css = container.style; + const autoSize = getAutoSizeDimensions(rawAutoSize); + for (const dimension of autoSize) { + css.removeProperty('box-sizing'); + css.removeProperty(`max-${dimension}`); + css.removeProperty(dimension); + } + const { + x: originX, + y: originY, + scale: { x: scaleX, y: scaleY }, + } = getCoordinateSpace(container, strategy); + const rtl = container.ownerDocument.defaultView!.getComputedStyle(container).direction === 'rtl'; + const referenceElement = + 'nodeType' in target ? target : target.contextElement || container.ownerDocument.documentElement; + const clip = (element: Element, boundary?: PositioningBoundary) => + getClippingRect(element, getBoundary(container, boundary) || 'clippingAncestors'); + const flipClip = clip(flipBoundary ? referenceElement : container, flipBoundary || undefined); + const shiftClip = clip(overflowBoundary ? referenceElement : container, overflowBoundary || undefined); + const visibilityClip = clip(referenceElement); + const padding = paddingOf(toFloatingUIPadding(overflowBoundaryPadding || 0, isRtl)); + const overflow = (rect: Rect, bounds: Rect, inset = paddingOf()) => ({ + top: (bounds.y - (rect.y * scaleY + originY) + inset.top) / scaleY, + bottom: ((rect.y + rect.height) * scaleY + originY - bounds.y - bounds.height + inset.bottom) / scaleY, + left: (bounds.x - (rect.x * scaleX + originX) + inset.left) / scaleX, + right: ((rect.x + rect.width) * scaleX + originX - bounds.x - bounds.width + inset.right) / scaleX, + }); + const oppositePlacement = (p: Placement): Placement => + p.replace(/top|bottom|left|right/, side => opposite[side as Side]) as Placement; + const oppositeAlignment = (p: Placement) => + p.replace(/start|end/, value => (value === 'start' ? 'end' : 'start')) as Placement; + const fallbacks = (fallbackPositions || []).map(shorthand => { + const [position, align] = shorthand.split('-') as [Position, Alignment?]; + return toFloatingUIPlacement(align, position, isRtl)!; + }); + const candidates = [ + initial, + ...(fallbacks.length + ? fallbacks + : alignOf(initial) + ? [oppositeAlignment(initial), oppositePlacement(initial), oppositeAlignment(oppositePlacement(initial))] + : [oppositePlacement(initial)]), + ]; + const attempts: [placement: Placement, overflows: number[]][] = []; + let candidateIndex = 0; + let placement = initial; + let matchAttempt = false; + let arrowAdjusted = false; + let arrowData: { x?: number; y?: number; centerOffset: number } = { centerOffset: 0 }; + let coords = { x: 0, y: 0 }; + const measure = () => getElementRects({ reference: target, floating: container, strategy }); + let rects = measure(); + let preserveCoordinates = false; + let delta = { x: 0, y: 0 }; + + for (let attempt = 0; attempt <= 50; attempt++) { + const { reference: r, floating: f } = rects; + const isVertical = vertical(placement); + const side = sideOf(placement); + const align = alignOf(placement); + const main = isVertical ? 'x' : 'y'; + const cross = main === 'x' ? 'y' : 'x'; + if (!preserveCoordinates) { + coords = coordinates(rects, placement, rtl); + } + preserveCoordinates = false; + if (matchTargetSize && r.width !== f.width && !matchAttempt) { + css.setProperty('--fui-match-target-size', `${r.width}px`); + if (!css.width) { + css.width = 'var(--fui-match-target-size)'; + } + matchAttempt = true; + rects = measure(); + continue; + } + + if (offset) { + const { position, alignment } = fromFloatingUIPlacement(placement); + const raw = + typeof offset === 'function' ? offset({ positionedRect: f, targetRect: r, position, alignment }) : offset; + const value = typeof raw === 'number' ? { mainAxis: raw } : raw; + const mainOffset = (value.mainAxis || 0) * (/^(top|left)/.test(placement) ? -1 : 1); + const crossOffset = (value.crossAxis || 0) * (rtl && isVertical ? -1 : 1); + if (!arrowAdjusted) { + delta = { x: isVertical ? crossOffset : mainOffset, y: isVertical ? mainOffset : crossOffset }; + coords.x += delta.x; + coords.y += delta.y; + } + } + if (coverTarget) { + coords[cross] += (side === 'top' || side === 'left' ? 1 : -1) * r[isVertical ? 'height' : 'width']; + } + if (!pinned && !arrowAdjusted) { + const values = overflow({ ...f, ...coords }, flipClip); + const length = isVertical ? 'width' : 'height'; + let alignSide: Side = isVertical + ? align === (rtl ? 'end' : 'start') + ? 'right' + : 'left' + : align === 'start' + ? 'bottom' + : 'top'; + if (r[length] > f[length]) { + alignSide = opposite[alignSide]; + } + const overflows = [values[side], values[alignSide], values[opposite[alignSide]]]; + attempts.push([placement, overflows]); + if (overflows.some(value => value > 0)) { + const next = candidates[++candidateIndex]; + if (next) { + placement = next; + continue; + } + const fitting = attempts.filter(item => item[1][0] <= 0).sort((a, b) => a[1][1] - b[1][1]); + const score = (overflowsToScore: number[]) => + overflowsToScore.reduce((sum, value) => sum + Math.max(0, value), 0); + const best = (fitting[0] || attempts.slice().sort((a, b) => score(a[1]) - score(b[1]))[0])[0]; + if (best !== placement) { + placement = best; + continue; + } + } + } + const values = overflow({ ...f, ...coords }, shiftClip, padding); + const shiftCross = disableTether ? disableTether === 'all' : !!shiftToCoverTarget; + coords[main] = clamp( + coords[main] + values[main === 'x' ? 'left' : 'top'], + coords[main], + coords[main] - values[main === 'x' ? 'right' : 'bottom'], + ); + if (shiftCross) { + coords[cross] = clamp( + coords[cross] + values[cross === 'x' ? 'left' : 'top'], + coords[cross], + coords[cross] - values[cross === 'x' ? 'right' : 'bottom'], + ); + if (disableTether !== 'all') { + const length = cross === 'x' ? 'width' : 'height'; + const origin = side === 'top' || side === 'left'; + coords[cross] = clamp( + r[cross] - f[length] + (origin ? delta[cross] : 0), + coords[cross], + r[cross] + r[length] + (origin ? 0 : delta[cross]), + ); + } + } + if (autoSize.length) { + const available = overflow({ ...f, ...coords }, shiftClip, padding); + const maxWidth = Math.max(0, f.width - available.left - available.right); + const maxHeight = Math.max(0, f.height - available.top - available.bottom); + const widthSide = isVertical ? (align === (rtl ? 'start' : 'end') ? 'left' : 'right') : side; + const heightSide = isVertical ? side : align === 'end' ? 'top' : 'bottom'; + const width = Math.max( + 0, + main === 'x' || shiftCross ? maxWidth : Math.min(f.width - available[widthSide], maxWidth), + ); + const height = Math.max( + 0, + main === 'y' || shiftCross ? maxHeight : Math.min(f.height - available[heightSide], maxHeight), + ); + for (const dimension of autoSize) { + const size = dimension === 'width' ? width : height; + css.setProperty('box-sizing', 'border-box'); + css.setProperty(`max-${dimension}`, `${size}px`); + if (f[dimension] > size) { + css.setProperty(dimension, `${size}px`); + const property = `overflow-${dimension === 'width' ? 'x' : 'y'}`; + if (!css.getPropertyValue(property)) { + css.setProperty(property, 'auto'); + } + } + } + const dimensions = getDimensions(container); + if (f.width !== dimensions.width || f.height !== dimensions.height) { + rects = measure(); + continue; + } + } + if (arrow) { + const length = main === 'x' ? 'width' : 'height'; + const client = main === 'x' ? 'clientWidth' : 'clientHeight'; + const dimensions = getDimensions(arrow); + const parent = getOffsetParent(arrow); + const clientSize = parent?.[client] || container[client] || f[length]; + const largest = clientSize / 2 - dimensions[length] / 2 - 1; + const inset = Math.min(arrowPadding, largest); + const max = clientSize - dimensions[length] - inset; + const startDiff = coords[main] - r[main]; + const endDiff = r[length] + r[main] - coords[main] - f[length]; + const center = clientSize / 2 - dimensions[length] / 2 + endDiff / 2 - startDiff / 2; + const arrowOffset = clamp(inset, center, max); + arrowData = { [main]: arrowOffset, centerOffset: center - arrowOffset }; + if (!arrowAdjusted && align && center !== arrowOffset && r[length] / 2 - inset - dimensions[length] / 2 < 0) { + coords[main] += center - arrowOffset; + arrowAdjusted = true; + preserveCoordinates = true; + continue; + } + } + break; + } + const floatingOverflow = overflow({ ...rects.floating, ...coords }, visibilityClip); + const referenceOverflow = overflow(rects.reference, visibilityClip); + const isHidden = (values: Record, rect: Rect) => + (Object.keys(opposite) as Side[]).some( + side => values[side] >= (/top|bottom/.test(side) ? rect.height : rect.width), + ); + return { + ...coords, + placement, + strategy, + middlewareData: { + arrow: arrowData, + hide: { + referenceHidden: isHidden(referenceOverflow, rects.reference), + escaped: isHidden(floatingOverflow, rects.floating), + }, + intersectionObserver: { + intersecting: + (floatingOverflow.top > 0 && floatingOverflow.top < rects.floating.height) || + (floatingOverflow.bottom > 0 && floatingOverflow.bottom < rects.floating.height), + }, + }, + }; +} diff --git a/packages/react-components/react-positioning/library/src/engine/containingBlock.ts b/packages/react-components/react-positioning/library/src/engine/containingBlock.ts new file mode 100644 index 0000000000000..f088b2a62ce0c --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/containingBlock.ts @@ -0,0 +1,32 @@ +const FORMS_CONTAINING_BLOCK = /transform|perspective|filter/; + +// Properties that are not supported are empty strings (i.e. in jsdom) +const isSet = (value: string | undefined): boolean => !!value && value !== 'none'; + +export function isContainingBlock(style: CSSStyleDeclaration, strategy: 'absolute' | 'fixed'): boolean { + return ( + (strategy === 'absolute' && !!style.position && style.position !== 'static') || + isSet(style.transform) || + isSet(style.perspective) || + isSet(style.filter) || + isSet(style.backdropFilter) || + (!!style.containerType && style.containerType !== 'normal') || + FORMS_CONTAINING_BLOCK.test(style.willChange) || + /paint|layout|strict|content/.test(style.contain) + ); +} + +/** + * Finds the element that forms the containing block of the positioned element, + * `null` is the viewport (`fixed`) or the initial containing block (`absolute`). + */ +export function getContainingBlock(container: HTMLElement, strategy: 'absolute' | 'fixed'): HTMLElement | null { + const win = container.ownerDocument.defaultView!; + const root = container.ownerDocument.documentElement; + for (let node = container.parentElement; node && node !== root; node = node.parentElement) { + if (isContainingBlock(win.getComputedStyle(node), strategy)) { + return node; + } + } + return null; +} diff --git a/packages/react-components/react-positioning/library/src/engine/geometry.test.ts b/packages/react-components/react-positioning/library/src/engine/geometry.test.ts new file mode 100644 index 0000000000000..8b42fd7d89555 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/geometry.test.ts @@ -0,0 +1,56 @@ +import { getScrollAncestors } from './geometry'; + +describe('getScrollAncestors', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it.each(['absolute', 'fixed'])('ignores overflow outside a %s element containing block', position => { + const ancestor = document.createElement('div'); + ancestor.style.overflow = 'hidden'; + const element = document.createElement('div'); + element.style.position = position; + ancestor.appendChild(element); + document.body.appendChild(ancestor); + + expect(getScrollAncestors(element)).toEqual([]); + }); + + it('includes overflow on a transformed fixed-position containing block', () => { + const ancestor = document.createElement('div'); + ancestor.style.overflow = 'hidden'; + ancestor.style.transform = 'translateZ(0)'; + const element = document.createElement('div'); + element.style.position = 'fixed'; + ancestor.appendChild(element); + document.body.appendChild(ancestor); + + expect(getScrollAncestors(element)).toEqual([ancestor]); + }); + + it('ignores non-containing overflow ancestors of a fixed-position parent', () => { + const ancestor = document.createElement('div'); + ancestor.style.overflow = 'auto'; + const fixed = document.createElement('div'); + fixed.style.position = 'fixed'; + const element = document.createElement('button'); + fixed.appendChild(element); + ancestor.appendChild(fixed); + document.body.appendChild(ancestor); + + expect(getScrollAncestors(element)).toEqual([]); + }); + + it('includes nested overflow ancestors of an in-flow element', () => { + const outer = document.createElement('div'); + outer.style.overflow = 'hidden'; + const inner = document.createElement('div'); + inner.style.overflow = 'auto'; + const element = document.createElement('button'); + inner.appendChild(element); + outer.appendChild(inner); + document.body.appendChild(outer); + + expect(getScrollAncestors(element)).toEqual([inner, outer]); + }); +}); diff --git a/packages/react-components/react-positioning/library/src/engine/geometry.ts b/packages/react-components/react-positioning/library/src/engine/geometry.ts new file mode 100644 index 0000000000000..9adb992a91ea3 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/geometry.ts @@ -0,0 +1,71 @@ +import { isContainingBlock } from './containingBlock'; + +export interface PlacementRect { + left: number; + top: number; + width: number; + height: number; +} + +const OVERFLOW = /auto|scroll|overlay|hidden|clip/; + +export const intersect = (a: PlacementRect, b: PlacementRect): PlacementRect => { + const left = Math.max(a.left, b.left); + const top = Math.max(a.top, b.top); + + return { + left, + top, + width: Math.min(a.left + a.width, b.left + b.width) - left, + height: Math.min(a.top + a.height, b.top + b.height) - top, + }; +}; + +export const getViewportRect = (element: Element): PlacementRect => { + const { clientWidth, clientHeight } = element.ownerDocument.documentElement; + return { left: 0, top: 0, width: clientWidth, height: clientHeight }; +}; + +export const getPaddingBox = (element: HTMLElement): PlacementRect => { + const { left, top } = element.getBoundingClientRect(); + return { + left: left + element.clientLeft, + top: top + element.clientTop, + width: element.clientWidth, + height: element.clientHeight, + }; +}; + +/** The ancestors that can clip the element, the closest first */ +export const getScrollAncestors = (element: Element): HTMLElement[] => { + const win = element.ownerDocument.defaultView; + const root = element.ownerDocument.documentElement; + const ancestors: HTMLElement[] = []; + const position = win?.getComputedStyle(element).position; + let strategy: 'absolute' | 'fixed' | undefined = + position === 'fixed' || position === 'absolute' ? position : undefined; + + for (let node = element.parentElement; win && node && node !== root; node = node.parentElement) { + const style = win.getComputedStyle(node); + const { overflow, overflowX, overflowY } = style; + + // Positioned descendants escape overflow between themselves and their containing block. + if (strategy && isContainingBlock(style, strategy)) { + strategy = undefined; + } + if (!strategy && OVERFLOW.test(overflow + overflowX + overflowY)) { + ancestors.push(node); + } + if (style.position === 'fixed' || style.position === 'absolute') { + strategy = style.position; + } + } + + return ancestors; +}; + +export const getClippingRect = (element: Element): PlacementRect => + getScrollAncestors(element).reduce( + (rect, ancestor) => intersect(rect, getPaddingBox(ancestor)), + getViewportRect(element), + ); diff --git a/packages/react-components/react-positioning/library/src/engine/index.ts b/packages/react-components/react-positioning/library/src/engine/index.ts new file mode 100644 index 0000000000000..39f06549b0127 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/index.ts @@ -0,0 +1,221 @@ +import type { + DetectOverflowOptions, + FlipOptions, + LimitShiftOptions, + Middleware, + MiddlewareState, + OffsetOptions, + Padding, + Placement, + ReferenceElement, + Side, + ShiftOptions, + SizeOptions, +} from '@floating-ui/dom'; + +const opposite: Record = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }; +const sideOf = (placement: Placement) => placement.split('-')[0] as Side; +const alignOf = (placement: Placement) => placement.split('-')[1]; +const vertical = (placement: Placement) => /^(top|bottom)/.test(placement); +const clamp = (min: number, value: number, max: number) => Math.max(min, Math.min(value, max)); +const paddingOf = (padding: Padding = 0) => + typeof padding === 'number' + ? { top: padding, right: padding, bottom: padding, left: padding } + : { top: 0, right: 0, bottom: 0, left: 0, ...padding }; + +export async function detectOverflow( + state: MiddlewareState, + options: DetectOverflowOptions = {}, +): Promise> { + const { rects, elements, platform, strategy } = state; + const { + elementContext = 'floating', + altBoundary = false, + boundary = 'clippingAncestors', + rootBoundary = 'viewport', + } = options; + const context = altBoundary ? (elementContext === 'floating' ? 'reference' : 'floating') : elementContext; + const element = elements[context]; + const clippingElement = (await platform.isElement?.(element)) + ? (element as Element) + : (element as ReferenceElement & { contextElement?: Element }).contextElement ?? + elements.floating.ownerDocument.documentElement; + const clip = await platform.getClippingRect({ element: clippingElement, boundary, rootBoundary, strategy }); + const parent = await platform.getOffsetParent?.(elements.floating); + const scale = + parent && (await platform.isElement?.(parent)) && platform.getScale + ? await platform.getScale(parent) + : { x: 1, y: 1 }; + const local = + elementContext === 'reference' + ? rects.reference + : { x: state.x, y: state.y, width: rects.floating.width, height: rects.floating.height }; + const rect = platform.convertOffsetParentRelativeRectToViewportRelativeRect + ? await platform.convertOffsetParentRelativeRectToViewportRelativeRect({ + elements, + rect: local, + offsetParent: parent, + strategy, + }) + : local; + const padding = paddingOf(options.padding); + return { + top: (clip.y - rect.y + padding.top) / scale.y, + bottom: (rect.y + rect.height - clip.y - clip.height + padding.bottom) / scale.y, + left: (clip.x - rect.x + padding.left) / scale.x, + right: (rect.x + rect.width - clip.x - clip.width + padding.right) / scale.x, + }; +} + +export function offset(options: OffsetOptions = 0): Middleware { + return { + name: 'offset', + async fn(state) { + const raw = typeof options === 'function' ? options(state) : options; + const value = typeof raw === 'number' ? { mainAxis: raw } : raw; + let cross = value.crossAxis || 0; + if (alignOf(state.placement) && typeof value.alignmentAxis === 'number') { + cross = value.alignmentAxis * (alignOf(state.placement) === 'end' ? -1 : 1); + } + const isVertical = vertical(state.placement); + const rtl = await state.platform.isRTL?.(state.elements.floating); + const main = (value.mainAxis || 0) * (/^(top|left)/.test(state.placement) ? -1 : 1); + cross *= rtl && isVertical ? -1 : 1; + if (state.middlewareData.offset?.placement === state.placement && state.middlewareData.arrow?.alignmentOffset) { + return {}; + } + const delta = { x: isVertical ? cross : main, y: isVertical ? main : cross }; + return { x: state.x + delta.x, y: state.y + delta.y, data: { ...delta, placement: state.placement } }; + }, + }; +} + +export function flip(options: FlipOptions = {}): Middleware { + return { + name: 'flip', + async fn(state) { + if (state.middlewareData.arrow?.alignmentOffset) { + return {}; + } + const initial = state.initialPlacement; + const align = alignOf(initial); + const oppositePlacement = (p: Placement): Placement => + `${opposite[sideOf(p)]}${alignOf(p) ? `-${alignOf(p)}` : ''}` as Placement; + const oppositeAlignment = (p: Placement): Placement => + p.replace(/start|end/, match => (match === 'start' ? 'end' : 'start')) as Placement; + const candidates = [ + initial, + ...(options.fallbackPlacements ?? + (align + ? [oppositeAlignment(initial), oppositePlacement(initial), oppositeAlignment(oppositePlacement(initial))] + : [oppositePlacement(initial)])), + ]; + const overflow = await detectOverflow(state, options); + const isVertical = vertical(state.placement); + const rtl = await state.platform.isRTL?.(state.elements.floating); + const length = isVertical ? 'width' : 'height'; + let alignmentSide: Side = isVertical + ? alignOf(state.placement) === (rtl ? 'end' : 'start') + ? 'right' + : 'left' + : alignOf(state.placement) === 'start' + ? 'bottom' + : 'top'; + if (state.rects.reference[length] > state.rects.floating[length]) { + alignmentSide = opposite[alignmentSide]; + } + const overflows = [overflow[sideOf(state.placement)], overflow[alignmentSide], overflow[opposite[alignmentSide]]]; + const attempts: { placement: Placement; overflows: number[] }[] = [ + ...(state.middlewareData.flip?.overflows ?? []), + { placement: state.placement, overflows }, + ]; + if (overflows.every(value => value <= 0)) { + return {}; + } + const index = (state.middlewareData.flip?.index ?? 0) + 1; + if (candidates[index]) { + return { data: { index, overflows: attempts }, reset: { placement: candidates[index] } }; + } + const fitting = attempts.filter(item => item.overflows[0] <= 0).sort((a, b) => a.overflows[1] - b.overflows[1]); + const score = (values: number[]) => values.reduce((sum, value) => sum + Math.max(0, value), 0); + const best = fitting[0] ?? attempts.sort((a, b) => score(a.overflows) - score(b.overflows))[0]; + return best.placement === state.placement ? {} : { reset: { placement: best.placement } }; + }, + }; +} + +export function limitShift(options: LimitShiftOptions = {}): NonNullable { + return { + fn(state) { + const coords = { x: state.x, y: state.y }; + const main = vertical(state.placement) ? 'x' : 'y'; + const cross = main === 'x' ? 'y' : 'x'; + const { reference: r, floating: f } = state.rects; + if (options.mainAxis !== false) { + const length = main === 'x' ? 'width' : 'height'; + coords[main] = clamp(r[main] - f[length], coords[main], r[main] + r[length]); + } + if (options.crossAxis !== false) { + const length = cross === 'x' ? 'width' : 'height'; + const origin = /^(top|left)/.test(state.placement); + const delta = state.middlewareData.offset?.[cross] || 0; + coords[cross] = clamp( + r[cross] - f[length] + (origin ? delta : 0), + coords[cross], + r[cross] + r[length] + (origin ? 0 : delta), + ); + } + return coords; + }, + }; +} + +export function shift(options: ShiftOptions = {}): Middleware { + return { + name: 'shift', + async fn(state) { + const overflow = await detectOverflow(state, options); + const coords = { x: state.x, y: state.y }; + const main = vertical(state.placement) ? 'x' : 'y'; + const cross = main === 'x' ? 'y' : 'x'; + const enabled = { [main]: options.mainAxis !== false, [cross]: !!options.crossAxis }; + for (const axis of ['x', 'y'] as const) { + if (enabled[axis]) { + coords[axis] = clamp( + coords[axis] + overflow[axis === 'x' ? 'left' : 'top'], + coords[axis], + coords[axis] - overflow[axis === 'x' ? 'right' : 'bottom'], + ); + } + } + const limited = options.limiter?.fn({ ...state, ...coords }) ?? coords; + return { ...limited, data: { x: limited.x - state.x, y: limited.y - state.y, enabled } }; + }, + }; +} + +export function size(options: SizeOptions): Middleware { + return { + name: 'size', + async fn(state) { + const overflow = await detectOverflow(state, options); + const side = sideOf(state.placement); + const align = alignOf(state.placement); + const rtl = await state.platform.isRTL?.(state.elements.floating); + const { width, height } = state.rects.floating; + const widthSide = vertical(state.placement) ? (align === (rtl ? 'start' : 'end') ? 'left' : 'right') : side; + const heightSide = vertical(state.placement) ? side : align === 'end' ? 'top' : 'bottom'; + const maxWidth = width - overflow.left - overflow.right; + const maxHeight = height - overflow.top - overflow.bottom; + const availableWidth = state.middlewareData.shift?.enabled.x + ? maxWidth + : Math.min(width - overflow[widthSide], maxWidth); + const availableHeight = state.middlewareData.shift?.enabled.y + ? maxHeight + : Math.min(height - overflow[heightSide], maxHeight); + await options.apply?.({ ...state, availableWidth, availableHeight }); + const dimensions = await state.platform.getDimensions(state.elements.floating); + return width !== dimensions.width || height !== dimensions.height ? { reset: { rects: true } } : {}; + }, + }; +} diff --git a/packages/react-components/react-positioning/library/src/engine/offset.ts b/packages/react-components/react-positioning/library/src/engine/offset.ts new file mode 100644 index 0000000000000..798d08bfe51de --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/offset.ts @@ -0,0 +1,45 @@ +import type { Alignment, Offset, Position } from '../types'; +import type { Placement, PlacementInput } from './computePlacement'; + +export type ResolvedOffset = [mainAxis: number, crossAxis: number]; + +/** + * Number and object offsets. Function offsets are resolved for every placement by `applyFunctionOffset()`. + */ +export function resolveOffset(offset: Offset | undefined): ResolvedOffset { + if (typeof offset === 'number') { + return [offset, 0]; + } + + if (offset && typeof offset === 'object') { + return [offset.mainAxis ?? 0, offset.crossAxis ?? 0]; + } + + return [0, 0]; +} + +/** + * Handles `offset` when it's a function: it's called with every placement that is tried. + */ +export function applyFunctionOffset(input: PlacementInput, offset: Offset | undefined): void { + if (typeof offset !== 'function') { + return; + } + + const [anchor, , width, height] = input; + + input[12] = ({ position, align }: Placement) => { + const blockMain = position === 'above' || position === 'below'; + const alignment: Alignment = blockMain ? align : align === 'start' ? 'top' : align === 'end' ? 'bottom' : align; + const [mainAxis, crossAxis] = resolveOffset( + offset({ + positionedRect: { x: 0, y: 0, width, height }, + targetRect: { x: anchor.left, y: anchor.top, width: anchor.width, height: anchor.height }, + position: position as Position, + alignment, + }), + ); + + return blockMain ? [mainAxis, crossAxis] : [crossAxis, mainAxis]; + }; +} diff --git a/packages/react-components/react-positioning/library/src/engine/placement.ts b/packages/react-components/react-positioning/library/src/engine/placement.ts new file mode 100644 index 0000000000000..9681b2681b1e0 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/placement.ts @@ -0,0 +1,30 @@ +import type { Alignment, Position, PositioningPlacement } from '../types'; +import type { LogicalAlignment, Placement } from './computePlacement'; + +/** + * Alignments that don't apply to the axis of the position are ignored: + * `above` + `top` is centered, `before` + `start` is centered. + */ +export function toPlacement(position: Position = 'below', align: Alignment = 'center'): Placement { + const positionedVertically = position === 'above' || position === 'below'; + let logical: LogicalAlignment = 'center'; + + if (positionedVertically) { + logical = align === 'start' || align === 'end' ? align : 'center'; + } else if (align === 'top' || align === 'bottom') { + logical = align === 'top' ? 'start' : 'end'; + } + + return { position, align: logical }; +} + +/** + * The physical placement that is exposed in `data-popper-placement` and in `onPositioningEnd`. + * `before` and `after` swap sides in RTL, the alignment (`start`, `end`) stays logical. + */ +export function toPositioningPlacement({ position, align }: Placement, rtl: boolean): PositioningPlacement { + const side = + position === 'above' ? 'top' : position === 'below' ? 'bottom' : (position === 'before') !== rtl ? 'left' : 'right'; + + return (align === 'center' ? side : `${side}-${align}`) as PositioningPlacement; +} diff --git a/packages/react-components/react-positioning/library/src/engine/platform.ts b/packages/react-components/react-positioning/library/src/engine/platform.ts new file mode 100644 index 0000000000000..047926b9f9196 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/platform.ts @@ -0,0 +1,115 @@ +import { isHTMLElement } from '@fluentui/react-utilities'; +import type { Boundary, ElementRects, Platform, Rect, Strategy } from '@floating-ui/dom'; +import { getContainingBlock } from './containingBlock'; +import { getScrollAncestors, intersect } from './geometry'; + +const style = (element: Element) => element.ownerDocument.defaultView!.getComputedStyle(element); + +function getCssDimensions(element: Element) { + const css = style(element); + let width = parseFloat(css.width) || 0; + let height = parseFloat(css.height) || 0; + const rounded = + isHTMLElement(element) && + (Math.round(width) !== element.offsetWidth || Math.round(height) !== element.offsetHeight); + if (rounded) { + width = element.offsetWidth; + height = element.offsetHeight; + } + return { width, height, rounded }; +} + +export function getDimensions(element: Element): { width: number; height: number } { + const { width, height } = getCssDimensions(element); + return { width, height }; +} + +export function getScale(element: HTMLElement): { x: number; y: number } { + const rect = element.getBoundingClientRect(); + const { width, height, rounded } = getCssDimensions(element); + const x = (rounded ? Math.round(rect.width) : rect.width) / width; + const y = (rounded ? Math.round(rect.height) : rect.height) / height; + return { x: x && Number.isFinite(x) ? x : 1, y: y && Number.isFinite(y) ? y : 1 }; +} + +export function getOffsetParent(element: HTMLElement): HTMLElement | null { + const strategy = style(element).position === 'fixed' ? 'fixed' : 'absolute'; + let parent = strategy === 'fixed' ? null : element.offsetParent; + while (isHTMLElement(parent) && /^(TABLE|TD|TH)$/.test(parent.tagName) && style(parent).position === 'static') { + parent = parent.offsetParent; + } + if (isHTMLElement(parent)) { + if (!/^(HTML|BODY)$/.test(parent.tagName) || style(parent).position !== 'static') { + return parent; + } + } + return getContainingBlock(element, strategy); +} + +export function getCoordinateSpace( + element: HTMLElement, + strategy: Strategy, +): { x: number; y: number; scale: { x: number; y: number } } { + const parent = getOffsetParent(element); + if (parent) { + const rect = parent.getBoundingClientRect(); + const scale = getScale(parent); + return { + scale, + x: rect.left + parent.clientLeft * scale.x - parent.scrollLeft * scale.x, + y: rect.top + parent.clientTop * scale.y - parent.scrollTop * scale.y, + }; + } + const win = element.ownerDocument.defaultView!; + return { + scale: { x: 1, y: 1 }, + x: strategy === 'fixed' ? 0 : -win.scrollX, + y: strategy === 'fixed' ? 0 : -win.scrollY, + }; +} + +export function getElementRects({ + reference, + floating, + strategy, +}: Parameters[0]): ElementRects { + const space = getCoordinateSpace(floating, strategy); + const rect = reference.getBoundingClientRect(); + return { + reference: { + x: (rect.left - space.x) / space.scale.x, + y: (rect.top - space.y) / space.scale.y, + width: rect.width / space.scale.x, + height: rect.height / space.scale.y, + }, + floating: { x: 0, y: 0, ...getDimensions(floating) }, + }; +} + +export function getClippingRect(element: Element, boundary: Boundary): Rect { + const root = element.ownerDocument.documentElement; + const win = element.ownerDocument.defaultView!; + const viewport = win.visualViewport; + let clip = viewport + ? { left: viewport.offsetLeft, top: viewport.offsetTop, width: viewport.width, height: viewport.height } + : { left: 0, top: 0, width: root.clientWidth, height: root.clientHeight }; + const boundaries = + boundary === 'clippingAncestors' ? getScrollAncestors(element) : Array.isArray(boundary) ? boundary : [boundary]; + for (const item of boundaries) { + if (typeof item === 'object' && 'nodeType' in item) { + const node = item as HTMLElement; + const rect = node.getBoundingClientRect(); + const scale = getScale(node); + clip = intersect(clip, { + left: rect.left + node.clientLeft * scale.x, + top: rect.top + node.clientTop * scale.y, + width: node.clientWidth * scale.x, + height: node.clientHeight * scale.y, + }); + } else if (typeof item === 'object') { + const rect: Rect = item; + clip = intersect(clip, { left: rect.x, top: rect.y, width: rect.width, height: rect.height }); + } + } + return { x: clip.left, y: clip.top, width: clip.width, height: clip.height }; +} diff --git a/packages/react-components/react-positioning/library/src/engine/plugins.test.ts b/packages/react-components/react-positioning/library/src/engine/plugins.test.ts new file mode 100644 index 0000000000000..99dab18c9f5d4 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/plugins.test.ts @@ -0,0 +1,287 @@ +import type { PlacementInput, PlacementRect, Placement } from './computePlacement'; +import type { PositioningOptions, TargetElement } from '../types'; +import { applyArrow } from './arrow'; +import { applyAutoSize, resetAutoSize } from './autoSize'; +import { applyBoundary } from './boundary'; +import { applyFunctionOffset } from './offset'; +import { toPlacement, toPositioningPlacement } from './placement'; +import { getVisibility } from './visibility'; + +const rect = (left: number, top: number, width: number, height: number): PlacementRect => ({ + left, + top, + width, + height, +}); + +const createInput = (): PlacementInput => [ + rect(450, 300, 100, 40), + rect(0, 0, 1000, 700), + 200, + 100, + false, + { position: 'below', align: 'center' }, + 0, + 0, +]; + +const mockSize = (element: HTMLElement, size: { width?: number; height?: number }) => { + Object.entries({ + offsetWidth: size.width, + offsetHeight: size.height, + scrollWidth: size.width, + scrollHeight: size.height, + }).forEach(([property, value]) => Object.defineProperty(element, property, { value, configurable: true })); +}; + +const mockViewport = () => { + Object.defineProperty(document.documentElement, 'clientWidth', { value: 1000, configurable: true }); + Object.defineProperty(document.documentElement, 'clientHeight', { value: 700, configurable: true }); +}; + +describe('applyArrow', () => { + const placement: Placement = { position: 'below', align: 'center' }; + const createArrow = () => { + const arrow = document.createElement('div'); + mockSize(arrow, { width: 10, height: 10 }); + return arrow; + }; + + it('points to the center of the target', () => { + const arrow = createArrow(); + + applyArrow(arrow, placement, rect(450, 300, 100, 40), rect(400, 340, 200, 100)); + + // The center of the target is at 500, the container starts at 400 + expect(arrow).toHaveStyle({ left: '95px' }); + expect(arrow.style.top).toBe(''); + }); + + it('uses the top for placements next to the target', () => { + const arrow = createArrow(); + + applyArrow(arrow, { position: 'after', align: 'center' }, rect(450, 300, 100, 40), rect(560, 260, 100, 120)); + + expect(arrow).toHaveStyle({ top: '55px' }); + expect(arrow.style.left).toBe(''); + }); + + it('keeps the arrow inside the container using arrowPadding', () => { + const arrow = createArrow(); + + // The center of the target is after the end of the container + applyArrow(arrow, placement, rect(590, 300, 40, 40), rect(400, 340, 200, 100), 12); + + expect(arrow).toHaveStyle({ left: '178px' }); + }); + + it('does nothing without an arrow', () => { + expect(() => applyArrow(null, placement, rect(0, 0, 10, 10), rect(0, 0, 10, 10))).not.toThrow(); + }); +}); + +describe('applyAutoSize', () => { + const both = { applyMaxWidth: true, applyMaxHeight: true }; + const heightOnly = { applyMaxWidth: false, applyMaxHeight: true }; + + it.each([ + ['above', { maxHeight: '300px', maxWidth: '1000px' }], + ['below', { maxHeight: '360px', maxWidth: '1000px' }], + ['before', { maxHeight: '700px', maxWidth: '450px' }], + ['after', { maxHeight: '700px', maxWidth: '450px' }], + ] as const)('limits the size to the space next to the target (%s)', (position, expected) => { + const container = document.createElement('div'); + mockSize(container, { width: 100, height: 100 }); + + applyAutoSize( + container, + both, + { position, align: 'center' }, + rect(450, 300, 100, 40), + rect(0, 0, 1000, 700), + false, + 0, + ); + + expect(container.style.maxHeight).toBe(expected.maxHeight); + expect(container.style.maxWidth).toBe(expected.maxWidth); + expect(container.style.boxSizing).toBe('border-box'); + }); + + it('subtracts the offset from both sides', () => { + const container = document.createElement('div'); + mockSize(container, { width: 100, height: 100 }); + + applyAutoSize( + container, + heightOnly, + { position: 'above', align: 'center' }, + rect(450, 300, 100, 40), + rect(0, 0, 1000, 700), + false, + 8, + ); + + expect(container.style.maxHeight).toBe('284px'); + expect(container.style.maxWidth).toBe(''); + }); + + it('scrolls the container when the content does not fit', () => { + const container = document.createElement('div'); + mockSize(container, { width: 100, height: 500 }); + + applyAutoSize( + container, + heightOnly, + { position: 'above', align: 'center' }, + rect(450, 300, 100, 40), + rect(0, 0, 1000, 700), + false, + 0, + ); + + expect(container.style.overflowY).toBe('auto'); + }); + + it('resets the styles of the previous update', () => { + const container = document.createElement('div'); + container.style.maxHeight = '100px'; + container.style.maxWidth = '100px'; + container.style.boxSizing = 'border-box'; + + resetAutoSize(container, heightOnly); + + expect(container.style.maxHeight).toBe(''); + expect(container.style.maxWidth).toBe('100px'); + expect(container.style.boxSizing).toBe(''); + }); +}); + +describe('getVisibility', () => { + beforeEach(mockViewport); + + it('detects a target and a container that are outside of the viewport', () => { + const container = document.createElement('div'); + const target = document.createElement('button'); + + expect(getVisibility(container, target, rect(450, -100, 100, 40), rect(400, -60, 200, 100))).toMatchObject({ + referenceHidden: true, + escaped: false, + }); + expect(getVisibility(container, target, rect(450, 300, 100, 40), rect(400, 740, 200, 100))).toMatchObject({ + referenceHidden: false, + escaped: true, + }); + }); + + it('detects a container that overlaps the edges of the viewport', () => { + const container = document.createElement('div'); + const target = document.createElement('button'); + + expect(getVisibility(container, target, rect(450, 20, 100, 40), rect(400, -40, 200, 100)).intersecting).toBe(true); + expect(getVisibility(container, target, rect(450, 300, 100, 40), rect(400, 340, 200, 100)).intersecting).toBe( + false, + ); + }); + + it('uses the context element of virtual elements', () => { + const container = document.createElement('div'); + const target: TargetElement = { getBoundingClientRect: () => rect(0, 0, 0, 0) as unknown as DOMRect }; + + expect(() => getVisibility(container, target, rect(450, 300, 0, 0), rect(400, 340, 200, 100))).not.toThrow(); + }); +}); + +describe('placement', () => { + it('ignores alignments that do not apply to the position', () => { + expect(toPlacement('above', 'top')).toEqual({ position: 'above', align: 'center' }); + expect(toPlacement('before', 'start')).toEqual({ position: 'before', align: 'center' }); + expect(toPlacement('before', 'bottom')).toEqual({ position: 'before', align: 'end' }); + expect(toPlacement('below', 'end')).toEqual({ position: 'below', align: 'end' }); + }); + + it('defaults to below', () => { + expect(toPlacement()).toEqual({ position: 'below', align: 'center' }); + }); + + it('writes physical placements', () => { + expect(toPositioningPlacement({ position: 'before', align: 'end' }, false)).toBe('left-end'); + expect(toPositioningPlacement({ position: 'before', align: 'end' }, true)).toBe('right-end'); + expect(toPositioningPlacement({ position: 'above', align: 'center' }, true)).toBe('top'); + }); +}); + +describe('applyFunctionOffset', () => { + it('computes the margins of every placement with the function', () => { + const offset = jest.fn(({ position }: { position: string }) => + position === 'above' || position === 'below' ? { mainAxis: 8, crossAxis: 2 } : { mainAxis: 4, crossAxis: 1 }, + ); + const input = createInput(); + applyFunctionOffset(input, offset); + + expect(input[12]?.({ position: 'above', align: 'start' })).toEqual([8, 2]); + expect(input[12]?.({ position: 'after', align: 'start' })).toEqual([1, 4]); + expect(offset).toHaveBeenLastCalledWith({ + positionedRect: { x: 0, y: 0, width: 200, height: 100 }, + targetRect: { x: 450, y: 300, width: 100, height: 40 }, + position: 'after', + alignment: 'top', + }); + }); + + it('keeps the input when the offset is not a function', () => { + const input = createInput(); + + applyFunctionOffset(input, 4); + + expect(input[12]).toBeUndefined(); + }); +}); + +describe('applyBoundary', () => { + type Options = Pick; + const apply = (input: PlacementInput, options: Options, rtl = false) => + applyBoundary(input, document.createElement('div'), rtl, options); + + beforeEach(mockViewport); + + it('uses rects as boundaries and applies the padding to the overflow boundary', () => { + const input = createInput(); + apply(input, { + flipBoundary: { x: 100, y: 50, width: 800, height: 600 }, + overflowBoundary: { x: 10, y: 20, width: 500, height: 400 }, + overflowBoundaryPadding: { top: 5, bottom: 15, start: 10, end: 20 }, + }); + + expect(input[8]).toEqual(rect(100, 50, 800, 600)); + expect(input[1]).toEqual(rect(20, 25, 470, 380)); + }); + + it('swaps the start and the end of the padding for rtl', () => { + const input = createInput(); + apply(input, { overflowBoundaryPadding: { start: 10, end: 30 } }, true); + + expect(input[1]).toEqual(rect(30, 0, 960, 700)); + }); + + it('keeps the bounds for the flip when only the overflow boundary is set', () => { + const input = createInput(); + apply(input, { overflowBoundary: { x: 100, y: 100, width: 300, height: 300 } }); + + expect(input[1]).toEqual(rect(100, 100, 300, 300)); + expect(input[8]).toEqual(rect(0, 0, 1000, 700)); + }); + + it('resolves elements and the window', () => { + const element = document.createElement('div'); + element.getBoundingClientRect = () => ({ left: 100, top: 100 } as DOMRect); + Object.defineProperty(element, 'clientWidth', { value: 300 }); + Object.defineProperty(element, 'clientHeight', { value: 200 }); + + const input = createInput(); + apply(input, { flipBoundary: element, overflowBoundary: 'window' }); + + expect(input[8]).toEqual(rect(100, 100, 300, 200)); + expect(input[1]).toEqual(rect(0, 0, 1000, 700)); + }); +}); diff --git a/packages/react-components/react-positioning/library/src/engine/visibility.ts b/packages/react-components/react-positioning/library/src/engine/visibility.ts new file mode 100644 index 0000000000000..71b5e7aae6ec5 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/engine/visibility.ts @@ -0,0 +1,38 @@ +import type { TargetElement } from '../types'; +import type { PlacementRect } from './computePlacement'; +import { getClippingRect } from './geometry'; + +export type Visibility = { + /** The target is not visible */ + referenceHidden: boolean; + /** The container is not visible */ + escaped: boolean; + /** The container overlaps the top or bottom edge of the target's clipping area */ + intersecting: boolean; +}; + +const isClipped = (rect: PlacementRect, clip: PlacementRect): boolean => + rect.left + rect.width <= clip.left || + rect.left >= clip.left + clip.width || + rect.top + rect.height <= clip.top || + rect.top >= clip.top + clip.height; + +/** Computes what is visible after the container is positioned. */ +export function getVisibility( + container: HTMLElement, + target: TargetElement, + anchor: PlacementRect, + popup: PlacementRect, +): Visibility { + const element = ('nodeType' in target ? target : target.contextElement) ?? container; + const clip = getClippingRect(element); + const overflowTop = clip.top - popup.top; + const overflowBottom = popup.top + popup.height - (clip.top + clip.height); + + return { + referenceHidden: isClipped(anchor, clip), + escaped: isClipped(popup, clip), + intersecting: + (overflowTop > 0 && overflowTop < popup.height) || (overflowBottom > 0 && overflowBottom < popup.height), + }; +} diff --git a/packages/react-components/react-positioning/library/src/index.ts b/packages/react-components/react-positioning/library/src/index.ts index 26430e1129759..ad44453846ac1 100644 --- a/packages/react-components/react-positioning/library/src/index.ts +++ b/packages/react-components/react-positioning/library/src/index.ts @@ -9,6 +9,8 @@ export type { CreateArrowStylesOptions } from './createArrowStyles'; export { PositioningConfigurationProvider } from './PositioningConfigurationContext'; export { usePositioning } from './usePositioning'; +export { createPositionManager } from './createPositionManager'; +export type { PositionManagerOptions } from './createPositionManager'; export { usePositioningMouseTarget } from './usePositioningMouseTarget'; export { useSafeZoneArea } from './hooks/useSafeZoneArea/useSafeZoneArea'; export type { UseSafeZoneOptions } from './hooks/useSafeZoneArea/useSafeZoneArea'; @@ -33,6 +35,7 @@ export type { PositioningShorthandValue, PositioningVirtualElement, SetVirtualMouseTarget, + PositionManager, PositioningConfigurationFn, PositioningConfigurationFnOptions, } from './types'; diff --git a/packages/react-components/react-positioning/library/src/middleware/coverTarget.ts b/packages/react-components/react-positioning/library/src/middleware/coverTarget.ts deleted file mode 100644 index bbc9e119ce596..0000000000000 --- a/packages/react-components/react-positioning/library/src/middleware/coverTarget.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Middleware } from '@floating-ui/dom'; -import { parseFloatingUIPlacement } from '../utils/index'; - -export function coverTarget(): Middleware { - return { - name: 'coverTarget', - fn: middlewareArguments => { - const { placement, rects, x, y } = middlewareArguments; - const basePlacement = parseFloatingUIPlacement(placement).side; - const newCoords = { x, y }; - - switch (basePlacement) { - case 'bottom': - newCoords.y -= rects.reference.height; - break; - case 'top': - newCoords.y += rects.reference.height; - break; - case 'left': - newCoords.x += rects.reference.width; - break; - case 'right': - newCoords.x -= rects.reference.width; - break; - } - - return newCoords; - }, - }; -} diff --git a/packages/react-components/react-positioning/library/src/middleware/flip.ts b/packages/react-components/react-positioning/library/src/middleware/flip.ts index 4b59eb16939e6..99bb0c17dbbee 100644 --- a/packages/react-components/react-positioning/library/src/middleware/flip.ts +++ b/packages/react-components/react-positioning/library/src/middleware/flip.ts @@ -1,5 +1,5 @@ import type { Placement, Middleware } from '@floating-ui/dom'; -import { flip as baseFlip } from '@floating-ui/dom'; +import { flip as baseFlip } from '../engine'; import type { PositioningOptions } from '../types'; import { getBoundary, resolvePositioningShorthand, toFloatingUIPlacement } from '../utils/index'; diff --git a/packages/react-components/react-positioning/library/src/middleware/index.ts b/packages/react-components/react-positioning/library/src/middleware/index.ts deleted file mode 100644 index 0e1477f46ec75..0000000000000 --- a/packages/react-components/react-positioning/library/src/middleware/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { coverTarget } from './coverTarget'; -export type { FlipMiddlewareOptions } from './flip'; -export { flip } from './flip'; -export { intersecting } from './intersecting'; -export type { MaxSizeMiddlewareOptions } from './maxSize'; -export { maxSize, resetMaxSize } from './maxSize'; -export { offset } from './offset'; -export type { ShiftMiddlewareOptions } from './shift'; -export { shift } from './shift'; -export { matchTargetSize, matchTargetSizeCssVar } from './matchTargetSize'; diff --git a/packages/react-components/react-positioning/library/src/middleware/intersecting.ts b/packages/react-components/react-positioning/library/src/middleware/intersecting.ts index b09865f265e86..10978147e2275 100644 --- a/packages/react-components/react-positioning/library/src/middleware/intersecting.ts +++ b/packages/react-components/react-positioning/library/src/middleware/intersecting.ts @@ -1,5 +1,5 @@ import type { Middleware } from '@floating-ui/dom'; -import { detectOverflow } from '@floating-ui/dom'; +import { detectOverflow } from '../engine'; export function intersecting(): Middleware { return { diff --git a/packages/react-components/react-positioning/library/src/middleware/matchTargetSize.test.ts b/packages/react-components/react-positioning/library/src/middleware/matchTargetSize.test.ts deleted file mode 100644 index d55803336c8e9..0000000000000 --- a/packages/react-components/react-positioning/library/src/middleware/matchTargetSize.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type * as React from 'react'; -import type { MiddlewareState } from '@floating-ui/dom'; -import { matchTargetSize, matchTargetSizeCssVar } from './matchTargetSize'; - -describe('matchTargetSize', () => { - const middlewareFn = matchTargetSize().fn; - const createElementMock = () => ({ - style: { setProperty: jest.fn() } as React.CSSProperties & { setProperty: jest.Mock }, - }); - it('should match reference width if not same', async () => { - expect.assertions(3); - const floatingElement = createElementMock(); - const referenceWidth = 100; - const middlewareArguments = { - middlewareData: {}, - elements: { - floating: floatingElement, - }, - - rects: { - reference: { width: referenceWidth }, - floating: { width: '1px' }, - }, - } as unknown as MiddlewareState; - - const result = await middlewareFn(middlewareArguments); - - expect(result).toEqual({ - data: { - matchTargetSizeAttempt: true, - }, - reset: { - rects: true, - }, - }); - expect(floatingElement.style.setProperty).toHaveBeenCalledWith(matchTargetSizeCssVar, `${referenceWidth}px`); - expect(floatingElement.style.width).toEqual('var(--fui-match-target-size)'); - }); - - it('should not apply width style if already set', async () => { - expect.assertions(3); - const floatingElement = createElementMock(); - floatingElement.style.width = '100px'; - const referenceWidth = 100; - const middlewareArguments = { - middlewareData: {}, - elements: { - floating: floatingElement, - }, - - rects: { - reference: { width: referenceWidth }, - floating: { width: '1px' }, - }, - } as unknown as MiddlewareState; - - const result = await middlewareFn(middlewareArguments); - - expect(result).toEqual({ - data: { - matchTargetSizeAttempt: true, - }, - reset: { - rects: true, - }, - }); - expect(floatingElement.style.setProperty).toHaveBeenCalledWith(matchTargetSizeCssVar, `${referenceWidth}px`); - expect(floatingElement.style.width).toEqual('100px'); - }); - - it('should do nothing if reference width is equal to floating width', async () => { - expect.assertions(1); - const floatingElement = document.createElement('div'); - const referenceWidth = 100; - const middlewareArguments = { - middlewareData: {}, - elements: { - floating: floatingElement, - }, - - rects: { - reference: { width: referenceWidth }, - floating: { width: referenceWidth }, - }, - } as unknown as MiddlewareState; - - const result = await middlewareFn(middlewareArguments); - - expect(result).toEqual({}); - }); - - it('should do nothing if there have been a match attempt', async () => { - expect.assertions(1); - const floatingElement = document.createElement('div'); - const referenceWidth = 100; - const middlewareArguments = { - middlewareData: { matchTargetSize: { matchTargetSizeAttempt: true } }, - elements: { - floating: floatingElement, - }, - - rects: { - reference: { width: referenceWidth }, - floating: { width: '1px' }, - }, - } as unknown as MiddlewareState; - - const result = await middlewareFn(middlewareArguments); - - expect(result).toEqual({}); - }); -}); diff --git a/packages/react-components/react-positioning/library/src/middleware/matchTargetSize.ts b/packages/react-components/react-positioning/library/src/middleware/matchTargetSize.ts deleted file mode 100644 index 3dd4ed96caba2..0000000000000 --- a/packages/react-components/react-positioning/library/src/middleware/matchTargetSize.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Middleware } from '@floating-ui/dom'; - -export const matchTargetSizeCssVar = '--fui-match-target-size'; - -export function matchTargetSize(): Middleware { - return { - name: 'matchTargetSize', - fn: async middlewareArguments => { - const { - rects: { reference: referenceRect, floating: floatingRect }, - elements: { floating: floatingElement }, - middlewareData: { matchTargetSize: { matchTargetSizeAttempt = false } = {} }, - } = middlewareArguments; - - if (referenceRect.width === floatingRect.width || matchTargetSizeAttempt) { - return {}; - } - - const { width } = referenceRect; - floatingElement.style.setProperty(matchTargetSizeCssVar, `${width}px`); - if (!floatingElement.style.width) { - floatingElement.style.width = `var(${matchTargetSizeCssVar})`; - } - - return { - data: { matchTargetSizeAttempt: true }, - reset: { - rects: true, - }, - }; - }, - }; -} diff --git a/packages/react-components/react-positioning/library/src/middleware/maxSize.test.ts b/packages/react-components/react-positioning/library/src/middleware/maxSize.test.ts deleted file mode 100644 index 219632dea6655..0000000000000 --- a/packages/react-components/react-positioning/library/src/middleware/maxSize.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Platform } from '@floating-ui/dom'; -import { computePosition } from '@floating-ui/dom'; -import { resetMaxSize } from './maxSize'; - -const referenceRect: Partial = { x: 0, y: 0, width: 100, height: 100 }; -const floatingRect: Partial = { x: 0, y: 0, width: 50, height: 50 }; -const mockGetElementRects = jest.fn(() => - Promise.resolve({ - reference: referenceRect, - floating: floatingRect, - }), -); -const platform = { - getElementRects: mockGetElementRects, -} as Partial as Platform; - -describe('maxSize', () => { - it('resetMaxSize reset once per life cycle', async () => { - expect.hasAssertions(); - const button = document.createElement('div'); - const tooltip = document.createElement('div'); - const autoSize = { applyMaxHeight: true, applyMaxWidth: true }; - - await computePosition(button, tooltip, { - placement: 'right', - middleware: [resetMaxSize(autoSize)], - platform, - }); - expect(mockGetElementRects).toHaveBeenCalledTimes(2); - }); -}); diff --git a/packages/react-components/react-positioning/library/src/middleware/maxSize.ts b/packages/react-components/react-positioning/library/src/middleware/maxSize.ts index e3d36bfbe0fed..a4bed58fbc18d 100644 --- a/packages/react-components/react-positioning/library/src/middleware/maxSize.ts +++ b/packages/react-components/react-positioning/library/src/middleware/maxSize.ts @@ -1,4 +1,4 @@ -import { size } from '@floating-ui/dom'; +import { size } from '../engine'; import type { Middleware } from '@floating-ui/dom'; import type { NormalizedAutoSize, PositioningOptions } from '../types'; import { getBoundary } from '../utils/getBoundary'; diff --git a/packages/react-components/react-positioning/library/src/middleware/offset.ts b/packages/react-components/react-positioning/library/src/middleware/offset.ts index b5e33fafc7cd2..c177bd9fcf290 100644 --- a/packages/react-components/react-positioning/library/src/middleware/offset.ts +++ b/packages/react-components/react-positioning/library/src/middleware/offset.ts @@ -1,5 +1,5 @@ import type { Middleware } from '@floating-ui/dom'; -import { offset as baseOffset } from '@floating-ui/dom'; +import { offset as baseOffset } from '../engine'; import type { PositioningOptions } from '../types'; import { getFloatingUIOffset } from '../utils/getFloatingUIOffset'; diff --git a/packages/react-components/react-positioning/library/src/middleware/shift.ts b/packages/react-components/react-positioning/library/src/middleware/shift.ts index df068a870ff7a..a067db91dc8b7 100644 --- a/packages/react-components/react-positioning/library/src/middleware/shift.ts +++ b/packages/react-components/react-positioning/library/src/middleware/shift.ts @@ -1,5 +1,5 @@ import type { Middleware } from '@floating-ui/dom'; -import { shift as baseShift, limitShift } from '@floating-ui/dom'; +import { shift as baseShift, limitShift } from '../engine'; import type { PositioningOptions } from '../types'; import { getBoundary, toFloatingUIPadding } from '../utils/index'; diff --git a/packages/react-components/react-positioning/library/src/types.ts b/packages/react-components/react-positioning/library/src/types.ts index 762d3153be86f..6d70c61036deb 100644 --- a/packages/react-components/react-positioning/library/src/types.ts +++ b/packages/react-components/react-positioning/library/src/types.ts @@ -1,9 +1,8 @@ import type * as React from 'react'; /** - * Physical placement of a positioned element relative to its target, as computed by Floating UI. - * This is a Fluent-owned equivalent of Floating UI's `Placement` type, avoiding a transitive - * dependency on `@floating-ui/dom` in the public API surface. + * Physical placement of a positioned element relative to its target. + * `start` and `end` alignments follow the direction of the content. */ export type PositioningPlacement = | 'top' @@ -21,24 +20,22 @@ export type PositioningPlacement = /** * Detail payload of the positioning end event, providing the final computed placement - * after all middleware (flip, shift, etc.) have run. + * after flipping and shifting. */ export type OnPositioningEndEventDetail = { /** * The computed placement of the positioned element. May differ from the requested - * placement if flip or other middleware adjusted it. + * placement if it was flipped. */ placement: PositioningPlacement; /** - * True when the floating element has escaped its clipping boundary, - * as reported by Floating UI's hide middleware. + * True when the positioned element is outside of its clipping boundary. */ escaped: boolean; /** - * True when the reference element is clipped or out of view, - * as reported by Floating UI's hide middleware. + * True when the reference element is clipped or out of view. */ referenceHidden: boolean; }; @@ -218,6 +215,7 @@ export interface PositioningOptions { /** * When the reference element or the viewport is outside viewport allows a positioned element to be fully in viewport. * "all" enables this behavior for all axis. + * Currently has no effect: the positioned element is always kept inside its boundary. */ // eslint-disable-next-line @typescript-eslint/naming-convention unstable_disableTether?: boolean | 'all'; @@ -247,7 +245,7 @@ export interface PositioningOptions { /** * Called when a position update has finished. Multiple position updates can happen in a single render, * since positioning happens outside of the React lifecycle. - * The event's `detail.placement` indicates the final computed placement after middleware adjustments. + * The event's `detail.placement` indicates the final computed placement after flipping. * * It's also possible to listen to the custom DOM event `fui-positioningend` */ @@ -261,6 +259,7 @@ export interface PositioningOptions { /** * When true, the positioned element will shift to cover the target element when there's not enough space. * @default false + * Currently has no effect: the positioned element is always kept inside its boundary. */ shiftToCoverTarget?: boolean; } 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 0000000000000..0688e4fb1ad1e --- /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 0000000000000..c8a7b6994a563 --- /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); + }); +}); diff --git a/packages/react-components/react-positioning/library/src/usePositioning.raster.tsx b/packages/react-components/react-positioning/library/src/usePositioning.raster.tsx new file mode 100644 index 0000000000000..ef406014251ed --- /dev/null +++ b/packages/react-components/react-positioning/library/src/usePositioning.raster.tsx @@ -0,0 +1,44 @@ +import { webLightTheme as theme } from '@fluentui/react-theme'; +import './usePositioning.geometry.cy'; + +// Paint the otherwise transparent geometry fixtures without changing their layout. +beforeEach(() => { + cy.document().then(doc => { + const sheet = doc.createElement('style'); + sheet.dataset.positioningRaster = ''; + sheet.textContent = ` + [data-enabled] { background: ${theme.colorNeutralBackground1}; } + [data-target] { + background: ${theme.colorBrandBackground2}; + outline: ${theme.strokeWidthThin} solid ${theme.colorBrandStroke1}; + } + [data-floating] { + background: ${theme.colorNeutralBackground2}; + outline: ${theme.strokeWidthThin} solid ${theme.colorNeutralStroke1}; + border-radius: ${theme.borderRadiusMedium}; + box-shadow: ${theme.shadow8}; + } + [data-arrow] { background: ${theme.colorBrandBackground}; } + [data-target]::after, [data-floating]::after { + position: absolute; + inset: 0; + overflow: hidden; + color: ${theme.colorNeutralForeground1}; + font: ${theme.fontSizeBase300} ${theme.fontFamilyBase}; + } + [data-target]::after { content: 'Target'; } + [data-floating]::after { content: 'Positioned surface'; } + [data-boundary] { outline: ${theme.strokeWidthThin} solid ${theme.colorNeutralStroke2}; } + `; + doc.head.appendChild(sheet); + }); +}); + +afterEach(() => { + cy.document().then(doc => { + if (doc.querySelector('[data-enabled]')) { + cy.get('[data-enabled]').first().screenshot(Cypress.currentTest.title, { overwrite: true, scale: false }); + } + }); + cy.get('style[data-positioning-raster]').invoke('remove'); +}); diff --git a/packages/react-components/react-positioning/library/src/usePositioning.test.tsx b/packages/react-components/react-positioning/library/src/usePositioning.test.tsx index d92b4736def38..4e344341b0697 100644 --- a/packages/react-components/react-positioning/library/src/usePositioning.test.tsx +++ b/packages/react-components/react-positioning/library/src/usePositioning.test.tsx @@ -1,14 +1,16 @@ import { act, render } from '@testing-library/react'; import * as React from 'react'; +import { createComputedPositionManager } from './createComputedPositionManager'; import { usePositioning } from './usePositioning'; import { POSITIONING_END_EVENT } from './constants'; import type { OnPositioningEndEvent, OnPositioningEndEventDetail, PositioningProps } from './types'; +import { useMergedRefs, type ForwardRefComponent } from '@fluentui/react-utilities'; -// Mock createPositionManager to avoid @floating-ui/dom dependency in this test. +// Mock the computed manager to keep layout out of this test. // The mock dispatches the positioning end event asynchronously (via microtask), -// matching the real implementation's debounce + computePosition promise chain. -jest.mock('./createPositionManager', () => ({ - createPositionManager: jest.fn(({ container }: { container: HTMLElement }) => { +// matching the real implementation's debounced update. +jest.mock('./createComputedPositionManager', () => ({ + createComputedPositionManager: jest.fn(({ container }: { container: HTMLElement }) => { const dispatchEnd = () => { Promise.resolve().then(() => { container.dispatchEvent( @@ -32,22 +34,38 @@ jest.mock('./createPositionManager', () => ({ }), })); -const TestComponent: React.FC<{ onPositioningEnd?: PositioningProps['onPositioningEnd'] }> = ({ onPositioningEnd }) => { - const { targetRef, containerRef } = usePositioning({ onPositioningEnd }); +const TestComponent: ForwardRefComponent = React.forwardRef((props, ref) => { + const { targetRef, containerRef } = usePositioning(props); + const mergedContainerRef = useMergedRefs(containerRef, ref); return ( <> -
+
Container
); -}; +}); describe('usePositioning', () => { + it('preserves the trigger when the target option is explicitly undefined', async () => { + const { getByTestId } = render(); + + await act(async () => { + await new Promise(process.nextTick); + }); + + expect(createComputedPositionManager).toHaveBeenLastCalledWith( + expect.objectContaining({ + target: getByTestId('target'), + container: getByTestId('container'), + }), + ); + }); + describe('onPositioningEnd', () => { it('calls onPositioningEnd with the positioning event', async () => { const onPositioningEnd = jest.fn(); @@ -79,4 +97,20 @@ describe('usePositioning', () => { }); }); }); + + describe('strategy', () => { + it.each([ + [undefined, 'absolute'], + ['absolute', 'fixed'], + ['fixed', 'fixed'], + ] as const)('passes strategy %s as "%s" to the position manager', async (strategy, expected) => { + render(); + + await act(async () => { + await new Promise(process.nextTick); + }); + + expect(createComputedPositionManager).toHaveBeenLastCalledWith(expect.objectContaining({ strategy: expected })); + }); + }); }); diff --git a/packages/react-components/react-positioning/library/src/usePositioning.ts b/packages/react-components/react-positioning/library/src/usePositioning.ts index 0eeeda8f41a34..10e27d16c0d51 100644 --- a/packages/react-components/react-positioning/library/src/usePositioning.ts +++ b/packages/react-components/react-positioning/library/src/usePositioning.ts @@ -4,7 +4,7 @@ import { canUseDOM, useEventCallback, useIsomorphicLayoutEffect } from '@fluentu import * as React from 'react'; import { POSITIONING_END_EVENT } from './constants'; -import { createPositionManager } from './createPositionManager'; +import { createComputedPositionManager } from './createComputedPositionManager'; import type { OnPositioningEndEvent, PositioningOptions, @@ -34,10 +34,10 @@ export function usePositioning(options: PositioningProps & PositioningOptions): } managerRef.current = null; - const target = overrideTargetRef.current ?? targetRef.current; + const target = overrideTargetRef.current || targetRef.current; if (enabled && canUseDOM() && target && containerRef.current) { - managerRef.current = createPositionManager({ + managerRef.current = createComputedPositionManager({ container: containerRef.current, target, arrow: arrowRef.current, @@ -71,7 +71,7 @@ export function usePositioning(options: PositioningProps & PositioningOptions): ); useIsomorphicLayoutEffect(() => { - setOverrideTarget(options.target ?? null); + setOverrideTarget(options.target || null); }, [options.target, setOverrideTarget]); useIsomorphicLayoutEffect(() => { diff --git a/packages/react-components/react-positioning/library/src/usePositioningOptions.ts b/packages/react-components/react-positioning/library/src/usePositioningOptions.ts index ef8719797caca..4dc7c65d25b16 100644 --- a/packages/react-components/react-positioning/library/src/usePositioningOptions.ts +++ b/packages/react-components/react-positioning/library/src/usePositioningOptions.ts @@ -1,193 +1,105 @@ 'use client'; -import { devtools } from '@floating-ui/devtools'; -import { hide as hideMiddleware, arrow as arrowMiddleware } from '@floating-ui/dom'; -import type { Middleware, Placement, Strategy } from '@floating-ui/dom'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import * as React from 'react'; - -import { - shift as shiftMiddleware, - flip as flipMiddleware, - coverTarget as coverTargetMiddleware, - maxSize as maxSizeMiddleware, - resetMaxSize as resetMaxSizeMiddleware, - offset as offsetMiddleware, - intersecting as intersectingMiddleware, - matchTargetSize as matchTargetSizeMiddleware, -} from './middleware'; -import type { PositioningConfigurationFn, PositioningConfigurationFnOptions, PositioningOptions } from './types'; -import { toFloatingUIPlacement, hasScrollParent, normalizeAutoSize } from './utils'; -import { devtoolsCallback } from './utils/devtools'; +import type { PositioningOptions, PositioningPlacement } from './types'; +import { toFloatingUIPlacement } from './utils'; import { usePositioningConfiguration } from './PositioningConfigurationContext'; -/** - * This is redundant and exists only to manage React dependencies properly & avoid leaking individual options to the - * scope of `usePositioningOptions`. - * - * @internal - */ -function usePositioningConfigFn( - configFn: PositioningConfigurationFn, - options: PositioningOptions, -): (container: HTMLElement, arrow: HTMLElement | null) => PositioningConfigurationFnOptions { +/** Keep callback/ref rerenders out of the manager lifecycle, including removed options. */ +export function usePositioningOptions(options: PositioningOptions): ( + container: HTMLElement, + arrow: HTMLElement | null, +) => { + placement: PositioningPlacement | undefined; + positioningOptions: PositioningOptions; + strategy: 'absolute' | 'fixed'; + isRtl: boolean; + disableUpdateOnResize?: boolean; + useTransform?: boolean; +} { + const { dir } = useFluent(); + const configFn = usePositioningConfiguration(); const { align, arrowPadding, autoSize, coverTarget, disableUpdateOnResize, + fallbackPositions, flipBoundary, + matchTargetSize, offset, overflowBoundary, + overflowBoundaryPadding, pinned, position, - // eslint-disable-next-line @typescript-eslint/naming-convention - unstable_disableTether, + shiftToCoverTarget, strategy, - overflowBoundaryPadding, - fallbackPositions, useTransform, - matchTargetSize, - shiftToCoverTarget, + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_disableTether, + // eslint-disable-next-line @typescript-eslint/no-deprecated + positionFixed, } = options; - + const isRtl = dir === 'rtl'; return React.useCallback( (container: HTMLElement, arrow: HTMLElement | null) => { - return configFn({ + const resolved = configFn({ container, arrow, options: { + align, + arrowPadding, autoSize, + coverTarget, disableUpdateOnResize, + fallbackPositions, + flipBoundary, matchTargetSize, offset, - strategy, - coverTarget, - flipBoundary, overflowBoundary, - useTransform, overflowBoundaryPadding, pinned, - arrowPadding, - align, - fallbackPositions, - shiftToCoverTarget, position, + shiftToCoverTarget, + strategy, // eslint-disable-next-line @typescript-eslint/naming-convention unstable_disableTether, + useTransform, }, }); + return { + placement: toFloatingUIPlacement(resolved.align, resolved.position, isRtl), + positioningOptions: resolved, + isRtl, + // Preserve the baseline precedence: an explicit absolute strategy also resolves to fixed. + strategy: resolved.strategy ?? positionFixed ? ('fixed' as const) : ('absolute' as const), + disableUpdateOnResize: resolved.disableUpdateOnResize, + useTransform: resolved.useTransform, + }; }, [ + configFn, + isRtl, + positionFixed, + align, + arrowPadding, autoSize, + coverTarget, disableUpdateOnResize, + fallbackPositions, + flipBoundary, matchTargetSize, offset, - strategy, - coverTarget, - flipBoundary, overflowBoundary, - useTransform, overflowBoundaryPadding, pinned, - arrowPadding, - align, - fallbackPositions, - shiftToCoverTarget, position, + shiftToCoverTarget, + strategy, unstable_disableTether, - configFn, + useTransform, ], ); } - -/** - * @internal - */ -export function usePositioningOptions(options: PositioningOptions): ( - container: HTMLElement, - arrow: HTMLElement | null, -) => { - placement: Placement | undefined; - middleware: Middleware[]; - strategy: Strategy; - disableUpdateOnResize?: boolean; - useTransform?: boolean; -} { - const { dir, targetDocument } = useFluent(); - const isRtl = dir === 'rtl'; - - const configFn = usePositioningConfigFn(usePositioningConfiguration(), options); - const { - // eslint-disable-next-line @typescript-eslint/no-deprecated - positionFixed, - } = options; - - return React.useCallback( - (container: HTMLElement, arrow: HTMLElement | null) => { - const hasScrollableElement = hasScrollParent(container); - - const optionsAfterEnhancement = configFn(container, arrow); - const { - autoSize, - disableUpdateOnResize, - matchTargetSize, - offset, - coverTarget, - flipBoundary, - overflowBoundary, - useTransform, - overflowBoundaryPadding, - pinned, - position, - arrowPadding, - strategy, - align, - fallbackPositions, - shiftToCoverTarget, - // eslint-disable-next-line @typescript-eslint/naming-convention - unstable_disableTether, - } = optionsAfterEnhancement; - const normalizedAutoSize = normalizeAutoSize(autoSize); - - const middleware = [ - normalizedAutoSize && resetMaxSizeMiddleware(normalizedAutoSize), - matchTargetSize && matchTargetSizeMiddleware(), - offset && offsetMiddleware(offset), - coverTarget && coverTargetMiddleware(), - !pinned && flipMiddleware({ container, flipBoundary, hasScrollableElement, isRtl, fallbackPositions }), - shiftMiddleware({ - container, - hasScrollableElement, - overflowBoundary, - disableTether: unstable_disableTether, - overflowBoundaryPadding, - isRtl, - shiftToCoverTarget, - }), - normalizedAutoSize && - maxSizeMiddleware(normalizedAutoSize, { container, overflowBoundary, overflowBoundaryPadding, isRtl }), - intersectingMiddleware(), - arrow && arrowMiddleware({ element: arrow, padding: arrowPadding }), - hideMiddleware({ strategy: 'referenceHidden' }), - hideMiddleware({ strategy: 'escaped' }), - process.env.NODE_ENV !== 'production' && - targetDocument && - devtools(targetDocument, devtoolsCallback(optionsAfterEnhancement)), - ].filter(Boolean) as Middleware[]; - - const placement = toFloatingUIPlacement(align, position, isRtl); - - return { - placement, - middleware, - strategy: strategy ?? positionFixed ? ('fixed' as const) : ('absolute' as const), - - disableUpdateOnResize, - useTransform, - }; - }, - [configFn, isRtl, targetDocument, positionFixed], - ); -} diff --git a/packages/react-components/react-positioning/library/src/usePositioningSlideDirection.ts b/packages/react-components/react-positioning/library/src/usePositioningSlideDirection.ts index 18bebaa3eca3b..4c46fe9bee247 100644 --- a/packages/react-components/react-positioning/library/src/usePositioningSlideDirection.ts +++ b/packages/react-components/react-positioning/library/src/usePositioningSlideDirection.ts @@ -9,7 +9,7 @@ const registeredProperties = new WeakMap>(); const slideDirectionProperties = [POSITIONING_SLIDE_DIRECTION_VAR_X, POSITIONING_SLIDE_DIRECTION_VAR_Y]; /** - * Returns the slide direction unit vectors for a given Floating UI placement. + * Returns the slide direction unit vectors for a given placement. * Values are -1, 0, or 1, representing the direction the element slides in from. */ export function getPlacementSlideDirections(placement: string): { x: number; y: number } { diff --git a/packages/react-components/react-positioning/library/src/utils/devtools.ts b/packages/react-components/react-positioning/library/src/utils/devtools.ts deleted file mode 100644 index 3617fc2c245f9..0000000000000 --- a/packages/react-components/react-positioning/library/src/utils/devtools.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { MiddlewareState } from '@floating-ui/dom'; -import type { PositioningOptions, Position, Alignment } from '../types'; -import { isHTMLElement } from '@fluentui/react-utilities'; -import { listScrollParents } from './listScrollParents'; -import { fromFloatingUIPlacement } from './fromFloatingUIPlacement'; - -export const devtoolsCallback = - (options: Pick) => - ( - middlewareState: MiddlewareState, - ): { - type: 'FluentUIMiddleware'; - middlewareState: MiddlewareState; - options: Pick; - initialPlacement: { position: Position; alignment?: Alignment }; - placement: { position: Position; alignment?: Alignment }; - flipBoundaries: HTMLElement[]; - overflowBoundaries: HTMLElement[]; - scrollParents: HTMLElement[]; - } => { - const { - elements: { floating, reference }, - } = middlewareState; - const scrollParentsSet = new Set(); - if (isHTMLElement(reference)) { - listScrollParents(reference).forEach(scrollParent => scrollParentsSet.add(scrollParent)); - } - listScrollParents(floating).forEach(scrollParent => scrollParentsSet.add(scrollParent)); - const flipBoundaries: HTMLElement[] = Array.isArray(options.flipBoundary) - ? options.flipBoundary - : isHTMLElement(options.flipBoundary) - ? [options.flipBoundary] - : []; - const overflowBoundaries: HTMLElement[] = Array.isArray(options.overflowBoundary) - ? options.overflowBoundary - : isHTMLElement(options.overflowBoundary) - ? [options.overflowBoundary] - : []; - return { - type: 'FluentUIMiddleware', - middlewareState, - options, - initialPlacement: fromFloatingUIPlacement(middlewareState.initialPlacement), - placement: fromFloatingUIPlacement(middlewareState.placement), - flipBoundaries, - overflowBoundaries, - scrollParents: Array.from(scrollParentsSet), - } as const; - }; diff --git a/packages/react-components/react-positioning/library/src/utils/fromFloatingUIPlacement.test.ts b/packages/react-components/react-positioning/library/src/utils/fromFloatingUIPlacement.test.ts deleted file mode 100644 index d5dfbb37ec7b0..0000000000000 --- a/packages/react-components/react-positioning/library/src/utils/fromFloatingUIPlacement.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { fromFloatingUIPlacement } from './fromFloatingUIPlacement'; -import type { Placement } from '@floating-ui/dom'; - -describe('fromFloatingUIPlacement', () => { - it.each([ - //[align, position, placement] - ['top-start', 'start', 'above'], - ['top', undefined, 'above'], - ['top-end', 'end', 'above'], - ['bottom-start', 'start', 'below'], - ['bottom', undefined, 'below'], - ['bottom-end', 'end', 'below'], - ['left-start', 'top', 'before'], - ['left', undefined, 'before'], - ['left-end', 'bottom', 'before'], - ['right-start', 'top', 'after'], - ['right', undefined, 'after'], - ['right-end', 'bottom', 'after'], - ['top', undefined, 'above'], - ['bottom', undefined, 'below'], - ['left', undefined, 'before'], - ['right', undefined, 'after'], - ])( - 'should use placement %s and return position: %s and alignment: %s', - (placement, expectedAlignment, expectedPosition) => { - // Act - const { position, alignment } = fromFloatingUIPlacement(placement as Placement); - - // Assert - expect(position).toEqual(expectedPosition); - expect(alignment).toEqual(expectedAlignment); - }, - ); - - it.each([ - //[align, position, placement, rtlPlacement] - ['top-start', 'start', 'above'], - ['top', undefined, 'above'], - ['top-end', 'end', 'above'], - ['bottom-start', 'start', 'below'], - ['bottom', undefined, 'below'], - ['bottom-end', 'end', 'below'], - ['left-start', 'top', 'before'], - ['left', undefined, 'before'], - ['left-end', 'bottom', 'before'], - ['right-start', 'top', 'after'], - ['right', undefined, 'after'], - ['right-end', 'bottom', 'after'], - ['top', undefined, 'above'], - ['bottom', undefined, 'below'], - ['left', undefined, 'before'], - ['right', undefined, 'after'], - ])( - 'should use placement %s and return position: %s and alignment: %s', - (placement, expectedAlignment, expectedPosition) => { - // Act - const { position, alignment } = fromFloatingUIPlacement(placement as Placement); - - // Assert - expect(position).toEqual(expectedPosition); - expect(alignment).toEqual(expectedAlignment); - }, - ); -}); diff --git a/packages/react-components/react-positioning/library/src/utils/fromFloatingUIPlacement.ts b/packages/react-components/react-positioning/library/src/utils/fromFloatingUIPlacement.ts index 15255c5ece1a9..fa2397877934c 100644 --- a/packages/react-components/react-positioning/library/src/utils/fromFloatingUIPlacement.ts +++ b/packages/react-components/react-positioning/library/src/utils/fromFloatingUIPlacement.ts @@ -1,38 +1,16 @@ -import type { Side, Alignment as FloatingUIAlignment, Placement } from '@floating-ui/dom'; +import type { Placement } from '@floating-ui/dom'; import type { Alignment, Position } from '../types'; import { parseFloatingUIPlacement } from './parseFloatingUIPlacement'; -const getPositionMap = (): Record => ({ - top: 'above', - bottom: 'below', - right: 'after', - left: 'before', -}); - -// Floating UI automatically flips alignment -// https://github.com/floating-ui/floating-ui/issues/1563 -const getAlignmentMap = (position: Position): Record => { - if (position === 'above' || position === 'below') { - return { - start: 'start', - end: 'end', - }; - } - - return { - start: 'top', - end: 'bottom', - }; -}; - /** * Maps Floating UI placement to positioning values * @see positioningHelper.test.ts for expected placement values */ export const fromFloatingUIPlacement = (placement: Placement): { position: Position; alignment?: Alignment } => { const { side, alignment: floatingUIAlignment } = parseFloatingUIPlacement(placement); - const position = getPositionMap()[side]; - const alignment = floatingUIAlignment && getAlignmentMap(position)[floatingUIAlignment]; - + const block = side === 'top' || side === 'bottom'; + const position = block ? (side === 'top' ? 'above' : 'below') : side === 'left' ? 'before' : 'after'; + const alignment = + floatingUIAlignment && (block ? floatingUIAlignment : floatingUIAlignment === 'start' ? 'top' : 'bottom'); return { position, alignment }; }; diff --git a/packages/react-components/react-positioning/library/src/utils/getBoundary.ts b/packages/react-components/react-positioning/library/src/utils/getBoundary.ts index 39e0db34f3b51..f7975b51c6dee 100644 --- a/packages/react-components/react-positioning/library/src/utils/getBoundary.ts +++ b/packages/react-components/react-positioning/library/src/utils/getBoundary.ts @@ -1,17 +1,17 @@ import type { Boundary as FloatingUIBoundary } from '@floating-ui/dom'; - -import { getScrollParent } from './getScrollParent'; import type { PositioningBoundary } from '../types'; +import { getScrollAncestors } from '../engine/geometry'; -/** - * Allows to mimic a behavior from V1 of Popper and accept `window` and `scrollParent` as strings. - */ export function getBoundary( element: HTMLElement | null, boundary?: PositioningBoundary, ): FloatingUIBoundary | undefined { + if (!element) { + return boundary === 'clippingParents' ? 'clippingAncestors' : undefined; + } + if (boundary === 'window') { - return element?.ownerDocument!.documentElement; + return element.ownerDocument.documentElement; } if (boundary === 'clippingParents') { @@ -19,13 +19,7 @@ export function getBoundary( } if (boundary === 'scrollParent') { - let boundariesNode: HTMLElement | undefined = getScrollParent(element); - - if (boundariesNode.nodeName === 'BODY') { - boundariesNode = element?.ownerDocument!.documentElement; - } - - return boundariesNode; + return getScrollAncestors(element)[0] ?? element.ownerDocument.documentElement; } return boundary; diff --git a/packages/react-components/react-positioning/library/src/utils/getFloatingUIOffset.test.ts b/packages/react-components/react-positioning/library/src/utils/getFloatingUIOffset.test.ts deleted file mode 100644 index e7602445bd314..0000000000000 --- a/packages/react-components/react-positioning/library/src/utils/getFloatingUIOffset.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { MiddlewareState } from '@floating-ui/dom'; -import type { OffsetFunction } from '../types'; -import type { FloatingUIOffsetFunction } from './getFloatingUIOffset'; -import { getFloatingUIOffset } from './getFloatingUIOffset'; - -describe('getFloatingUIOffset', () => { - const testMiddlewareArgs: MiddlewareState = { - elements: { - reference: document.createElement('div'), - floating: document.createElement('div'), - }, - initialPlacement: 'top', - placement: 'top', - strategy: 'fixed', - rects: { - floating: { - x: 0, - y: 0, - height: 0, - width: 0, - }, - reference: { - x: 0, - y: 0, - height: 0, - width: 0, - }, - }, - platform: { - getElementRects: jest.fn(), - getClippingRect: jest.fn(), - getDimensions: jest.fn(), - convertOffsetParentRelativeRectToViewportRelativeRect: jest.fn(), - getOffsetParent: jest.fn(), - isElement: jest.fn(), - getDocumentElement: jest.fn(), - getClientRects: jest.fn(), - isRTL: jest.fn(), - }, - middlewareData: {}, - x: 0, - y: 0, - }; - - it('should ignore object offsets', () => { - const offset = { crossAxis: 10, mainAxis: 10 }; - const transformedOffset = getFloatingUIOffset({ crossAxis: 10, mainAxis: 10 }); - expect(transformedOffset).toEqual(offset); - }); - - it('should ignore number offsets', () => { - const offset = 10; - const transformedOffset = getFloatingUIOffset(offset); - expect(transformedOffset).toEqual(offset); - }); - - it('should keep function offset as function', () => { - const dummyRect = { x: 0, y: 0, width: 0, height: 0 }; - const offset = { crossAxis: 10, mainAxis: 10 }; - const transformedOffset = getFloatingUIOffset(() => offset) as FloatingUIOffsetFunction; - expect( - transformedOffset({ - ...testMiddlewareArgs, - rects: { floating: dummyRect, reference: dummyRect }, - placement: 'top', - }), - ).toEqual(offset); - }); - - it('should transform placement argument in function offset', () => { - const dummyRect = { x: 0, y: 0, width: 0, height: 0 }; - const offsetFn: OffsetFunction = ({ position, alignment }) => { - if (position === 'above' && alignment === 'start') { - return 1; - } - - return -1; - }; - const transformedOffset = getFloatingUIOffset(offsetFn) as FloatingUIOffsetFunction; - expect( - transformedOffset({ - ...testMiddlewareArgs, - rects: { floating: dummyRect, reference: dummyRect }, - placement: 'top-start', - }), - ).toEqual(1); - }); - - it('should rename floating property to positioned', () => { - const dummyRect = { x: 0, y: 0, width: 0, height: 0 }; - const offsetFn: OffsetFunction = ({ positionedRect }) => { - if (positionedRect === dummyRect) { - return 1; - } - - return -1; - }; - const transformedOffset = getFloatingUIOffset(offsetFn) as FloatingUIOffsetFunction; - expect( - transformedOffset({ - ...testMiddlewareArgs, - rects: { floating: dummyRect, reference: dummyRect }, - placement: 'top-start', - }), - ).toEqual(1); - }); - - it('should rename reference property to target', () => { - const dummyRect = { x: 0, y: 0, width: 0, height: 0 }; - const offsetFn: OffsetFunction = ({ targetRect }) => { - if (targetRect === dummyRect) { - return 1; - } - - return -1; - }; - const transformedOffset = getFloatingUIOffset(offsetFn) as FloatingUIOffsetFunction; - expect( - transformedOffset({ - ...testMiddlewareArgs, - rects: { floating: dummyRect, reference: dummyRect }, - placement: 'top-start', - }), - ).toEqual(1); - }); -}); diff --git a/packages/react-components/react-positioning/library/src/utils/getFloatingUIOffset.ts b/packages/react-components/react-positioning/library/src/utils/getFloatingUIOffset.ts index 04ace8eea99ab..da9de5cb81700 100644 --- a/packages/react-components/react-positioning/library/src/utils/getFloatingUIOffset.ts +++ b/packages/react-components/react-positioning/library/src/utils/getFloatingUIOffset.ts @@ -1,50 +1,21 @@ -import type { Offset } from '../types'; import type { MiddlewareState } from '@floating-ui/dom'; +import type { Offset } from '../types'; import { fromFloatingUIPlacement } from './fromFloatingUIPlacement'; -/** - * Type taken from Floating UI since they are not exported - */ + export type FloatingUIOffsetValue = | number | { - /** - * The axis that runs along the side of the floating element. - * @default 0 - */ mainAxis?: number; - /** - * The axis that runs along the alignment of the floating element. - * @default 0 - */ crossAxis?: number; - /** - * When set to a number, overrides the `crossAxis` value for aligned - * (non-centered/base) placements and works logically. A positive number - * will move the floating element in the direction of the opposite edge - * to the one that is aligned, while a negative number the reverse. - * @default null - */ alignmentAxis?: number | null; }; -/** - * Type taken from Floating UI since they are not exported - */ export type FloatingUIOffsetFunction = (args: MiddlewareState) => FloatingUIOffsetValue; -/** - * Shim to transform offset values from this library to Floating UI - * @param rawOffset Offset from this library - * @returns An offset value compatible with Floating UI - */ export function getFloatingUIOffset( rawOffset: Offset | undefined, ): FloatingUIOffsetValue | FloatingUIOffsetFunction | undefined { - if (!rawOffset) { - return rawOffset; - } - - if (typeof rawOffset === 'number' || typeof rawOffset === 'object') { + if (!rawOffset || typeof rawOffset === 'number' || typeof rawOffset === 'object') { return rawOffset; } diff --git a/packages/react-components/react-positioning/library/src/utils/getScrollParent.test.ts b/packages/react-components/react-positioning/library/src/utils/getScrollParent.test.ts deleted file mode 100644 index 339114f1d49be..0000000000000 --- a/packages/react-components/react-positioning/library/src/utils/getScrollParent.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { getParentNode, getStyleComputedProperty, getScrollParent, hasScrollParent } from './getScrollParent'; - -describe('getParentNode', () => { - it('returns the node itself if it is HTML', () => { - const node = document.createElement('html'); - - expect(getParentNode(node)).toBe(node); - }); - - it('returns the parent node for regular elements', () => { - const parent = document.createElement('div'); - const child = document.createElement('span'); - - parent.appendChild(child); - - expect(getParentNode(child)).toBe(parent); - }); - - it('returns the host for shadow DOM elements', () => { - const host = document.createElement('div'); - const shadowRoot = host.attachShadow({ mode: 'open' }); - const child = document.createElement('span'); - shadowRoot.appendChild(child); - - // Mock the host property since we can't directly test shadow DOM in JSDOM - Object.defineProperty(child, 'parentNode', { value: null }); - Object.defineProperty(child, 'host', { value: host }); - - expect(getParentNode(child as HTMLElement)).toBe(host); - }); -}); - -describe('getStyleComputedProperty', () => { - it('returns empty object for non-element nodes', () => { - const textNode = document.createTextNode('text'); - - expect(getStyleComputedProperty(textNode as unknown as HTMLElement)).toEqual({}); - }); - - it('returns computed style for element nodes', () => { - const mockStyle: Partial = { - overflow: 'hidden', - }; - - const element = document.createElement('div'); - document.body.appendChild(element); - - jest - .spyOn(window, 'getComputedStyle') - .mockReturnValueOnce(mockStyle as Partial as CSSStyleDeclaration); - - expect(getStyleComputedProperty(element)).toMatchObject(mockStyle); - }); - - it('returns an empty object when defaultView is null', () => { - const el = document.createElement('div'); - const mockDocument: Partial = { defaultView: null }; - - Object.defineProperty(el, 'ownerDocument', { value: mockDocument }); - - expect(getStyleComputedProperty(el)).toEqual({}); - }); -}); - -describe('getScrollParent', () => { - it('returns document.body when node is null', () => { - expect(getScrollParent(null)).toBe(document.body); - }); - - it('returns document.body for HTML and BODY nodes', () => { - const htmlEl = document.createElement('html'); - const bodyEl = document.createElement('body'); - - expect(getScrollParent(htmlEl)).toBe(document.body); - expect(getScrollParent(bodyEl)).toBe(document.body); - }); - - it('returns document.body for document', () => { - expect(getScrollParent(document)).toBe(document.body); - }); - - it('returns the element if it has overflow properties', () => { - const parentEl = document.createElement('div'); - const childEl = document.createElement('div'); - - parentEl.appendChild(childEl); - document.body.appendChild(parentEl); - - jest.spyOn(window, 'getComputedStyle').mockReturnValueOnce({ - overflow: 'auto', - overflowX: '', - overflowY: '', - } as Partial as CSSStyleDeclaration); - - expect(getScrollParent(childEl)).toBe(parentEl); - }); - - it('recursively finds the first scrollable parent', () => { - const scrollableEl = document.createElement('div'); - const middleEl = document.createElement('div'); - const childEl = document.createElement('div'); - - scrollableEl.appendChild(middleEl); - middleEl.appendChild(childEl); - document.body.appendChild(scrollableEl); - - jest.spyOn(window, 'getComputedStyle').mockReturnValueOnce({ - overflow: 'visible', - overflowX: '', - overflowY: '', - } as Partial as CSSStyleDeclaration); - jest.spyOn(window, 'getComputedStyle').mockReturnValueOnce({ - overflow: 'auto', - overflowX: '', - overflowY: '', - } as Partial as CSSStyleDeclaration); - - expect(getScrollParent(childEl)).toBe(scrollableEl); - }); -}); - -describe('hasScrollParent', () => { - it('returns false when node is null', () => { - expect(hasScrollParent(null)).toBe(false); - }); - - it('returns false when scroll parent is document.body', () => { - expect(hasScrollParent(document.body)).toBe(false); - }); -}); diff --git a/packages/react-components/react-positioning/library/src/utils/getScrollParent.ts b/packages/react-components/react-positioning/library/src/utils/getScrollParent.ts deleted file mode 100644 index b7aeb4851a50a..0000000000000 --- a/packages/react-components/react-positioning/library/src/utils/getScrollParent.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Returns the parent node or the host of the node argument. - * @param node - DOM node. - * @returns - parent DOM node. - */ - -'use client'; - -export const getParentNode = (node: HTMLElement): HTMLElement => { - if (node.nodeName === 'HTML') { - return node; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return node.parentNode || (node as any).host; -}; - -/** - * Returns CSS styles of the given node. - * @param node - DOM node. - * @returns - CSS styles. - */ -export const getStyleComputedProperty = (node: HTMLElement): Partial => { - if (node.nodeType !== 1) { - return {}; - } - - const targetWindow = node.ownerDocument?.defaultView; - - if (targetWindow) { - return targetWindow.getComputedStyle(node, null); - } - - return {}; -}; - -/** - * Returns the first scrollable parent of the given element. - * @param node - DOM node. - * @returns - the first scrollable parent. - */ -export const getScrollParent = (node: Document | HTMLElement | null): HTMLElement => { - // Return body, `getScroll` will take care to get the correct `scrollTop` from it - const parentNode = node && getParentNode(node as HTMLElement); - // eslint-disable-next-line - if (!parentNode) return document.body; - - switch (parentNode.nodeName) { - case 'HTML': - case 'BODY': - return parentNode.ownerDocument!.body; - case '#document': - return (parentNode as unknown as Document).body; - } - - // If any of the overflow props is defined for the node then we return it as the parent - const { overflow, overflowX, overflowY } = getStyleComputedProperty(parentNode); - if (/(auto|scroll|overlay)/.test(overflow! + overflowY! + overflowX)) { - return parentNode; - } - - return getScrollParent(parentNode); -}; - -export const hasScrollParent = (node: Document | HTMLElement | null): boolean => { - const scrollParentElement: HTMLElement = getScrollParent(node); - - return scrollParentElement ? scrollParentElement !== scrollParentElement.ownerDocument?.body : false; -}; diff --git a/packages/react-components/react-positioning/library/src/utils/index.ts b/packages/react-components/react-positioning/library/src/utils/index.ts index 04feadbc94831..e10e1d2f825d9 100644 --- a/packages/react-components/react-positioning/library/src/utils/index.ts +++ b/packages/react-components/react-positioning/library/src/utils/index.ts @@ -1,17 +1,15 @@ -export { parseFloatingUIPlacement } from './parseFloatingUIPlacement'; -export { getBoundary } from './getBoundary'; export type { Fiber, HookType } from './getReactFiberFromNode'; export { getReactFiberFromNode } from './getReactFiberFromNode'; -export { getParentNode, getScrollParent, hasScrollParent } from './getScrollParent'; -export { mergeArrowOffset } from './mergeArrowOffset'; +export { getBoundary } from './getBoundary'; +export { getFloatingUIOffset } from './getFloatingUIOffset'; +export { fromFloatingUIPlacement } from './fromFloatingUIPlacement'; +export { parseFloatingUIPlacement } from './parseFloatingUIPlacement'; export { toFloatingUIPadding } from './toFloatingUIPadding'; export { toFloatingUIPlacement } from './toFloatingUIPlacement'; -export { fromFloatingUIPlacement } from './fromFloatingUIPlacement'; +export { mergeArrowOffset } from './mergeArrowOffset'; export { resolvePositioningShorthand } from './resolvePositioningShorthand'; export { useCallbackRef } from './useCallbackRef'; export { debounce } from './debounce'; -export { toggleScrollListener } from './toggleScrollListener'; export { hasAutofocusFilter } from './hasAutoFocusFilter'; -export { writeArrowUpdates } from './writeArrowUpdates'; -export { writeContainerUpdates } from './writeContainerupdates'; +export { writeContainerUpdates, writeVisibilityUpdates } from './writeContainerupdates'; export { normalizeAutoSize } from './normalizeAutoSize'; diff --git a/packages/react-components/react-positioning/library/src/utils/listScrollParents.test.ts b/packages/react-components/react-positioning/library/src/utils/listScrollParents.test.ts deleted file mode 100644 index 865fd02a38c5a..0000000000000 --- a/packages/react-components/react-positioning/library/src/utils/listScrollParents.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { listScrollParents } from './listScrollParents'; - -describe('listScrollParents', () => { - beforeEach(() => { - document.body.innerHTML = ''; - }); - - const createScrollParent = () => { - const el = document.createElement('div'); - el.style.overflow = 'auto'; - return el; - }; - - it('should return all scroll parents include and up to body', () => { - const start = document.createElement('div'); - const scrollParent1 = createScrollParent(); - const scrollParent2 = createScrollParent(); - - scrollParent1.appendChild(start); - scrollParent2.appendChild(scrollParent1); - document.body.append(scrollParent2); - - const scrollParents = listScrollParents(start); - - expect(scrollParents.length).toBe(3); - expect(scrollParents).toContain(scrollParent1); - expect(scrollParents).toContain(scrollParent2); - expect(scrollParents).toContain(document.body); - }); -}); diff --git a/packages/react-components/react-positioning/library/src/utils/listScrollParents.ts b/packages/react-components/react-positioning/library/src/utils/listScrollParents.ts deleted file mode 100644 index 44fdb8b9de156..0000000000000 --- a/packages/react-components/react-positioning/library/src/utils/listScrollParents.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { getScrollParent } from './getScrollParent'; - -export function listScrollParents(node: HTMLElement): HTMLElement[] { - const scrollParents: HTMLElement[] = []; - - let cur: HTMLElement | null = node; - while (cur) { - const scrollParent = getScrollParent(cur); - - if (node.ownerDocument.body === scrollParent) { - scrollParents.push(scrollParent); - break; - } - - if (scrollParent.nodeName === 'BODY' && scrollParent !== node.ownerDocument.body) { - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.error( - '@fluentui/react-positioning: You are comparing two different documents! This is an unexpected error, please report this as a bug to the Fluent UI team ', - ); - } - break; - } - - scrollParents.push(scrollParent); - cur = scrollParent; - } - - return scrollParents; -} diff --git a/packages/react-components/react-positioning/library/src/utils/normalizeAutoSize.ts b/packages/react-components/react-positioning/library/src/utils/normalizeAutoSize.ts index 49701999ccd6c..c4dfbe43b42f3 100644 --- a/packages/react-components/react-positioning/library/src/utils/normalizeAutoSize.ts +++ b/packages/react-components/react-positioning/library/src/utils/normalizeAutoSize.ts @@ -6,29 +6,30 @@ import type { NormalizedAutoSize, PositioningOptions } from '../types'; * This function maps them to true/'height'/'width' */ export const normalizeAutoSize = (autoSize?: PositioningOptions['autoSize']): NormalizedAutoSize | false => { + const dimensions = getAutoSizeDimensions(autoSize); + return dimensions.length + ? { + applyMaxWidth: dimensions.includes('width'), + applyMaxHeight: dimensions.includes('height'), + } + : false; +}; + +export function getAutoSizeDimensions(autoSize?: PositioningOptions['autoSize']): ('width' | 'height')[] { switch (autoSize) { case 'always': case true: - return { - applyMaxWidth: true, - applyMaxHeight: true, - }; + return ['width', 'height']; case 'width-always': case 'width': - return { - applyMaxWidth: true, - applyMaxHeight: false, - }; + return ['width']; case 'height-always': case 'height': - return { - applyMaxWidth: false, - applyMaxHeight: true, - }; + return ['height']; default: - return false; + return []; } -}; +} diff --git a/packages/react-components/react-positioning/library/src/utils/parseFloatingUIPlacement.test.ts b/packages/react-components/react-positioning/library/src/utils/parseFloatingUIPlacement.test.ts deleted file mode 100644 index 00c41266622f0..0000000000000 --- a/packages/react-components/react-positioning/library/src/utils/parseFloatingUIPlacement.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type * as FloatingUI from '@floating-ui/dom'; -import { parseFloatingUIPlacement } from './parseFloatingUIPlacement'; - -describe('getSide', () => { - it.each([ - ['top', { side: 'top' }], - ['bottom', { side: 'bottom' }], - ['right', { side: 'right' }], - ['left', { side: 'left' }], - ['top-start', { side: 'top', alignment: 'start' }], - ['top-end', { side: 'top', alignment: 'end' }], - ['bottom-start', { side: 'bottom', alignment: 'start' }], - ['bottom-end', { side: 'bottom', alignment: 'end' }], - ['right-start', { side: 'right', alignment: 'start' }], - ['right-end', { side: 'right', alignment: 'end' }], - ['left-start', { side: 'left', alignment: 'start' }], - ['left-end', { side: 'left', alignment: 'end' }], - ])('should return %s from %s', (placement, basePlacement) => { - expect(parseFloatingUIPlacement(placement as unknown as FloatingUI.Placement)).toEqual(basePlacement); - }); -}); diff --git a/packages/react-components/react-positioning/library/src/utils/parseFloatingUIPlacement.ts b/packages/react-components/react-positioning/library/src/utils/parseFloatingUIPlacement.ts index 71f93a73df839..fe2e28e62a1d0 100644 --- a/packages/react-components/react-positioning/library/src/utils/parseFloatingUIPlacement.ts +++ b/packages/react-components/react-positioning/library/src/utils/parseFloatingUIPlacement.ts @@ -1,15 +1,6 @@ -import type { Side, Placement, Alignment } from '@floating-ui/dom'; +import type { Alignment, Placement, Side } from '@floating-ui/dom'; -/** - * Parses Floating UI placement and returns the different components - * @param placement - the floating ui placement (i.e. bottom-start) - * - * @returns side and alignment components of the placement - */ export function parseFloatingUIPlacement(placement: Placement): { side: Side; alignment: Alignment } { - const tokens = placement.split('-'); - return { - side: tokens[0] as Side, - alignment: tokens[1] as Alignment, - }; + const [side, alignment] = placement.split('-'); + return { side: side as Side, alignment: alignment as Alignment }; } diff --git a/packages/react-components/react-positioning/library/src/utils/toFloatingUIPadding.test.ts b/packages/react-components/react-positioning/library/src/utils/toFloatingUIPadding.test.ts deleted file mode 100644 index b4c613adc5923..0000000000000 --- a/packages/react-components/react-positioning/library/src/utils/toFloatingUIPadding.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { toFloatingUIPadding } from './toFloatingUIPadding'; - -describe('toFloatingUIPadding', () => { - it('should return shorthand number value directly', () => { - expect(toFloatingUIPadding(10, false)).toEqual(10); - }); - - it('should transform logical sides to directional sides', () => { - expect(toFloatingUIPadding({ start: 1, end: 2 }, false)).toEqual({ left: 1, right: 2 }); - }); - - it('should transform logical sides to directional sides in RTL', () => { - expect(toFloatingUIPadding({ start: 1, end: 2 }, true)).toEqual({ left: 2, right: 1 }); - }); -}); diff --git a/packages/react-components/react-positioning/library/src/utils/toFloatingUIPadding.ts b/packages/react-components/react-positioning/library/src/utils/toFloatingUIPadding.ts index 0797ecc355db2..53f0142bfe391 100644 --- a/packages/react-components/react-positioning/library/src/utils/toFloatingUIPadding.ts +++ b/packages/react-components/react-positioning/library/src/utils/toFloatingUIPadding.ts @@ -10,19 +10,15 @@ export function toFloatingUIPadding( } const { start, end, ...verticalPadding } = padding; - const paddingObject: Partial = verticalPadding; - const left = isRtl ? 'end' : 'start'; const right = isRtl ? 'start' : 'end'; - // assign properties explicitly since undefined values are actually handled by floating UI - // TODO create floating UI issue - if (padding[left]) { + if (padding[left] !== undefined) { paddingObject.left = padding[left]; } - if (padding[right]) { + if (padding[right] !== undefined) { paddingObject.right = padding[right]; } diff --git a/packages/react-components/react-positioning/library/src/utils/toFloatingUIPlacement.test.ts b/packages/react-components/react-positioning/library/src/utils/toFloatingUIPlacement.test.ts deleted file mode 100644 index 43f70bc8333b8..0000000000000 --- a/packages/react-components/react-positioning/library/src/utils/toFloatingUIPlacement.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { toFloatingUIPlacement } from './toFloatingUIPlacement'; -import type { Alignment, Position } from '../types'; - -describe('toFloatingUIPlacement', () => { - it.each([ - //[align, position, placement, rtlPlacement] - ['start', 'above', 'top-start', 'top-start'], - ['center', 'above', 'top', 'top'], - ['end', 'above', 'top-end', 'top-end'], - ['start', 'below', 'bottom-start', 'bottom-start'], - ['center', 'below', 'bottom', 'bottom'], - ['end', 'below', 'bottom-end', 'bottom-end'], - ['top', 'before', 'left-start', 'right-start'], - ['center', 'before', 'left', 'right'], - ['bottom', 'before', 'left-end', 'right-end'], - ['top', 'after', 'right-start', 'left-start'], - ['center', 'after', 'right', 'left'], - ['bottom', 'after', 'right-end', 'left-end'], - [undefined, 'above', 'top', 'top'], - [undefined, 'below', 'bottom', 'bottom'], - [undefined, 'before', 'left', 'right'], - [undefined, 'after', 'right', 'left'], - ])( - 'should use align: "%s" position: "%s" and return LTR placement: "%s" and RTL placement: "%s"', - (align, position, expectedPlacement, expectedRtlPlacement) => { - // Act - const placement = toFloatingUIPlacement(align as Alignment, position as Position); - const rtlPlacement = toFloatingUIPlacement(align as Alignment, position as Position, true); - - // Assert - expect(placement).toEqual(expectedPlacement); - expect(rtlPlacement).toEqual(expectedRtlPlacement); - }, - ); -}); diff --git a/packages/react-components/react-positioning/library/src/utils/toFloatingUIPlacement.ts b/packages/react-components/react-positioning/library/src/utils/toFloatingUIPlacement.ts index a70fc97c82595..4c3df0ad3936c 100644 --- a/packages/react-components/react-positioning/library/src/utils/toFloatingUIPlacement.ts +++ b/packages/react-components/react-positioning/library/src/utils/toFloatingUIPlacement.ts @@ -1,46 +1,25 @@ -import type { Placement, Side, Alignment as FloatingUIAlignment } from '@floating-ui/dom'; +import type { Placement, Side } from '@floating-ui/dom'; import type { Alignment, Position } from '../types'; -type PlacementPosition = Side; -type PlacementAlign = FloatingUIAlignment; - -const getPositionMap = (rtl?: boolean): Record => ({ - above: 'top', - below: 'bottom', - before: rtl ? 'right' : 'left', - after: rtl ? 'left' : 'right', -}); - -// Floating UI automatically flips alignment -// https://github.com/floating-ui/floating-ui/issues/1563 -const getAlignmentMap = (): Record => ({ - start: 'start', - end: 'end', - top: 'start', - bottom: 'end', - center: undefined, -}); - -const shouldAlignToCenter = (p?: Position, a?: Alignment): boolean => { - const positionedVertically = p === 'above' || p === 'below'; - const alignedVertically = a === 'top' || a === 'bottom'; - - return (positionedVertically && alignedVertically) || (!positionedVertically && !alignedVertically); -}; - /** * Maps internal positioning values to Floating UI placement * @see positioningHelper.test.ts for expected placement values */ export const toFloatingUIPlacement = (align?: Alignment, position?: Position, rtl?: boolean): Placement | undefined => { - const alignment = shouldAlignToCenter(position, align) ? 'center' : align; - - const computedPosition = position && getPositionMap(rtl)[position]; - const computedAlignment = alignment && getAlignmentMap()[alignment]; - - if (computedPosition && computedAlignment) { - return `${computedPosition}-${computedAlignment}` as Placement; + if (!position) { + return undefined; } - - return computedPosition; + const block = position === 'above' || position === 'below'; + const side: Side = block + ? position === 'above' + ? 'top' + : 'bottom' + : (position === 'before') !== !!rtl + ? 'left' + : 'right'; + const alignedVertically = align === 'top' || align === 'bottom'; + if (!align || align === 'center' || block === alignedVertically) { + return side; + } + return `${side}-${align === 'start' || align === 'top' ? 'start' : 'end'}`; }; diff --git a/packages/react-components/react-positioning/library/src/utils/toggleScrollListener.test.ts b/packages/react-components/react-positioning/library/src/utils/toggleScrollListener.test.ts deleted file mode 100644 index 286366bae692e..0000000000000 --- a/packages/react-components/react-positioning/library/src/utils/toggleScrollListener.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { toggleScrollListener } from './toggleScrollListener'; -describe('toggleScrollListener', () => { - beforeEach(jest.clearAllMocks); - - it('should add event listener for scroll', () => { - const handler = jest.fn(); - toggleScrollListener(document.createElement('div'), null, handler); - document.body.dispatchEvent(new CustomEvent('scroll')); - - expect(handler).toHaveBeenCalledTimes(1); - }); - - it('should remove previous event listener', () => { - const handler = jest.fn(); - const next = document.createElement('div'); - const prev = document.createElement('div'); - const prevScrollParent = document.createElement('div'); - prevScrollParent.style.overflow = 'scroll'; - prevScrollParent.appendChild(prev); - - toggleScrollListener(prev, null, handler); - toggleScrollListener(next, prev, handler); - prevScrollParent.dispatchEvent(new CustomEvent('scroll')); - - expect(handler).toHaveBeenCalledTimes(0); - }); - - it('should do nothing if previous and next elements are the same', () => { - const handler = jest.fn(); - const element = document.createElement('div'); - const addEventListener = jest.spyOn(document.body, 'addEventListener'); - const removeEventListener = jest.spyOn(document.body, 'removeEventListener'); - toggleScrollListener(element, null, handler); - toggleScrollListener(element, element, handler); - - expect(removeEventListener).toHaveBeenCalledTimes(0); - expect(addEventListener).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/react-components/react-positioning/library/src/utils/toggleScrollListener.ts b/packages/react-components/react-positioning/library/src/utils/toggleScrollListener.ts deleted file mode 100644 index 7b7b40f0009e9..0000000000000 --- a/packages/react-components/react-positioning/library/src/utils/toggleScrollListener.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { isHTMLElement } from '@fluentui/react-utilities'; -import type { PositioningVirtualElement } from '../types'; -import { getScrollParent } from './getScrollParent'; - -/** - * Toggles event listeners for scroll parent. - * Cleans up the event listeners for the previous element and adds them for the new scroll parent. - * @param next Next element - * @param prev Previous element - */ -export function toggleScrollListener( - next: HTMLElement | PositioningVirtualElement | null, - prev: HTMLElement | PositioningVirtualElement | null, - handler: EventListener, -): void { - if (next === prev) { - return; - } - - if (isHTMLElement(prev)) { - const prevScrollParent = getScrollParent(prev); - prevScrollParent.removeEventListener('scroll', handler); - } - if (isHTMLElement(next)) { - const scrollParent = getScrollParent(next); - scrollParent.addEventListener('scroll', handler); - } -} diff --git a/packages/react-components/react-positioning/library/src/utils/writeArrowUpdates.ts b/packages/react-components/react-positioning/library/src/utils/writeArrowUpdates.ts index 37e6c843b2f31..ed46c68ec36b5 100644 --- a/packages/react-components/react-positioning/library/src/utils/writeArrowUpdates.ts +++ b/packages/react-components/react-positioning/library/src/utils/writeArrowUpdates.ts @@ -12,7 +12,7 @@ export function writeArrowUpdates(options: { arrow: HTMLElement | null; middlewa const { x: arrowX, y: arrowY } = middlewareData.arrow; Object.assign(arrow.style, { - left: arrowX !== null && arrowX !== undefined ? `${arrowX}px` : '', - top: arrowY !== null && arrowY !== undefined ? `${arrowY}px` : '', + left: typeof arrowX === 'number' ? `${arrowX}px` : '', + top: typeof arrowY === 'number' ? `${arrowY}px` : '', }); } diff --git a/packages/react-components/react-positioning/library/src/utils/writeContainerupdates.ts b/packages/react-components/react-positioning/library/src/utils/writeContainerupdates.ts index b1da52ae3bff7..ed62eeae40778 100644 --- a/packages/react-components/react-positioning/library/src/utils/writeContainerupdates.ts +++ b/packages/react-components/react-positioning/library/src/utils/writeContainerupdates.ts @@ -1,17 +1,24 @@ -import type { Placement, MiddlewareData, Strategy, Coords } from '@floating-ui/dom'; +import type { MiddlewareData, Coords } from '@floating-ui/dom'; import { DATA_POSITIONING_ESCAPED, DATA_POSITIONING_HIDDEN, DATA_POSITIONING_INTERSECTING, DATA_POSITIONING_PLACEMENT, } from '../constants'; +import type { PositioningPlacement } from '../types'; + +type Visibility = { + referenceHidden: boolean; + escaped: boolean; + intersecting: boolean; +}; /** * Writes all container element position updates after the position is computed */ export function writeContainerUpdates(options: { container: HTMLElement | null; - placement: Placement; + placement: PositioningPlacement; middlewareData: MiddlewareData; /** * Layer acceleration can disable subpixel rendering which causes slightly @@ -19,29 +26,18 @@ export function writeContainerUpdates(options: { * instead */ lowPPI: boolean; - strategy: Strategy; coordinates: Coords; useTransform?: boolean; }): void { - const { container, placement, middlewareData, strategy, lowPPI, coordinates, useTransform = true } = options; + const { container, placement, middlewareData, lowPPI, coordinates, useTransform = true } = options; if (!container) { return; } container.setAttribute(DATA_POSITIONING_PLACEMENT, placement); - container.removeAttribute(DATA_POSITIONING_INTERSECTING); - if (middlewareData.intersectionObserver.intersecting) { - container.setAttribute(DATA_POSITIONING_INTERSECTING, ''); - } - - container.removeAttribute(DATA_POSITIONING_ESCAPED); - if (middlewareData.hide?.escaped) { - container.setAttribute(DATA_POSITIONING_ESCAPED, ''); - } - - container.removeAttribute(DATA_POSITIONING_HIDDEN); - if (middlewareData.hide?.referenceHidden) { - container.setAttribute(DATA_POSITIONING_HIDDEN, ''); - } + container.toggleAttribute(DATA_POSITIONING_INTERSECTING, !!middlewareData.intersectionObserver.intersecting); + const { escaped, referenceHidden } = middlewareData.hide || {}; + container.toggleAttribute(DATA_POSITIONING_ESCAPED, !!escaped); + container.toggleAttribute(DATA_POSITIONING_HIDDEN, !!referenceHidden); // Round so that the coordinates land on device pixels. // This prevents blurriness in cases where the browser doesn't apply pixel snapping, such as when other effects like @@ -51,10 +47,6 @@ export function writeContainerUpdates(options: { const x = Math.round(coordinates.x * devicePixelRatio) / devicePixelRatio; const y = Math.round(coordinates.y * devicePixelRatio) / devicePixelRatio; - Object.assign(container.style, { - position: strategy, - }); - if (useTransform) { Object.assign(container.style, { transform: lowPPI ? `translate(${x}px, ${y}px)` : `translate3d(${x}px, ${y}px, 0)`, @@ -67,3 +59,24 @@ export function writeContainerUpdates(options: { top: `${y}px`, }); } + +/** + * Writes the attributes that tell if the container or the target are visible + */ +export function writeVisibilityUpdates(options: { + container: HTMLElement; + visibility: Visibility; + /** Names of the attributes, an empty string skips an attribute */ + attributes?: Partial>; +}): void { + const { container, visibility, attributes } = options; + const toggle = (name: string | undefined, fallback: string, value: boolean) => { + if (name !== '') { + container.toggleAttribute(name ?? fallback, value); + } + }; + + toggle(attributes?.intersecting, DATA_POSITIONING_INTERSECTING, visibility.intersecting); + toggle(attributes?.escaped, DATA_POSITIONING_ESCAPED, visibility.escaped); + toggle(attributes?.hidden, DATA_POSITIONING_HIDDEN, visibility.referenceHidden); +} diff --git a/yarn.lock b/yarn.lock index 9ad37cfeeb584..14b7845f72b3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2495,15 +2495,6 @@ __metadata: languageName: node linkType: hard -"@floating-ui/devtools@npm:^0.2.3": - version: 0.2.3 - resolution: "@floating-ui/devtools@npm:0.2.3" - peerDependencies: - "@floating-ui/dom": ^1.0.0 - checksum: 10c0/f4e1c830e1806ac4d20e02d3bc06255c24858a52c1b96516fc2d145c0ab8f016910667246be2b14bc63dab8e9c12bea46bc4757bf0c24416c612541f5822f7e5 - languageName: node - linkType: hard - "@floating-ui/dom@npm:1.6.12, @floating-ui/dom@npm:^1.6.12": version: 1.6.12 resolution: "@floating-ui/dom@npm:1.6.12" @@ -5295,8 +5286,6 @@ __metadata: version: 0.0.0-use.local resolution: "@fluentui/react-positioning@workspace:packages/react-components/react-positioning/library" dependencies: - "@floating-ui/devtools": "npm:^0.2.3" - "@floating-ui/dom": "npm:^1.6.12" "@fluentui/react-shared-contexts": "npm:^9.26.4" "@fluentui/react-theme": "npm:^9.2.2" "@fluentui/react-utilities": "npm:^9.27.0"