From 29e1f3999972ab0e8b281ceda20a99504ff40cd5 Mon Sep 17 00:00:00 2001 From: mainframev Date: Fri, 25 Sep 2026 14:45:34 +0200 Subject: [PATCH 01/11] feat(react-positioning): add floatingUIPositioningEngine --- ...sitioning-8c1f2a3e-positioning-engine.json | 7 + .../library/etc/react-positioning.api.md | 78 ++++++++ .../createFloatingUIPositioningEngine.test.ts | 154 +++++++++++++++ .../src/createFloatingUIPositioningEngine.ts | 91 +++++++++ .../react-positioning/library/src/index.ts | 9 + .../library/src/resolvePositioningOptions.ts | 167 ++++++++++++++++ .../react-positioning/library/src/types.ts | 32 +++- .../library/src/usePositioningOptions.ts | 181 +++++------------- 8 files changed, 581 insertions(+), 138 deletions(-) create mode 100644 change/@fluentui-react-positioning-8c1f2a3e-positioning-engine.json create mode 100644 packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts create mode 100644 packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts create mode 100644 packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts diff --git a/change/@fluentui-react-positioning-8c1f2a3e-positioning-engine.json b/change/@fluentui-react-positioning-8c1f2a3e-positioning-engine.json new file mode 100644 index 0000000000000..e3e061b4bb439 --- /dev/null +++ b/change/@fluentui-react-positioning-8c1f2a3e-positioning-engine.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add floatingUIPositioningEngine", + "packageName": "@fluentui/react-positioning", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} 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..b9a9bae66f4b4 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,12 +32,23 @@ export type CreateArrowStylesOptions = { borderColor?: GriffelStyle['borderBottomColor']; }; +// @public +export function createFloatingUIPositioningEngine(engineOptions?: CreateFloatingUIPositioningEngineOptions): PositioningEngine; + +// @public (undocumented) +export type CreateFloatingUIPositioningEngineOptions = { + configuration?: PositioningConfigurationFn; +}; + // @public @deprecated export function createSlideStyles(mainAxis: number): GriffelStyle; // @public export function createVirtualElementFromClick(nativeEvent: MouseEvent): PositioningVirtualElement; +// @public +export const floatingUIPositioningEngine: PositioningEngine; + // @internal export function mergeArrowOffset(userOffset: Offset | undefined | null, arrowHeight: number): Offset; @@ -64,6 +75,16 @@ export type OffsetObject = { // @public (undocumented) export type OffsetShorthand = number; +// @public +export type OnPositioningEndEvent = CustomEvent; + +// @public +export type OnPositioningEndEventDetail = { + placement: PositioningPlacement; + escaped: boolean; + referenceHidden: boolean; +}; + // @public (undocumented) export type Position = 'above' | 'below' | 'before' | 'after'; @@ -89,12 +110,61 @@ export type PositioningConfigurationFnOptions = Omit; +// @public +export interface PositioningEngine { + // (undocumented) + create: (params: PositioningEngineCreateParams) => PositionManager; +} + +// @public +export type PositioningEngineCreateParams = { + container: HTMLElement; + target: TargetElement; + arrow: HTMLElement | null; + options: PositioningOptions; + dir?: 'ltr' | 'rtl'; + targetDocument?: Document; +}; + // @public (undocumented) export type PositioningImperativeRef = { updatePosition: () => void; setTarget: (target: TargetElement | null) => void; }; +// @public +export interface PositioningOptions { + align?: Alignment; + arrowPadding?: number; + autoSize?: AutoSize; + coverTarget?: boolean; + disableUpdateOnResize?: boolean; + enabled?: boolean; + fallbackPositions?: PositioningShorthandValue[]; + flipBoundary?: PositioningBoundary | null; + matchTargetSize?: 'width'; + offset?: Offset; + onPositioningEnd?: (e: OnPositioningEndEvent) => void; + overflowBoundary?: PositioningBoundary | null; + overflowBoundaryPadding?: number | Partial<{ + top: number; + end: number; + bottom: number; + start: number; + }>; + pinned?: boolean; + position?: Position; + // @deprecated + positionFixed?: boolean; + shiftToCoverTarget?: boolean; + strategy?: 'absolute' | 'fixed'; + unstable_disableTether?: boolean | 'all'; + useTransform?: boolean; +} + +// @public +export type PositioningPlacement = 'top' | 'top-start' | 'top-end' | 'right' | 'right-start' | 'right-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end'; + // @public export interface PositioningProps extends Pick { positioningRef?: React_2.Ref; @@ -130,6 +200,14 @@ export type PositioningVirtualElement = { contextElement?: Element; }; +// @public +export interface PositionManager { + // (undocumented) + dispose: () => void; + // (undocumented) + updatePosition: () => void; +} + // @public (undocumented) export function resolvePositioningShorthand(shorthand: PositioningShorthand | undefined | null): Readonly; diff --git a/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts new file mode 100644 index 0000000000000..4649a535865fe --- /dev/null +++ b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts @@ -0,0 +1,154 @@ +import { computePosition } from '@floating-ui/dom'; +import { createFloatingUIPositioningEngine, floatingUIPositioningEngine } from './createFloatingUIPositioningEngine'; +import { POSITIONING_END_EVENT } from './constants'; +import type { OnPositioningEndEvent, PositioningConfigurationFn } from './types'; + +jest.mock('@floating-ui/dom', () => ({ + ...jest.requireActual('@floating-ui/dom'), + computePosition: jest.fn(), +})); + +const computePositionMock = computePosition as jest.MockedFunction; + +const flushMicrotasks = async () => { + for (let i = 0; i < 5; i++) { + await new Promise(process.nextTick); + } +}; + +function createTestElements() { + const container = document.createElement('div'); + const target = document.createElement('button'); + + document.body.appendChild(container); + document.body.appendChild(target); + + return { container, target }; +} + +describe('createFloatingUIPositioningEngine', () => { + beforeEach(() => { + computePositionMock.mockReset(); + computePositionMock.mockResolvedValue({ + x: 10, + y: 20, + placement: 'bottom-start', + strategy: 'fixed', + middlewareData: { + intersectionObserver: { intersecting: false }, + hide: { escaped: false, referenceHidden: false }, + }, + }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('positions the container with the resolved placement and strategy', async () => { + const { container, target } = createTestElements(); + + const manager = floatingUIPositioningEngine.create({ + container, + target, + arrow: null, + options: { position: 'below', align: 'start', strategy: 'fixed' }, + }); + await flushMicrotasks(); + + expect(computePositionMock).toHaveBeenCalledWith( + target, + container, + expect.objectContaining({ placement: 'bottom-start', strategy: 'fixed' }), + ); + expect(container.style.position).toBe('fixed'); + expect(container).toHaveAttribute('data-popper-placement', 'bottom-start'); + + manager.dispose(); + }); + + it('honors rtl direction when resolving the placement', async () => { + const { container, target } = createTestElements(); + + const manager = floatingUIPositioningEngine.create({ + container, + target, + arrow: null, + dir: 'rtl', + options: { position: 'after', align: 'top' }, + }); + await flushMicrotasks(); + + expect(computePositionMock).toHaveBeenCalledWith( + target, + container, + expect.objectContaining({ placement: 'left-start' }), + ); + + manager.dispose(); + }); + + it('subscribes onPositioningEnd and unsubscribes on dispose', async () => { + const { container, target } = createTestElements(); + const onPositioningEnd = jest.fn(); + + const manager = floatingUIPositioningEngine.create({ + container, + target, + arrow: null, + options: { onPositioningEnd }, + }); + await flushMicrotasks(); + + expect(onPositioningEnd).toHaveBeenCalledTimes(1); + expect(onPositioningEnd.mock.calls[0][0].detail.placement).toBe('bottom-start'); + + manager.dispose(); + container.dispatchEvent(new CustomEvent(POSITIONING_END_EVENT, { detail: { placement: 'top' } })); + + expect(onPositioningEnd).toHaveBeenCalledTimes(1); + }); + + it('does nothing when enabled is false', async () => { + const { container, target } = createTestElements(); + + const manager = floatingUIPositioningEngine.create({ + container, + target, + arrow: null, + options: { enabled: false }, + }); + await flushMicrotasks(); + + expect(computePositionMock).not.toHaveBeenCalled(); + expect(() => manager.updatePosition()).not.toThrow(); + expect(() => manager.dispose()).not.toThrow(); + }); + + it('runs the configuration function over the merged options', async () => { + const { container, target } = createTestElements(); + const configuration = jest.fn, Parameters>( + ({ options }) => ({ ...options, position: 'above' }), + ); + const engine = createFloatingUIPositioningEngine({ configuration }); + + const manager = engine.create({ + container, + target, + arrow: null, + options: { position: 'below', autoSize: true }, + }); + await flushMicrotasks(); + + expect(configuration).toHaveBeenCalledWith( + expect.objectContaining({ + container, + arrow: null, + options: expect.objectContaining({ position: 'below', autoSize: true }), + }), + ); + expect(computePositionMock).toHaveBeenCalledWith(target, container, expect.objectContaining({ placement: 'top' })); + + manager.dispose(); + }); +}); diff --git a/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts new file mode 100644 index 0000000000000..26d623201971a --- /dev/null +++ b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts @@ -0,0 +1,91 @@ +import { POSITIONING_END_EVENT } from './constants'; +import { createPositionManager } from './createPositionManager'; +import { resolvePositioningOptions } from './resolvePositioningOptions'; +import type { + PositioningConfigurationFn, + PositioningEngine, + PositioningEngineCreateParams, + PositionManager, +} from './types'; + +const NOOP_MANAGER: PositionManager = { + updatePosition: () => undefined, + dispose: () => undefined, +}; + +const DEFAULT_CONFIGURATION: PositioningConfigurationFn = ({ options }) => options; + +export type CreateFloatingUIPositioningEngineOptions = { + /** + * Equivalent of `PositioningConfigurationProvider` for engine consumers: lets you post-process the + * resolved options for every surface positioned by this engine. + */ + configuration?: PositioningConfigurationFn; +}; + +/** + * Creates a {@link PositioningEngine} backed by Floating UI — the same implementation that powers + * `usePositioning()` in Fluent UI React v9. + * + * Intended for consumers of `@fluentui/react-headless-components-preview` who need positioning + * features that CSS anchor positioning cannot express (`autoSize`, `flipBoundary`, + * `overflowBoundary`, virtual targets, etc.). + * + * @example + * ```tsx + * import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; + * + * + * ``` + */ +export function createFloatingUIPositioningEngine( + engineOptions: CreateFloatingUIPositioningEngineOptions = {}, +): PositioningEngine { + const { configuration = DEFAULT_CONFIGURATION } = engineOptions; + + return { + create: (params: PositioningEngineCreateParams): PositionManager => { + const { container, target, arrow, options, dir = 'ltr', targetDocument = container.ownerDocument } = params; + const { enabled = true, onPositioningEnd } = options; + + if (!enabled) { + return NOOP_MANAGER; + } + + const manager = createPositionManager({ + container, + target, + arrow, + ...resolvePositioningOptions({ + container, + arrow, + options, + isRtl: dir === 'rtl', + targetDocument, + configFn: configuration, + }), + }); + + // Cast because CustomEvent is not assignable to EventListener + const onPositioningEndListener = onPositioningEnd as EventListener | undefined; + if (onPositioningEndListener) { + container.addEventListener(POSITIONING_END_EVENT, onPositioningEndListener); + } + + return { + updatePosition: manager.updatePosition, + dispose: () => { + if (onPositioningEndListener) { + container.removeEventListener(POSITIONING_END_EVENT, onPositioningEndListener); + } + manager.dispose(); + }, + }; + }, + }; +} + +/** + * Default Floating UI {@link PositioningEngine}. See {@link createFloatingUIPositioningEngine}. + */ +export const floatingUIPositioningEngine: PositioningEngine = createFloatingUIPositioningEngine(); diff --git a/packages/react-components/react-positioning/library/src/index.ts b/packages/react-components/react-positioning/library/src/index.ts index 26430e1129759..f59052decf151 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 { createFloatingUIPositioningEngine, floatingUIPositioningEngine } from './createFloatingUIPositioningEngine'; +export type { CreateFloatingUIPositioningEngineOptions } from './createFloatingUIPositioningEngine'; export { usePositioningMouseTarget } from './usePositioningMouseTarget'; export { useSafeZoneArea } from './hooks/useSafeZoneArea/useSafeZoneArea'; export type { UseSafeZoneOptions } from './hooks/useSafeZoneArea/useSafeZoneArea'; @@ -24,10 +26,17 @@ export type { OffsetFunctionParam, OffsetObject, OffsetShorthand, + OnPositioningEndEvent, + OnPositioningEndEventDetail, Position, PositioningBoundary, + PositioningEngine, + PositioningEngineCreateParams, PositioningImperativeRef, + PositioningOptions, + PositioningPlacement, PositioningProps, + PositionManager, PositioningRect, PositioningShorthand, PositioningShorthandValue, diff --git a/packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts b/packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts new file mode 100644 index 0000000000000..57febae9217aa --- /dev/null +++ b/packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts @@ -0,0 +1,167 @@ +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 { + 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'; + +/** + * @internal + */ +export type ResolvedPositioningOptions = { + placement: Placement | undefined; + middleware: Middleware[]; + strategy: Strategy; + disableUpdateOnResize?: boolean; + useTransform?: boolean; +}; + +/** + * @internal + */ +export type ResolvePositioningOptionsParams = { + container: HTMLElement; + arrow: HTMLElement | null; + options: PositioningOptions; + isRtl: boolean; + targetDocument: Document | undefined; + configFn: PositioningConfigurationFn; +}; + +/** + * Picks the options that are forwarded to a `PositioningConfigurationFn`. Options that only make sense + * to the React layer (`enabled`, `onPositioningEnd`, `positionFixed`, `target`, `positioningRef`) are + * intentionally left out. + */ +function toConfigurationFnOptions(options: PositioningOptions): PositioningConfigurationFnOptions { + const { + align, + arrowPadding, + autoSize, + coverTarget, + disableUpdateOnResize, + flipBoundary, + offset, + overflowBoundary, + pinned, + position, + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_disableTether, + strategy, + overflowBoundaryPadding, + fallbackPositions, + useTransform, + matchTargetSize, + shiftToCoverTarget, + } = options; + + return { + autoSize, + disableUpdateOnResize, + matchTargetSize, + offset, + strategy, + coverTarget, + flipBoundary, + overflowBoundary, + useTransform, + overflowBoundaryPadding, + pinned, + arrowPadding, + align, + fallbackPositions, + shiftToCoverTarget, + position, + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_disableTether, + }; +} + +/** + * Translates Fluent positioning options into a Floating UI placement + middleware chain. + * + * Pure with respect to React: it can be called from a hook (see `usePositioningOptions`) or from an + * imperative engine (see `createFloatingUIPositioningEngine`). + * + * @internal + */ +export function resolvePositioningOptions(params: ResolvePositioningOptionsParams): ResolvedPositioningOptions { + const { container, arrow, options, isRtl, targetDocument, configFn } = params; + const { + // eslint-disable-next-line @typescript-eslint/no-deprecated + positionFixed, + } = options; + + const hasScrollableElement = hasScrollParent(container); + + const optionsAfterEnhancement = configFn({ container, arrow, options: toConfigurationFnOptions(options) }); + 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, + }; +} diff --git a/packages/react-components/react-positioning/library/src/types.ts b/packages/react-components/react-positioning/library/src/types.ts index 762d3153be86f..7b1b12f7f1ac0 100644 --- a/packages/react-components/react-positioning/library/src/types.ts +++ b/packages/react-components/react-positioning/library/src/types.ts @@ -66,13 +66,43 @@ export type OffsetFunctionParam = { export type TargetElement = HTMLElement | PositioningVirtualElement; /** - * @internal + * Handle to a running positioning instance that lives outside of the React lifecycle. */ export interface PositionManager { updatePosition: () => void; dispose: () => void; } +/** + * Arguments a {@link PositioningEngine} receives when it is asked to start positioning a surface. + */ +export type PositioningEngineCreateParams = { + /** The positioned element */ + container: HTMLElement; + /** Element (or virtual element) that the container is anchored to */ + target: TargetElement; + /** Arrow that points from the container to the target */ + arrow: HTMLElement | null; + /** Fully merged positioning options */ + options: PositioningOptions; + /** Text direction of the surface; defaults to `ltr` */ + dir?: 'ltr' | 'rtl'; + /** Document the surface is rendered into; defaults to `container.ownerDocument` */ + targetDocument?: Document; +}; + +/** + * A pluggable positioning implementation. + * + * Engines are plain objects, not hooks: `create` is invoked imperatively (typically from a layout + * effect) once the container and target elements are known, and the returned {@link PositionManager} + * is disposed when they change or unmount. Because no React hook is involved, an engine can be + * supplied through props or context without any rules-of-hooks constraints. + */ +export interface PositioningEngine { + create: (params: PositioningEngineCreateParams) => PositionManager; +} + export interface UsePositioningReturn { // React refs are supposed to be contravariant // (allows a more general type to be passed rather than a more specific one) diff --git a/packages/react-components/react-positioning/library/src/usePositioningOptions.ts b/packages/react-components/react-positioning/library/src/usePositioningOptions.ts index ef8719797caca..e1a1a64d4eb77 100644 --- a/packages/react-components/react-positioning/library/src/usePositioningOptions.ts +++ b/packages/react-components/react-positioning/library/src/usePositioningOptions.ts @@ -1,36 +1,25 @@ '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 } from './types'; import { usePositioningConfiguration } from './PositioningConfigurationContext'; +import { resolvePositioningOptions } from './resolvePositioningOptions'; +import type { ResolvedPositioningOptions } from './resolvePositioningOptions'; /** - * 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, +export function usePositioningOptions( options: PositioningOptions, -): (container: HTMLElement, arrow: HTMLElement | null) => PositioningConfigurationFnOptions { +): (container: HTMLElement, arrow: HTMLElement | null) => ResolvedPositioningOptions { + const { dir, targetDocument } = useFluent(); + const isRtl = dir === 'rtl'; + const configFn = usePositioningConfiguration(); + + // Individual options are listed as dependencies (rather than the `options` object) so that a new + // options object with the same values does not recreate the position manager. const { align, arrowPadding, @@ -50,144 +39,62 @@ function usePositioningConfigFn( useTransform, matchTargetSize, shiftToCoverTarget, + // eslint-disable-next-line @typescript-eslint/no-deprecated + positionFixed, } = options; return React.useCallback( - (container: HTMLElement, arrow: HTMLElement | null) => { - return configFn({ + (container: HTMLElement, arrow: HTMLElement | null) => + resolvePositioningOptions({ container, arrow, + isRtl, + targetDocument, + configFn, options: { + align, + arrowPadding, autoSize, - disableUpdateOnResize, - matchTargetSize, - offset, - strategy, coverTarget, + disableUpdateOnResize, flipBoundary, + offset, overflowBoundary, - useTransform, - overflowBoundaryPadding, pinned, - arrowPadding, - align, - fallbackPositions, - shiftToCoverTarget, position, // eslint-disable-next-line @typescript-eslint/naming-convention unstable_disableTether, + strategy, + overflowBoundaryPadding, + fallbackPositions, + useTransform, + matchTargetSize, + shiftToCoverTarget, + positionFixed, }, - }); - }, + }), [ + isRtl, + targetDocument, + configFn, + align, + arrowPadding, autoSize, - disableUpdateOnResize, - matchTargetSize, - offset, - strategy, coverTarget, + disableUpdateOnResize, flipBoundary, + offset, overflowBoundary, - useTransform, - overflowBoundaryPadding, pinned, - arrowPadding, - align, - fallbackPositions, - shiftToCoverTarget, position, unstable_disableTether, - configFn, + strategy, + overflowBoundaryPadding, + fallbackPositions, + useTransform, + matchTargetSize, + shiftToCoverTarget, + positionFixed, ], ); } - -/** - * @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], - ); -} From cc45edd64772585563a7e3aec05ce3f79eb53253 Mon Sep 17 00:00:00 2001 From: mainframev Date: Fri, 25 Sep 2026 14:45:37 +0200 Subject: [PATCH 02/11] feat(react-headless-components): support positioning engine injection --- .../src/PolyfillsAndFallbacks.mdx | 27 +- ...s-preview-8c1f2a3e-positioning-engine.json | 7 + .../library/bundle-isolation.config.json | 2 +- .../library/etc/avatar-group.api.md | 1 + .../library/etc/combobox.api.md | 1 + .../library/etc/dropdown.api.md | 1 + .../library/etc/info-label.api.md | 1 + .../library/etc/menu.api.md | 1 + .../library/etc/popover.api.md | 6 +- .../library/etc/positioning.api.md | 17 +- .../library/etc/tag-picker.api.md | 1 + .../library/etc/teaching-popover.api.md | 1 + .../library/etc/tooltip.api.md | 1 + .../src/components/Popover/Popover.types.ts | 5 +- .../PopoverSurface/usePopoverSurface.ts | 2 +- .../Popover/PositioningEngine.cy.tsx | 105 ++++++++ .../src/components/Popover/popoverContext.ts | 1 + .../src/components/Popover/usePopover.ts | 5 +- .../src/components/Tooltip/useTooltip.ts | 7 +- .../PositioningEngineContext.ts | 26 ++ .../library/src/hooks/usePositioning/index.ts | 3 +- .../library/src/hooks/usePositioning/types.ts | 36 ++- .../usePositioning/useCssAnchorPositioning.ts | 210 +++++++++++++++ .../usePositioning/useEnginePositioning.ts | 190 ++++++++++++++ .../usePositioning/usePositioning.test.tsx | 184 +++++++++++++ .../hooks/usePositioning/usePositioning.ts | 243 ++++++------------ .../src/hooks/usePositioning/utils/index.ts | 1 + .../utils/toHeadlessPlacement.test.ts | 30 +++ .../utils/toHeadlessPlacement.ts | 32 +++ .../library/src/positioning.ts | 2 + .../Positioning/PositioningDescription.md | 11 +- .../Positioning/PositioningEngine.stories.tsx | 94 +++++++ .../PositioningEngineDescription.md | 19 ++ .../Concepts/Positioning/index.stories.tsx | 1 + .../Concepts/Positioning/utils.stories.tsx | 23 +- 35 files changed, 1065 insertions(+), 232 deletions(-) create mode 100644 change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useCssAnchorPositioning.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/toHeadlessPlacement.test.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/toHeadlessPlacement.ts create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md diff --git a/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx b/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx index 5b14381d246f1..3897597b7662a 100644 --- a/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx +++ b/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx @@ -19,9 +19,11 @@ experience to provide. Fluent UI Headless leaves that tradeoff to the consuming anchor-positioning polyfill. This keeps the common path lean and requires little application code, although a polyfill may implement only part of the positioning behavior listed in the [browser support matrix](?path=/docs/overview-browser-support--docs#detailed-feature-support). -2. **Use product-owned positioning logic.** When `CSS.supports('anchor-name', '--x')` returns `false`, - hand the layer to a measurement-based positioner. This adds implementation work, but lets the - product control placement, flipping, and collision handling. +2. **Supply a JavaScript positioning engine.** When `CSS.supports('anchor-name', '--x')` returns `false`, + hand every surface to `floatingUIPositioningEngine` from `@fluentui/react-positioning` through + `PositioningEngineProvider`. This is the same Floating UI positioner Fluent UI React v9 uses, and it + also unlocks options CSS cannot express (`autoSize`, boundaries, …). The engine is only bundled when + you import it. 3. **Allow static placement.** Keep the native fallback behavior: the surface opens, but it may appear at its document position rather than beside the trigger. Products aimed primarily at browsers with native support may find this sufficient. @@ -57,8 +59,21 @@ if (!hasPopover) { Overlays are positioned relative to their trigger with [CSS anchor positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning). -The headless library does **not** include a JavaScript positioning fallback, so without support these -overlays render at their static position. Use the feature detection and fallback options above, and -load any polyfill before Fluent UI Headless renders. Consult the +The headless library does **not** bundle a JavaScript positioning fallback, so without support these +overlays render at their static position. Either load a polyfill before Fluent UI Headless renders, or +opt into the Floating UI engine explicitly: + +```tsx +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; + +const supportsAnchorPositioning = typeof CSS !== 'undefined' && CSS.supports('anchor-name', '--x'); + + + +; +``` + +Consult the [browser support matrix](?path=/docs/overview-browser-support--docs#detailed-feature-support) for the individual CSS properties Fluent UI Headless uses. diff --git a/change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json b/change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json new file mode 100644 index 0000000000000..84becd87a12f3 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: support positioning engine injection", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/bundle-isolation.config.json b/packages/react-components/react-headless-components-preview/library/bundle-isolation.config.json index 3042e4e686254..233765b603ee2 100644 --- a/packages/react-components/react-headless-components-preview/library/bundle-isolation.config.json +++ b/packages/react-components/react-headless-components-preview/library/bundle-isolation.config.json @@ -2,7 +2,7 @@ "$schema": "../../../../tools/verify-bundle-isolation/schema.json", "fixturesRoot": "./bundle-size", "externals": ["react", "react-dom", "react/jsx-runtime", "react/compiler-runtime"], - "forbiddenPackages": ["tabster", "@griffel/*", "@fluentui/react-icons"], + "forbiddenPackages": ["tabster", "@griffel/*", "@fluentui/react-icons", "@floating-ui/*"], "fixtures": { "AllComponents.fixture.js": {} } diff --git a/packages/react-components/react-headless-components-preview/library/etc/avatar-group.api.md b/packages/react-components/react-headless-components-preview/library/etc/avatar-group.api.md index 06a694c68c15a..d6a47605031dd 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/avatar-group.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/avatar-group.api.md @@ -23,6 +23,7 @@ import type { JSXElement } from '@fluentui/react-utilities'; import { PartitionAvatarGroupItems } from '@fluentui/react-avatar'; import { partitionAvatarGroupItems } from '@fluentui/react-avatar'; import { PartitionAvatarGroupItemsOptions } from '@fluentui/react-avatar'; +import type { PositioningEngine } from '@fluentui/react-positioning'; import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md b/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md index 8981e7e30adfa..1d495d5d8b8f5 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md @@ -20,6 +20,7 @@ import { OptionGroupState } from '@fluentui/react-combobox'; import { OptionProps } from '@fluentui/react-combobox'; import { OptionSlots } from '@fluentui/react-combobox'; import type { OptionState as OptionState_2 } from '@fluentui/react-combobox'; +import type { PositioningEngine } from '@fluentui/react-positioning'; import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md b/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md index d4bf188cc83e9..6a9d8c9f65a65 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md @@ -19,6 +19,7 @@ import { OptionGroupState } from '@fluentui/react-combobox'; import { OptionProps } from '@fluentui/react-combobox'; import { OptionSlots } from '@fluentui/react-combobox'; import type { OptionState as OptionState_2 } from '@fluentui/react-combobox'; +import type { PositioningEngine } from '@fluentui/react-positioning'; import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/info-label.api.md b/packages/react-components/react-headless-components-preview/library/etc/info-label.api.md index 07d06d34f44dc..ceea1a2350542 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/info-label.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/info-label.api.md @@ -11,6 +11,7 @@ import type { EventHandler } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import { LabelBaseProps } from '@fluentui/react-label'; +import type { PositioningEngine } from '@fluentui/react-positioning'; import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import * as React_2 from 'react'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/menu.api.md b/packages/react-components/react-headless-components-preview/library/etc/menu.api.md index 47225a7d4c8ab..ce418893a56c2 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/menu.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/menu.api.md @@ -49,6 +49,7 @@ import { MenuBaseState as MenuState } from '@fluentui/react-menu'; import { MenuTriggerChildProps } from '@fluentui/react-menu'; import { MenuTriggerProps } from '@fluentui/react-menu'; import { MenuTriggerState } from '@fluentui/react-menu'; +import type { PositioningEngine } from '@fluentui/react-positioning'; import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import * as React_2 from 'react'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/popover.api.md b/packages/react-components/react-headless-components-preview/library/etc/popover.api.md index 3a42a8eaacc18..3c04e91050b31 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/popover.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/popover.api.md @@ -13,6 +13,7 @@ import type { EventHandler } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import type { PopoverTriggerChildProps as PopoverTriggerChildProps_2 } from '@fluentui/react-popover'; +import type { PositioningEngine } from '@fluentui/react-positioning'; import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import * as React_2 from 'react'; @@ -35,10 +36,7 @@ export const Popover: { // @public export type PopoverContextValue = Pick & { - positioning: { - targetRef: React_2.RefCallback; - containerRef: React_2.RefCallback; - }; + positioning: PositioningReturn; }; // @public diff --git a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md index 8125ce4176703..a4c44dbbc4b4b 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md @@ -6,10 +6,11 @@ import { Alignment } from '@fluentui/react-positioning'; import { Position } from '@fluentui/react-positioning'; +import type { PositioningEngine } from '@fluentui/react-positioning'; import { PositioningImperativeRef } from '@fluentui/react-positioning'; import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; import { PositioningShorthandValue } from '@fluentui/react-positioning'; -import type * as React_2 from 'react'; +import * as React_2 from 'react'; export { Alignment } @@ -25,15 +26,23 @@ export function getPlacementString(position: Position, align: Alignment): Positi export { Position } +export { PositioningEngine } + +// @public +export const PositioningEngineProvider: React_2.Provider; + export { PositioningImperativeRef } -// @public (undocumented) -export type PositioningProps = Pick; +// @public +export type PositioningProps = PositioningProps_2 & { + engine?: PositioningEngine; +}; // @public (undocumented) export type PositioningReturn = { targetRef: React_2.RefCallback; containerRef: React_2.RefCallback; + arrowRef: React_2.RefCallback; }; // @public (undocumented) @@ -52,7 +61,7 @@ export const POSITIONS: { // @public (undocumented) export const resolvePositioningShorthand: ResolvePositioningShorthand; -// @public (undocumented) +// @public export function usePositioning(options: PositioningProps): PositioningReturn; // (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..cbe122c79baea 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,6 +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 { PositioningEngine } from '@fluentui/react-positioning'; import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; 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 f46f42a220695..3f2cd915822e3 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,6 +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 { PositioningEngine } from '@fluentui/react-positioning'; import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import * as React_2 from 'react'; 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..89e1bd7e33e31 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,6 +6,7 @@ import type { JSXElement } from '@fluentui/react-utilities'; import { OnVisibleChangeData } from '@fluentui/react-tooltip'; +import type { PositioningEngine } from '@fluentui/react-positioning'; import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts index 0a062ecf08fa1..71de2855441c8 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts @@ -132,8 +132,5 @@ export type PopoverContextValue = Pick< | 'surfaceId' | 'trapFocus' > & { - positioning: { - targetRef: React.RefCallback; - containerRef: React.RefCallback; - }; + positioning: PositioningReturn; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts index 84faf520ee494..28cf5689e7601 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts @@ -34,7 +34,7 @@ export const usePopoverSurface = ( const state: PopoverSurfaceState = { withArrow, - arrowRef, + arrowRef: useMergedRefs(arrowRef, positioningCtx.arrowRef), components: { root: 'dialog' }, root: slot.always( { diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx new file mode 100644 index 0000000000000..f223ad542c069 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx @@ -0,0 +1,105 @@ +import * as React from 'react'; +import { mount as mountBase } from '@fluentui/scripts-cypress'; +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import type { JSXElement } from '@fluentui/react-utilities'; +import { Popover } from './Popover'; +import { PopoverTrigger } from './PopoverTrigger/PopoverTrigger'; +import { PopoverSurface } from './PopoverSurface/PopoverSurface'; +import { PositioningEngineProvider } from '../../positioning'; + +const mount = (element: JSXElement) => { + mountBase(element); +}; + +const surfaceSelector = '[data-popover-surface]'; + +describe('Popover positioning engine', () => { + it('positions with CSS anchor positioning by default', () => { + mount( + + + + + Surface + , + ); + + cy.get(surfaceSelector) + .should('have.attr', 'data-placement', 'below-start') + .and($el => { + expect($el[0].style.getPropertyValue('position-anchor')).to.match(/^--popover-anchor-/); + expect($el[0]).not.to.have.attr('data-popper-placement'); + }); + }); + + it('delegates to an inline engine, which replaces CSS anchor positioning', () => { + mount( +
+ + + + + Surface + +
, + ); + + cy.get(surfaceSelector) + .should('have.attr', 'data-popper-placement', 'bottom-start') + .and('have.attr', 'data-placement', 'below-start') + .and($el => { + expect($el[0].style.getPropertyValue('position-anchor')).to.equal(''); + expect($el[0].style.transform).to.match(/translate/); + expect($el[0].style.position).to.equal('fixed'); + }); + + cy.get('button').then($trigger => { + const trigger = $trigger[0].getBoundingClientRect(); + cy.get(surfaceSelector).then($surface => { + const surface = $surface[0].getBoundingClientRect(); + expect(Math.round(surface.left)).to.equal(Math.round(trigger.left)); + expect(surface.top).to.be.closeTo(trigger.bottom, 1); + }); + }); + }); + + it('applies engine-only options such as autoSize', () => { + mount( +
+ + + + + +
Tall content
+
+
+
, + ); + + cy.get(surfaceSelector).should($el => { + expect($el[0].style.maxHeight).to.match(/px$/); + expect($el[0].getBoundingClientRect().bottom).to.be.at.most(window.innerHeight + 1); + }); + }); + + it('falls back to the engine supplied by PositioningEngineProvider and positions the arrow', () => { + mount( + +
+ + + + + Surface + +
+
, + ); + + cy.get(surfaceSelector).should('have.attr', 'data-popper-placement', 'right'); + cy.get('[data-arrow]').should($arrow => { + expect($arrow[0].style.top).to.match(/px$/); + }); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/popoverContext.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/popoverContext.ts index 30fc3c1212517..d3002f6071688 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/popoverContext.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/popoverContext.ts @@ -22,6 +22,7 @@ const popoverContextDefaultValue: PopoverContextValue = { positioning: { targetRef: () => undefined, containerRef: () => undefined, + arrowRef: () => undefined, }, surfaceId: '', }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts index 0d4069cce4ff4..9c3ef0c663490 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts @@ -207,10 +207,7 @@ export const usePopoverContextValues = (state: PopoverState): { popover: Popover openOnContext, withArrow, trapFocus, - positioning: { - targetRef: positioning.targetRef, - containerRef: positioning.containerRef, - }, + positioning, surfaceId, }, }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts index 301a94ad7aa49..3bd184c03d480 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts @@ -45,6 +45,9 @@ export const useTooltip = (props: TooltipProps): TooltipState => { hideDelay = 250, } = props; + const positioningOptions = resolvePositioningShorthand(positioning); + const { targetRef, containerRef, arrowRef } = usePositioning(positioningOptions); + const state: TooltipState = { positioning, showDelay, @@ -53,6 +56,7 @@ export const useTooltip = (props: TooltipProps): TooltipState => { visible, shouldRenderTooltip: visible, withArrow, + arrowRef, // Slots components: { content: 'div', @@ -66,9 +70,6 @@ export const useTooltip = (props: TooltipProps): TooltipState => { }), }; - const positioningOptions = resolvePositioningShorthand(positioning); - const { targetRef, containerRef } = usePositioning(positioningOptions); - state.content.id = useId('tooltip-', state.content.id); state.content['data-open'] = toDataAttributeValue(state.visible); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts new file mode 100644 index 0000000000000..296327928c8b8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts @@ -0,0 +1,26 @@ +'use client'; + +import * as React from 'react'; +import type { PositioningEngine } from '@fluentui/react-positioning'; + +const PositioningEngineContext = React.createContext(undefined); + +/** + * Supplies a default positioning engine to every headless component below it. A component-level + * `positioning={{ engine }}` still takes precedence. + * + * @example + * ```tsx + * import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; + * import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; + * + * + * + * + * ``` + */ +export const PositioningEngineProvider: React.Provider = + PositioningEngineContext.Provider; + +export const usePositioningEngineContext = (): PositioningEngine | undefined => + React.useContext(PositioningEngineContext); 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..b9755067353fb 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 @@ -1,8 +1,9 @@ export { resolvePositioningShorthand } from './resolvePositioningShorthand'; export { usePositioning } from './usePositioning'; +export { PositioningEngineProvider } from './PositioningEngineContext'; export { getPlacementString } from './utils'; export { POSITIONS, ALIGNMENTS } from './constants'; -export type { PositioningProps, PositioningShorthand, PositioningReturn } from './types'; +export type { PositioningEngine, PositioningProps, PositioningShorthand, PositioningReturn } from './types'; export type { Alignment, 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..a34c112b58f8a 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,28 +1,38 @@ import type * as React from 'react'; import type { + PositioningEngine, PositioningProps as CanonicalPositioningProps, PositioningShorthandValue, } from '@fluentui/react-positioning'; +export type { PositioningEngine }; + 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' ->; +/** + * Positioning options accepted by headless components. + * + * This is the canonical `PositioningProps` contract from `@fluentui/react-positioning` plus an optional + * `engine`. Without an engine, surfaces are positioned with CSS anchor positioning, which honors + * `position`, `align`, `offset`, `coverTarget`, `matchTargetSize`, `pinned`, `fallbackPositions`, + * `strategy`, `target` and `positioningRef`. The remaining options (`autoSize`, `flipBoundary`, + * `overflowBoundary`, `overflowBoundaryPadding`, `shiftToCoverTarget`, `arrowPadding`, `useTransform`, + * `disableUpdateOnResize`, `onPositioningEnd`) require an engine and are ignored otherwise — a warning + * is logged in development. + */ +export type PositioningProps = CanonicalPositioningProps & { + /** + * A JavaScript positioning engine that replaces CSS anchor positioning for this surface entirely. + * When set (or provided through `PositioningEngineProvider`), the engine owns every positioning + * option, including the ones the CSS engine also supports. + */ + engine?: PositioningEngine; +}; export type PositioningShorthand = PositioningProps | PositioningShorthandValue; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useCssAnchorPositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useCssAnchorPositioning.ts new file mode 100644 index 0000000000000..1390eca19b641 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useCssAnchorPositioning.ts @@ -0,0 +1,210 @@ +'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 type { PositioningShorthandValue, PositioningVirtualElement } from '@fluentui/react-positioning'; +import type { PositioningProps } from './types'; +import { POSITIONS, ALIGNMENTS, POSITION_AREA_MAP } from './constants'; +import { getPlacementString, normalizeAlign } from './utils/placement'; +import { applyOffset, getCoverSelfAlignment, resolveElementRef, resolveOffset, shorthandToPositionArea } from './utils'; +import { usePlacementObserver } from './usePlacementObserver'; + +export type TargetElement = HTMLElement | PositioningVirtualElement; + +const DEFAULT_FLIP = ['flip-block', 'flip-inline', 'flip-block flip-inline']; + +const EMPTY_FALLBACK_POSITIONS: PositioningShorthandValue[] = []; + +/** + * Reads the current anchor-name property from an element and parses it into an array of names. + * Handles comma-separated values and trimming. + */ +const readAnchorNames = (element: HTMLElement): string[] => { + return element.style + .getPropertyValue('anchor-name') + .split(',') + .map(name => name.trim()) + .filter(Boolean); +}; + +export type UseCssAnchorPositioningOptions = Pick< + PositioningProps, + | 'align' + | 'coverTarget' + | 'fallbackPositions' + | 'matchTargetSize' + | 'offset' + | 'pinned' + | 'position' + | 'strategy' + | 'target' +> & { + /** When false, the hook keeps its state but writes nothing to the DOM. */ + enabled: boolean; +}; + +export type UseCssAnchorPositioningReturn = { + targetRef: React.RefCallback; + containerRef: React.RefCallback; + setTarget: (target: TargetElement | null) => void; + updatePosition: () => void; +}; + +/** + * Positions a surface with native CSS anchor positioning. + */ +export function useCssAnchorPositioning(options: UseCssAnchorPositioningOptions): UseCssAnchorPositioningReturn { + const { + enabled, + pinned, + target: customTarget = null, + align: alignInput = ALIGNMENTS.center, + position = POSITIONS.above, + fallbackPositions = EMPTY_FALLBACK_POSITIONS, + offset, + coverTarget = false, + strategy = 'fixed', + matchTargetSize, + } = options; + + const align = normalizeAlign(alignInput); + + const { mainAxis, crossAxis } = resolveOffset(offset); + const coverAlignment = React.useMemo( + () => (coverTarget ? getCoverSelfAlignment(position, align) : null), + [coverTarget, position, align], + ); + + const [triggerEl, setTriggerEl] = React.useState(null); + const [containerEl, setContainerEl] = React.useState(null); + const [imperativeTarget, setImperativeTarget] = React.useState(null); + const effectiveTarget = enabled ? imperativeTarget ?? resolveElementRef(customTarget) ?? triggerEl : null; + + const anchorName = `--${useId('popover-anchor-')}`; + const positionArea = POSITION_AREA_MAP[position][align]; + const placement = getPlacementString(position, align); + + const { targetDocument } = useFluent(); + + const fallbackAreas = React.useMemo(() => fallbackPositions.map(shorthandToPositionArea), [fallbackPositions]); + + const updatePosition = usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget || !enabled); + + const setTarget = React.useCallback((el: TargetElement | null) => { + setImperativeTarget(resolveElementRef(el)); + }, []); + + useIsomorphicLayoutEffect(() => { + if (!effectiveTarget) { + return; + } + + // `anchor-name` is a comma-separated list. Append this instance's name + // instead of overwriting so that multiple positioned popovers can share a + // single trigger (e.g. a Tooltip on hover and a Menu on click attached to + // the same button) without clobbering each other's anchor. On cleanup we + // remove only our own name, preserving any others still in use. + if (anchorName) { + const names = readAnchorNames(effectiveTarget); + if (!names.includes(anchorName)) { + effectiveTarget.style.setProperty('anchor-name', [...names, anchorName].join(', ')); + } + } + + return () => { + if (anchorName) { + const remaining = readAnchorNames(effectiveTarget).filter(name => name !== anchorName); + if (remaining.length > 0) { + effectiveTarget.style.setProperty('anchor-name', remaining.join(', ')); + } else { + effectiveTarget.style.removeProperty('anchor-name'); + } + } + }; + }, [effectiveTarget, anchorName]); + + const targetRef: React.RefCallback = React.useCallback(node => { + setTriggerEl(node); + }, []); + + const containerRef: React.RefCallback = React.useCallback( + node => { + setContainerEl(node); + + if (!node || !enabled) { + return; + } + + node.style.setProperty('position', strategy); + node.style.setProperty('inset', 'auto'); + node.style.setProperty('margin', '0'); + + applyOffset(node, position, mainAxis, crossAxis); + + if (matchTargetSize === 'width') { + node.style.setProperty('width', 'anchor-size(width)'); + } else { + node.style.removeProperty('width'); + } + + node.style.setProperty('position-anchor', anchorName); + node.setAttribute('data-placement', placement); + + if (coverAlignment) { + node.style.setProperty('position-area', 'center'); + node.style.setProperty('align-self', coverAlignment.alignSelf); + node.style.setProperty('justify-self', coverAlignment.justifySelf); + node.style.removeProperty('position-try-fallbacks'); + return; + } + + node.style.setProperty('position-area', positionArea); + + /* + * Workaround for https://crbug.com/438334710: Chromium (<=130-ish) doesn't + apply the implicit `anchor-center` self-alignment that the spec defines + for single-keyword `position-area` values (`block-start`, `block-end`, + ` inline-start`, `inline-end`) or `span-all`. + */ + if (align === ALIGNMENTS.center) { + node.style.setProperty('place-self', 'anchor-center'); + } else { + node.style.removeProperty('place-self'); + node.style.removeProperty('align-self'); + node.style.removeProperty('justify-self'); + } + + if (pinned) { + node.style.removeProperty('position-try-fallbacks'); + return; + } + + if (fallbackAreas.length > 0) { + node.style.setProperty('position-try-fallbacks', fallbackAreas.join(', ')); + } else { + node.style.setProperty('position-try-fallbacks', DEFAULT_FLIP.join(', ')); + } + }, + [ + enabled, + anchorName, + positionArea, + placement, + fallbackAreas, + pinned, + position, + align, + mainAxis, + crossAxis, + coverAlignment, + strategy, + matchTargetSize, + ], + ); + + return React.useMemo( + () => ({ targetRef, containerRef, setTarget, updatePosition }), + [targetRef, containerRef, setTarget, updatePosition], + ); +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts new file mode 100644 index 0000000000000..9a880aa3112e7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts @@ -0,0 +1,190 @@ +'use client'; + +import * as React from 'react'; +import { canUseDOM, useEventCallback, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import type { + OnPositioningEndEvent, + PositioningEngine, + PositioningOptions, + PositioningVirtualElement, + PositionManager, +} from '@fluentui/react-positioning'; +import type { PositioningProps } from './types'; +import { ALIGNMENTS, POSITIONS } from './constants'; +import { getPlacementString, toHeadlessPlacement } from './utils'; + +type TargetElement = HTMLElement | PositioningVirtualElement; + +export type UseEnginePositioningOptions = Omit & { + /** `undefined` disables the hook: it keeps its state but never creates a manager. */ + engine: PositioningEngine | undefined; +}; + +export type UseEnginePositioningReturn = { + targetRef: React.RefCallback; + containerRef: React.RefCallback; + arrowRef: React.RefCallback; + setTarget: (target: TargetElement | null) => void; + updatePosition: () => void; +}; + +/** + * Memoizes positioning options field-by-field, so a fresh options object with equal values does not + * recreate the engine's position manager. Mirrors `usePositioningOptions` in react-positioning. + */ +function useStablePositioningOptions(options: UseEnginePositioningOptions): PositioningOptions { + const { + align, + arrowPadding, + autoSize, + coverTarget, + disableUpdateOnResize, + fallbackPositions, + flipBoundary, + matchTargetSize, + offset, + overflowBoundary, + overflowBoundaryPadding, + pinned, + position, + shiftToCoverTarget, + strategy, + useTransform, + } = options; + + return React.useMemo( + () => ({ + align, + arrowPadding, + autoSize, + coverTarget, + disableUpdateOnResize, + fallbackPositions, + flipBoundary, + matchTargetSize, + offset, + overflowBoundary, + overflowBoundaryPadding, + pinned, + position, + shiftToCoverTarget, + strategy, + useTransform, + }), + [ + align, + arrowPadding, + autoSize, + coverTarget, + disableUpdateOnResize, + fallbackPositions, + flipBoundary, + matchTargetSize, + offset, + overflowBoundary, + overflowBoundaryPadding, + pinned, + position, + shiftToCoverTarget, + strategy, + useTransform, + ], + ); +} + +/** + * Positions a surface with an injected {@link PositioningEngine}. + * + * The engine is plain data: it is only ever invoked inside a layout effect, so it can come from props + * or context and change identity freely without affecting hook order. + * + * Two concerns stay here regardless of engine because they belong to the surface, not the positioner: + * the top-layer reset (the UA `[popover]:popover-open` stylesheet applies `inset: 0; margin: auto`, + * which would stretch a surface that only receives `left`/`top` from a JS positioner), and reporting + * the resolved placement through `data-placement` in the headless vocabulary. + */ +export function useEnginePositioning(options: UseEnginePositioningOptions): UseEnginePositioningReturn { + const { engine, target: customTarget = null, onPositioningEnd } = options; + const enabled = engine !== undefined; + + const { dir, targetDocument } = useFluent(); + const isRtl = dir === 'rtl'; + + const [triggerEl, setTriggerEl] = React.useState(null); + const [containerEl, setContainerEl] = React.useState(null); + const [arrowEl, setArrowEl] = React.useState(null); + const [imperativeTarget, setImperativeTarget] = React.useState(null); + const managerRef = React.useRef(null); + + const effectiveTarget = enabled ? imperativeTarget ?? customTarget ?? triggerEl : null; + const initialPlacement = getPlacementString(options.position ?? POSITIONS.above, options.align ?? ALIGNMENTS.center); + + const stableOptions = useStablePositioningOptions(options); + + const handlePositioningEnd = useEventCallback((event: OnPositioningEndEvent) => { + const container = event.currentTarget as HTMLElement | null; + if (container) { + container.setAttribute('data-placement', toHeadlessPlacement(event.detail.placement, isRtl)); + } + onPositioningEnd?.(event); + }); + + useIsomorphicLayoutEffect(() => { + if (!engine || !containerEl || !effectiveTarget || !canUseDOM()) { + return; + } + + const manager = engine.create({ + container: containerEl, + target: effectiveTarget, + arrow: arrowEl, + options: { ...stableOptions, onPositioningEnd: handlePositioningEnd }, + dir, + targetDocument, + }); + managerRef.current = manager; + + return () => { + manager.dispose(); + managerRef.current = null; + }; + }, [engine, containerEl, effectiveTarget, arrowEl, stableOptions, handlePositioningEnd, dir, targetDocument]); + + const targetRef: React.RefCallback = React.useCallback(node => { + setTriggerEl(node); + }, []); + + const arrowRef: React.RefCallback = React.useCallback(node => { + setArrowEl(node); + }, []); + + const containerRef: React.RefCallback = React.useCallback( + node => { + setContainerEl(node); + + if (!node || !enabled) { + return; + } + + // Top-layer reset, applied synchronously before the engine's first write. + node.style.setProperty('inset', 'auto'); + node.style.setProperty('margin', '0'); + node.setAttribute('data-placement', initialPlacement); + }, + [enabled, initialPlacement], + ); + + const setTarget = React.useCallback((el: TargetElement | null) => { + setImperativeTarget(el); + }, []); + + const updatePosition = React.useCallback(() => { + managerRef.current?.updatePosition(); + }, []); + + return React.useMemo( + () => ({ targetRef, containerRef, arrowRef, setTarget, updatePosition }), + [targetRef, containerRef, arrowRef, setTarget, updatePosition], + ); +} 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..25beb8da8dec1 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 @@ -1,6 +1,8 @@ import * as React from 'react'; import { act, render } from '@testing-library/react'; +import type { PositioningEngine, PositioningImperativeRef, PositionManager } from '@fluentui/react-positioning'; import { usePositioning } from './usePositioning'; +import { PositioningEngineProvider } from './PositioningEngineContext'; import { getPlacementString } from './utils/placement'; import type { PositioningProps, PositioningReturn } from './types'; @@ -230,6 +232,188 @@ describe('usePositioning', () => { expect(() => positioningRef.current?.updatePosition()).not.toThrow(); }); }); + + describe('engine', () => { + type FakeEngine = PositioningEngine & { + create: jest.Mock>; + manager: { updatePosition: jest.Mock; dispose: jest.Mock }; + }; + + const createFakeEngine = (): FakeEngine => { + const manager = { updatePosition: jest.fn(), dispose: jest.fn() }; + const engine = { manager, create: jest.fn(() => manager) }; + return engine as unknown as FakeEngine; + }; + + const Surface = (props: { + positioning: PositioningProps; + withArrow?: boolean; + onResult?: (result: PositioningReturn) => void; + }) => { + const result = usePositioning(props.positioning); + props.onResult?.(result); + + return ( + <> + + + +
    + {items.map(item => ( +
  • {item}
  • + ))} +
+
+ + + +
+

Per-instance engine: overflowBoundary

+

+ The surface flips and shifts against the dashed box instead of the viewport. Scroll the box to see the surface + follow the trigger and stay inside the boundary. +

+
+
+ trigger inside a scroll box} + > + Requested: above-start · boundaries: the dashed box + +
+
+
+ +
+

App-wide engine with an arrow

+

+ PositioningEngineProvider makes the engine the default for every headless surface below it. The + engine also positions the withArrow element. +

+ + + + + + + Positioned by the engine from context + + + +
+ + ); +}; + +Engine.parameters = { + docs: { + description: { + story: descriptionMd, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md new file mode 100644 index 0000000000000..619737f6cfe91 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md @@ -0,0 +1,19 @@ +By default headless surfaces are positioned with native CSS anchor positioning, which ships no JavaScript positioner. Some options of the canonical `positioning` contract cannot be expressed in CSS: `autoSize`, `flipBoundary`, `overflowBoundary`, `overflowBoundaryPadding`, `shiftToCoverTarget`, `arrowPadding`, `useTransform`, `disableUpdateOnResize` and `onPositioningEnd`. Passing them without an engine logs a development warning and has no effect. + +To use them, supply a **positioning engine**. `@fluentui/react-positioning` exports `floatingUIPositioningEngine`, the Floating UI implementation that powers Fluent UI React v9, so a headless app gets full feature parity by importing it explicitly: + +```tsx +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; + +// one surface +; + +// every surface below the provider + + +; +``` + +An engine **replaces** CSS anchor positioning for the surface — it owns every option, including the ones CSS also supports — so there is never a mix of two positioners. A component-level `engine` wins over the provider. Component-derived options (a submenu's `after`/`top` placement, a context menu's pointer target) are merged before the engine is invoked, so nothing needs restating at the call site. + +An engine is a plain object with a `create()` method invoked from a layout effect; it is never called as a hook, so it can come from props or context and change at any time. `@floating-ui/*` is not bundled unless you import the engine: the headless package's bundle-isolation check forbids it. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx index 498fe9f2b21cd..8e74a9671f3eb 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx @@ -12,6 +12,7 @@ export { FallbackPositions } from './PositioningFallbackPositions.stories'; export { FlippingBlock } from './PositioningFlippingBlock.stories'; export { FlippingInline } from './PositioningFlippingInline.stories'; export { FlippingCorner } from './PositioningFlippingCorner.stories'; +export { Engine } from './PositioningEngine.stories'; export default { title: 'Concepts/Positioning', diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/utils.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/utils.stories.tsx index 9e438478da05e..ef567aa6f5a7f 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/utils.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/utils.stories.tsx @@ -1,25 +1,10 @@ import * as React from 'react'; import type { PositioningProps } from '@fluentui/react-headless-components-preview/positioning'; -/** - * Subset of `PositioningProps` that the headless preview's `usePositioning` actually consumes. - * Props not destructured by the hook (e.g. `autoSize`, `flipBoundary`, `useTransform`) are excluded - * so Storybook's auto-generated args table only advertises what is supported. - */ -type SupportedPositioningProps = Omit< - PositioningProps, - | 'arrowPadding' - | 'autoSize' - | 'disableUpdateOnResize' - | 'flipBoundary' - | 'onPositioningEnd' - | 'overflowBoundary' - | 'overflowBoundaryPadding' - | 'shiftToCoverTarget' - | 'useTransform' ->; - /** * Helper component used by Storybook to auto-generate the positioning props args table. + * + * Every option of the canonical contract is accepted; options that CSS anchor positioning cannot + * express take effect only when a positioning `engine` is supplied (see the Engine example). */ -export const Positioning: React.FC = () =>
; +export const Positioning: React.FC = () =>
; From 00038ae675276e7496ca8e27869768b998d3feaa Mon Sep 17 00:00:00 2001 From: mainframev Date: Fri, 25 Sep 2026 15:15:42 +0200 Subject: [PATCH 03/11] refactor(react-positioning): make the engine own top-layer reset and data-placement --- .../usePositioning/useEnginePositioning.ts | 38 ++++++------------- .../usePositioning/usePositioning.test.tsx | 24 ++++++------ .../src/hooks/usePositioning/utils/index.ts | 1 - .../utils/toHeadlessPlacement.ts | 32 ---------------- .../library/src/constants.ts | 4 ++ .../createFloatingUIPositioningEngine.test.ts | 34 +++++++++++++++++ .../src/createFloatingUIPositioningEngine.ts | 35 ++++++++--------- .../react-positioning/library/src/types.ts | 8 ++++ .../library/src/utils/index.ts | 1 + .../toPositioningShorthandValue.test.ts} | 8 ++-- .../src/utils/toPositioningShorthandValue.ts | 22 +++++++++++ 11 files changed, 114 insertions(+), 93 deletions(-) delete mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/toHeadlessPlacement.ts rename packages/react-components/{react-headless-components-preview/library/src/hooks/usePositioning/utils/toHeadlessPlacement.test.ts => react-positioning/library/src/utils/toPositioningShorthandValue.test.ts} (71%) create mode 100644 packages/react-components/react-positioning/library/src/utils/toPositioningShorthandValue.ts diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts index 9a880aa3112e7..73889aacbaf40 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts @@ -12,7 +12,7 @@ import type { } from '@fluentui/react-positioning'; import type { PositioningProps } from './types'; import { ALIGNMENTS, POSITIONS } from './constants'; -import { getPlacementString, toHeadlessPlacement } from './utils'; +import { getPlacementString } from './utils'; type TargetElement = HTMLElement | PositioningVirtualElement; @@ -97,19 +97,15 @@ function useStablePositioningOptions(options: UseEnginePositioningOptions): Posi * Positions a surface with an injected {@link PositioningEngine}. * * The engine is plain data: it is only ever invoked inside a layout effect, so it can come from props - * or context and change identity freely without affecting hook order. - * - * Two concerns stay here regardless of engine because they belong to the surface, not the positioner: - * the top-layer reset (the UA `[popover]:popover-open` stylesheet applies `inset: 0; margin: auto`, - * which would stretch a surface that only receives `left`/`top` from a JS positioner), and reporting - * the resolved placement through `data-placement` in the headless vocabulary. + * or context and change identity freely without affecting hook order. Everything about the positioned + * box — coordinates, top-layer reset, `data-placement` — is the engine's responsibility (see the + * `PositioningEngine` contract); this hook only wires elements and options to it. */ export function useEnginePositioning(options: UseEnginePositioningOptions): UseEnginePositioningReturn { - const { engine, target: customTarget = null, onPositioningEnd } = options; + const { engine, target: customTarget = null } = options; const enabled = engine !== undefined; const { dir, targetDocument } = useFluent(); - const isRtl = dir === 'rtl'; const [triggerEl, setTriggerEl] = React.useState(null); const [containerEl, setContainerEl] = React.useState(null); @@ -121,14 +117,7 @@ export function useEnginePositioning(options: UseEnginePositioningOptions): UseE const initialPlacement = getPlacementString(options.position ?? POSITIONS.above, options.align ?? ALIGNMENTS.center); const stableOptions = useStablePositioningOptions(options); - - const handlePositioningEnd = useEventCallback((event: OnPositioningEndEvent) => { - const container = event.currentTarget as HTMLElement | null; - if (container) { - container.setAttribute('data-placement', toHeadlessPlacement(event.detail.placement, isRtl)); - } - onPositioningEnd?.(event); - }); + const onPositioningEnd = useEventCallback((event: OnPositioningEndEvent) => options.onPositioningEnd?.(event)); useIsomorphicLayoutEffect(() => { if (!engine || !containerEl || !effectiveTarget || !canUseDOM()) { @@ -139,7 +128,7 @@ export function useEnginePositioning(options: UseEnginePositioningOptions): UseE container: containerEl, target: effectiveTarget, arrow: arrowEl, - options: { ...stableOptions, onPositioningEnd: handlePositioningEnd }, + options: { ...stableOptions, onPositioningEnd }, dir, targetDocument, }); @@ -149,7 +138,7 @@ export function useEnginePositioning(options: UseEnginePositioningOptions): UseE manager.dispose(); managerRef.current = null; }; - }, [engine, containerEl, effectiveTarget, arrowEl, stableOptions, handlePositioningEnd, dir, targetDocument]); + }, [engine, containerEl, effectiveTarget, arrowEl, stableOptions, onPositioningEnd, dir, targetDocument]); const targetRef: React.RefCallback = React.useCallback(node => { setTriggerEl(node); @@ -163,14 +152,11 @@ export function useEnginePositioning(options: UseEnginePositioningOptions): UseE node => { setContainerEl(node); - if (!node || !enabled) { - return; + // Seed the requested placement so placement-keyed styling is right before the engine's first + // (asynchronous) update reports the resolved one. + if (node && enabled) { + node.setAttribute('data-placement', initialPlacement); } - - // Top-layer reset, applied synchronously before the engine's first write. - node.style.setProperty('inset', 'auto'); - node.style.setProperty('margin', '0'); - node.setAttribute('data-placement', initialPlacement); }, [enabled, initialPlacement], ); 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 25beb8da8dec1..93fa0f8cb9abb 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 @@ -290,31 +290,29 @@ describe('usePositioning', () => { expect(getByTestId('container').style.getPropertyValue('position-area')).toBe(''); }); - it('resets the UA top-layer inset and seeds data-placement before the engine runs', () => { + it('seeds data-placement with the requested placement before the engine runs', () => { const engine = createFakeEngine(); const { getByTestId } = render(); - expect(getByTestId('container')).toHaveStyle({ inset: 'auto', margin: '0px' }); expect(getByTestId('container')).toHaveAttribute('data-placement', 'after-top'); }); - it('mirrors the resolved placement into data-placement and forwards onPositioningEnd', () => { + it('forwards onPositioningEnd to the engine with a stable identity', () => { const engine = createFakeEngine(); - const onPositioningEnd = jest.fn(); - const { getByTestId } = render(); + const first = jest.fn(); + const second = jest.fn(); + const { rerender } = render(); + + rerender(); + expect(engine.create).toHaveBeenCalledTimes(1); - const container = getByTestId('container'); const event = new CustomEvent('fui-positioningend', { detail: { placement: 'top-end', escaped: false, referenceHidden: false }, }); - Object.defineProperty(event, 'currentTarget', { value: container }); - - act(() => { - engine.create.mock.calls[0][0].options.onPositioningEnd?.(event as never); - }); + engine.create.mock.calls[0][0].options.onPositioningEnd?.(event as never); - expect(container).toHaveAttribute('data-placement', 'above-end'); - expect(onPositioningEnd).toHaveBeenCalledWith(event); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith(event); }); it('disposes the manager on unmount', () => { 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 ae9ab192e84d8..8506fa430c612 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 @@ -4,4 +4,3 @@ export { debounce } from './debounce'; export { applyOffset, resolveOffset } from './offset'; export { getCoverSelfAlignment, getPlacementString, shorthandToPositionArea } from './placement'; export { resolveElementRef } from './resolveElementRef'; -export { toHeadlessPlacement } from './toHeadlessPlacement'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/toHeadlessPlacement.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/toHeadlessPlacement.ts deleted file mode 100644 index 676dc75e50b0a..0000000000000 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/toHeadlessPlacement.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { PositioningPlacement, PositioningShorthandValue } from '@fluentui/react-positioning'; - -const SIDE_TO_POSITION = { - top: 'above', - bottom: 'below', - left: 'before', - right: 'after', -} as const; - -/** - * Maps a physical Floating UI placement (as reported by `onPositioningEnd`) to the logical - * `data-placement` vocabulary headless components use, so consumer styling keyed on placement works - * regardless of the engine. - */ -export function toHeadlessPlacement(placement: PositioningPlacement, isRtl: boolean): PositioningShorthandValue { - const [side, alignment] = placement.split('-') as [keyof typeof SIDE_TO_POSITION, 'start' | 'end' | undefined]; - - let position = SIDE_TO_POSITION[side]; - if (isRtl && (position === 'before' || position === 'after')) { - position = position === 'before' ? 'after' : 'before'; - } - - if (!alignment) { - return position; - } - - if (position === 'before' || position === 'after') { - return `${position}-${alignment === 'start' ? 'top' : 'bottom'}`; - } - - return `${position}-${alignment}`; -} diff --git a/packages/react-components/react-positioning/library/src/constants.ts b/packages/react-components/react-positioning/library/src/constants.ts index 9f2ef12574bec..a2d0b83aa8e19 100644 --- a/packages/react-components/react-positioning/library/src/constants.ts +++ b/packages/react-components/react-positioning/library/src/constants.ts @@ -3,6 +3,10 @@ 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'; +/** + * Logical placement (`PositioningShorthandValue`) written by positioning engines on the container. + */ +export const DATA_PLACEMENT = 'data-placement'; /** * CSS custom properties used to encode the slide direction for positioning-aware enter animations. diff --git a/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts index 4649a535865fe..ad97f92f3a093 100644 --- a/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts +++ b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts @@ -63,6 +63,39 @@ describe('createFloatingUIPositioningEngine', () => { ); expect(container.style.position).toBe('fixed'); expect(container).toHaveAttribute('data-popper-placement', 'bottom-start'); + expect(container).toHaveAttribute('data-placement', 'below-start'); + + manager.dispose(); + }); + + it('releases the UA top-layer inset before positioning', () => { + const { container, target } = createTestElements(); + container.style.inset = '0px'; + + const manager = floatingUIPositioningEngine.create({ container, target, arrow: null, options: {} }); + + expect(container.style.getPropertyValue('inset')).toBe('auto'); + + manager.dispose(); + }); + + it('writes a logical data-placement in rtl', async () => { + const { container, target } = createTestElements(); + computePositionMock.mockResolvedValue({ + x: 0, + y: 0, + placement: 'left-start', + strategy: 'fixed', + middlewareData: { + intersectionObserver: { intersecting: false }, + hide: { escaped: false, referenceHidden: false }, + }, + }); + + const manager = floatingUIPositioningEngine.create({ container, target, arrow: null, dir: 'rtl', options: {} }); + await flushMicrotasks(); + + expect(container).toHaveAttribute('data-placement', 'after-top'); manager.dispose(); }); @@ -107,6 +140,7 @@ describe('createFloatingUIPositioningEngine', () => { container.dispatchEvent(new CustomEvent(POSITIONING_END_EVENT, { detail: { placement: 'top' } })); expect(onPositioningEnd).toHaveBeenCalledTimes(1); + expect(container).toHaveAttribute('data-placement', 'below-start'); }); it('does nothing when enabled is false', async () => { diff --git a/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts index 26d623201971a..dc2f79b1d8593 100644 --- a/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts +++ b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts @@ -1,12 +1,14 @@ -import { POSITIONING_END_EVENT } from './constants'; +import { DATA_PLACEMENT, POSITIONING_END_EVENT } from './constants'; import { createPositionManager } from './createPositionManager'; import { resolvePositioningOptions } from './resolvePositioningOptions'; import type { + OnPositioningEndEvent, PositioningConfigurationFn, PositioningEngine, PositioningEngineCreateParams, PositionManager, } from './types'; +import { toPositioningShorthandValue } from './utils'; const NOOP_MANAGER: PositionManager = { updatePosition: () => undefined, @@ -31,12 +33,6 @@ export type CreateFloatingUIPositioningEngineOptions = { * features that CSS anchor positioning cannot express (`autoSize`, `flipBoundary`, * `overflowBoundary`, virtual targets, etc.). * - * @example - * ```tsx - * import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; - * - * - * ``` */ export function createFloatingUIPositioningEngine( engineOptions: CreateFloatingUIPositioningEngineOptions = {}, @@ -47,11 +43,24 @@ export function createFloatingUIPositioningEngine( create: (params: PositioningEngineCreateParams): PositionManager => { const { container, target, arrow, options, dir = 'ltr', targetDocument = container.ownerDocument } = params; const { enabled = true, onPositioningEnd } = options; + const isRtl = dir === 'rtl'; if (!enabled) { return NOOP_MANAGER; } + // Top-layer surfaces (`[popover]:popover-open`, `dialog:modal`) receive `inset: 0` from the UA + // stylesheet. The manager only writes `left`/`top`, so `right`/`bottom` must be released first or + // the surface stretches across the viewport. + container.style.setProperty('inset', 'auto'); + + const handlePositioningEnd = (event: Event) => { + const { placement } = (event as OnPositioningEndEvent).detail; + container.setAttribute(DATA_PLACEMENT, toPositioningShorthandValue(placement, isRtl)); + onPositioningEnd?.(event as OnPositioningEndEvent); + }; + container.addEventListener(POSITIONING_END_EVENT, handlePositioningEnd); + const manager = createPositionManager({ container, target, @@ -60,24 +69,16 @@ export function createFloatingUIPositioningEngine( container, arrow, options, - isRtl: dir === 'rtl', + isRtl, targetDocument, configFn: configuration, }), }); - // Cast because CustomEvent is not assignable to EventListener - const onPositioningEndListener = onPositioningEnd as EventListener | undefined; - if (onPositioningEndListener) { - container.addEventListener(POSITIONING_END_EVENT, onPositioningEndListener); - } - return { updatePosition: manager.updatePosition, dispose: () => { - if (onPositioningEndListener) { - container.removeEventListener(POSITIONING_END_EVENT, onPositioningEndListener); - } + container.removeEventListener(POSITIONING_END_EVENT, handlePositioningEnd); manager.dispose(); }, }; diff --git a/packages/react-components/react-positioning/library/src/types.ts b/packages/react-components/react-positioning/library/src/types.ts index 7b1b12f7f1ac0..b550a739038f7 100644 --- a/packages/react-components/react-positioning/library/src/types.ts +++ b/packages/react-components/react-positioning/library/src/types.ts @@ -98,6 +98,14 @@ export type PositioningEngineCreateParams = { * effect) once the container and target elements are known, and the returned {@link PositionManager} * is disposed when they change or unmount. Because no React hook is involved, an engine can be * supplied through props or context without any rules-of-hooks constraints. + * + * Contract for engine authors — `create` must: + * - leave the container fully positioned, including releasing any UA-provided `inset` when the + * surface is in the top layer (`[popover]`, `dialog:modal`); + * - keep the container's `data-placement` attribute current with the resolved logical placement, as + * a {@link PositioningShorthandValue} (e.g. `above-start`, `after-top`); + * - invoke `options.onPositioningEnd` after each update, if provided; + * - undo all of the above in `dispose`. */ export interface PositioningEngine { create: (params: PositioningEngineCreateParams) => PositionManager; 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..674f26bf85ba8 100644 --- a/packages/react-components/react-positioning/library/src/utils/index.ts +++ b/packages/react-components/react-positioning/library/src/utils/index.ts @@ -7,6 +7,7 @@ export { mergeArrowOffset } from './mergeArrowOffset'; export { toFloatingUIPadding } from './toFloatingUIPadding'; export { toFloatingUIPlacement } from './toFloatingUIPlacement'; export { fromFloatingUIPlacement } from './fromFloatingUIPlacement'; +export { toPositioningShorthandValue } from './toPositioningShorthandValue'; export { resolvePositioningShorthand } from './resolvePositioningShorthand'; export { useCallbackRef } from './useCallbackRef'; export { debounce } from './debounce'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/toHeadlessPlacement.test.ts b/packages/react-components/react-positioning/library/src/utils/toPositioningShorthandValue.test.ts similarity index 71% rename from packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/toHeadlessPlacement.test.ts rename to packages/react-components/react-positioning/library/src/utils/toPositioningShorthandValue.test.ts index facc759d9badd..066da3c022440 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/toHeadlessPlacement.test.ts +++ b/packages/react-components/react-positioning/library/src/utils/toPositioningShorthandValue.test.ts @@ -1,6 +1,6 @@ -import { toHeadlessPlacement } from './toHeadlessPlacement'; +import { toPositioningShorthandValue } from './toPositioningShorthandValue'; -describe('toHeadlessPlacement', () => { +describe('toPositioningShorthandValue', () => { it.each([ ['top', 'above'], ['top-start', 'above-start'], @@ -15,7 +15,7 @@ describe('toHeadlessPlacement', () => { ['right-start', 'after-top'], ['right-end', 'after-bottom'], ] as const)('maps %s to %s in ltr', (placement, expected) => { - expect(toHeadlessPlacement(placement, false)).toBe(expected); + expect(toPositioningShorthandValue(placement, false)).toBe(expected); }); it.each([ @@ -25,6 +25,6 @@ describe('toHeadlessPlacement', () => { ['right-end', 'before-bottom'], ['top-start', 'above-start'], ] as const)('maps %s to %s in rtl', (placement, expected) => { - expect(toHeadlessPlacement(placement, true)).toBe(expected); + expect(toPositioningShorthandValue(placement, true)).toBe(expected); }); }); diff --git a/packages/react-components/react-positioning/library/src/utils/toPositioningShorthandValue.ts b/packages/react-components/react-positioning/library/src/utils/toPositioningShorthandValue.ts new file mode 100644 index 0000000000000..cf5e37862a36a --- /dev/null +++ b/packages/react-components/react-positioning/library/src/utils/toPositioningShorthandValue.ts @@ -0,0 +1,22 @@ +import type { Position, PositioningPlacement, PositioningShorthandValue } from '../types'; +import { fromFloatingUIPlacement } from './fromFloatingUIPlacement'; + +const RTL_POSITION_SWAP: Partial> = { before: 'after', after: 'before' }; + +/** + * Maps a physical Floating UI placement to the logical shorthand vocabulary (`above-start`, + * `after-top`, ...). In RTL the horizontal sides are swapped so `before`/`after` stay logical. + */ +export function toPositioningShorthandValue( + placement: PositioningPlacement, + isRtl: boolean, +): PositioningShorthandValue { + const { position: physicalPosition, alignment } = fromFloatingUIPlacement(placement); + const position = (isRtl && RTL_POSITION_SWAP[physicalPosition]) || physicalPosition; + + if (!alignment || alignment === 'center') { + return position; + } + + return `${position}-${alignment}` as PositioningShorthandValue; +} From 7210d11079d5cfe8f2268440015469669feb3cbe Mon Sep 17 00:00:00 2001 From: mainframev Date: Fri, 25 Sep 2026 15:40:27 +0200 Subject: [PATCH 04/11] docs(react-headless-components): add positioning engine stories mirroring v9 --- .../Positioning/PositioningEngine.stories.tsx | 115 ++++++------- .../PositioningEngineAutoSize.stories.tsx | 74 ++++++++ .../PositioningEngineDescription.md | 34 +++- ...itioningEngineDisableTransform.stories.tsx | 44 +++++ .../PositioningEngineFlipBoundary.stories.tsx | 52 ++++++ ...sitioningEngineListenToUpdates.stories.tsx | 79 +++++++++ ...itioningEngineOverflowBoundary.stories.tsx | 53 ++++++ ...gEngineOverflowBoundaryPadding.stories.tsx | 84 +++++++++ ...ningEngineOverflowBoundaryRect.stories.tsx | 73 ++++++++ ...ioningEngineShiftToCoverTarget.stories.tsx | 98 +++++++++++ .../Concepts/Positioning/index.stories.tsx | 8 + .../Positioning/positioning.module.css | 160 ++++++++++++++++++ 12 files changed, 803 insertions(+), 71 deletions(-) create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx index 182493b39b1dd..84a57855ac54e 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx @@ -3,87 +3,74 @@ import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headles import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; // Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole // `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. -// eslint-disable-next-line @fluentui/no-restricted-imports, import/no-extraneous-dependencies +// eslint-disable-next-line @fluentui/no-restricted-imports import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; -import { InlineAnchored } from './InlineAnchored'; import descriptionMd from './PositioningEngineDescription.md'; import styles from './positioning.module.css'; -const items = Array.from({ length: 40 }, (_, i) => `Item ${i + 1}`); - -export const Engine = (): React.ReactNode => { - const [boundary, setBoundary] = React.useState(null); +export const Engine = (): React.ReactNode => ( +
+
+

Per surface: the engine replaces CSS anchor positioning

+

+ Identical positioning props. The left surface is laid out by the browser with{' '} + position-area; the right one passes engine: floatingUIPositioningEngine, so Floating + UI computes left/top instead and no anchor CSS is written. +

+
+ + + + + + position-area Positioned by the browser. + + - return ( -
-
-

Per-instance engine: autoSize

-

- autoSize constrains the surface to the available viewport space and makes it scroll. CSS anchor - positioning cannot express this, so the popover delegates to floatingUIPositioningEngine. -

- + - + -
    - {items.map(item => ( -
  • {item}
  • - ))} -
+ engine Positioned by Floating UI.
-
+
+
-
-

Per-instance engine: overflowBoundary

-

- The surface flips and shifts against the dashed box instead of the viewport. Scroll the box to see the surface - follow the trigger and stay inside the boundary. -

-
-
- trigger inside a scroll box} - > - Requested: above-start · boundaries: the dashed box - -
-
-
+
+

App-wide: PositioningEngineProvider

+

+ The provider makes the engine the default for every headless surface beneath it; none of the surfaces below sets{' '} + engine. A component-level engine still wins over the provider. The engine also + receives the withArrow element and writes its left/top along the anchored + edge; CSS only paints it. +

+ +
+ + + + + + Engine from context. + + -
-

App-wide engine with an arrow

-

- PositioningEngineProvider makes the engine the default for every headless surface below it. The - engine also positions the withArrow element. -

- - + - + - - Positioned by the engine from context + + Flips to before near the right edge. - -
-
- ); -}; +
+ + +
+); Engine.parameters = { docs: { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx new file mode 100644 index 0000000000000..4d06ea2d4dc3b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; +// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole +// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. +// eslint-disable-next-line @fluentui/no-restricted-imports +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; + +import styles from './positioning.module.css'; +import menuStyles from '../../Menu/menu.module.css'; + +export const EngineAutoSize = (): React.ReactNode => { + const [boundary, setBoundary] = React.useState(null); + const [open, setOpen] = React.useState(false); + const [itemCount, setItemCount] = React.useState(10); + + return ( +
+
+ + +
+
+ setOpen(data.open)} + positioning={{ + overflowBoundary: boundary, + flipBoundary: boundary, + autoSize: true, + engine: floatingUIPositioningEngine, + }} + > + + + + + + {Array.from({ length: itemCount }, (_, i) => ( + + Item {i} + + ))} + + + +
+
+ ); +}; + +EngineAutoSize.parameters = { + docs: { + description: { + story: [ + '`autoSize` sets inline `max-width` and `max-height` styles on the element to ensure it fits within the', + 'available space, and makes it scroll. Increase the item count past what fits in the dashed box to see it', + 'kick in. Requires a positioning engine; component-derived options (the menu still opens `below-start`) are', + 'merged before the engine is invoked.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md index 619737f6cfe91..bd8874877adad 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md @@ -1,19 +1,39 @@ -By default headless surfaces are positioned with native CSS anchor positioning, which ships no JavaScript positioner. Some options of the canonical `positioning` contract cannot be expressed in CSS: `autoSize`, `flipBoundary`, `overflowBoundary`, `overflowBoundaryPadding`, `shiftToCoverTarget`, `arrowPadding`, `useTransform`, `disableUpdateOnResize` and `onPositioningEnd`. Passing them without an engine logs a development warning and has no effect. - -To use them, supply a **positioning engine**. `@fluentui/react-positioning` exports `floatingUIPositioningEngine`, the Floating UI implementation that powers Fluent UI React v9, so a headless app gets full feature parity by importing it explicitly: +By default headless surfaces are positioned by the browser with native CSS anchor positioning: no JavaScript positioner runs. A **positioning engine** swaps that for an imperative positioner, per surface or app-wide. ```tsx import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; // one surface -; +; -// every surface below the provider +// every surface below the provider (a component-level `engine` still wins) ; ``` -An engine **replaces** CSS anchor positioning for the surface — it owns every option, including the ones CSS also supports — so there is never a mix of two positioners. A component-level `engine` wins over the provider. Component-derived options (a submenu's `after`/`top` placement, a context menu's pointer target) are merged before the engine is invoked, so nothing needs restating at the call site. +### When you need one + +Every option of the canonical `positioning` contract is accepted, but these have no CSS equivalent and only take effect with an engine: `autoSize`, `flipBoundary`, `overflowBoundary`, `overflowBoundaryPadding`, `shiftToCoverTarget`, `arrowPadding`, `useTransform`, `disableUpdateOnResize`, `onPositioningEnd`. Passing them without an engine logs a development warning and does nothing. + +`floatingUIPositioningEngine` is the Floating UI implementation that powers Fluent UI React v9, so a headless app that imports it gets full v9 parity; the `Engine…` stories below reproduce the corresponding v9 positioning examples. `@floating-ui/*` is only bundled if you import the engine; the headless package's bundle-isolation check forbids it. + +### What an engine is + +```ts +interface PositioningEngine { + create(params: { + container: HTMLElement; // the surface + target: HTMLElement | PositioningVirtualElement; // trigger, or a virtual element for context menus + arrow: HTMLElement | null; // present when `withArrow` + options: PositioningOptions; // props merged with component-derived options (submenu side, pointer target…) + dir?: 'ltr' | 'rtl'; + targetDocument?: Document; + }): { updatePosition(): void; dispose(): void }; +} +``` + +`create()` is called from a layout effect once the elements exist and disposed when they change or unmount. It is a plain object, never a hook, so it can come from props or context and change identity freely. An engine **replaces** CSS anchor positioning entirely: it owns every option, including the ones CSS also supports, so two positioners never mix. -An engine is a plain object with a `create()` method invoked from a layout effect; it is never called as a hook, so it can come from props or context and change at any time. `@floating-ui/*` is not bundled unless you import the engine: the headless package's bundle-isolation check forbids it. +The engine keeps the container positioned (including releasing the UA top-layer `inset: 0; margin: auto` that `[popover]` and `dialog:modal` receive) and keeps the container's `data-placement` current with the resolved **logical** placement (`above-start`, `after-top`, …), so placement-keyed CSS and arrows work the same way as with CSS anchor positioning. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx new file mode 100644 index 0000000000000..657da4c0fd076 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; +// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole +// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. +// eslint-disable-next-line @fluentui/no-restricted-imports +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; + +import styles from './positioning.module.css'; + +export const EngineDisableTransform = (): React.ReactNode => ( +
+
+ + + + + + Positioned with transform: translate3d() + + + + + + + + + Positioned with left/top + + +
+
+); + +EngineDisableTransform.parameters = { + docs: { + description: { + story: [ + 'By default the engine positions the element with a [CSS transform](https://developer.mozilla.org/en-US/docs/Web/CSS/transform)', + 'for better performance. Set `useTransform: false` to receive plain `left`/`top` instead, e.g. when the surface', + 'has its own transform animation. Inspect the surfaces to compare their inline styles.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx new file mode 100644 index 0000000000000..cacab6e57fcd7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; +import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; +// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole +// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. +// eslint-disable-next-line @fluentui/no-restricted-imports +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; + +import styles from './positioning.module.css'; + +export const EngineFlipBoundary = (): React.ReactNode => { + const [boundary, setBoundary] = React.useState(null); + + return ( + +
+
+ + + + + + Stays within the flip boundary + + + + + + + + + Overflows the flip boundary + + +
+
+
+ ); +}; + +EngineFlipBoundary.parameters = { + docs: { + description: { + story: [ + 'The flip boundary can be configured manually so that the positioned element stays within bounds for', + 'different positions. `flipBoundary` has no CSS anchor positioning equivalent (`position-try-fallbacks` always', + 'flips against the containing block), so it requires a positioning engine — here supplied app-wide through', + '`PositioningEngineProvider`.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx new file mode 100644 index 0000000000000..ba3c0b66e88ba --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; +import type { + PositioningImperativeRef, + PositioningProps, +} from '@fluentui/react-headless-components-preview/positioning'; +// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole +// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. +// eslint-disable-next-line @fluentui/no-restricted-imports +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; + +import styles from './positioning.module.css'; + +type OnPositioningEndEvent = Parameters>[0]; + +export const EngineListenToUpdates = (): React.ReactNode => { + const [statusLog, setStatusLog] = React.useState([]); + const positioningRef = React.useRef(null); + + const onPositioningEnd = React.useCallback((event: OnPositioningEndEvent) => { + const { placement, escaped, referenceHidden } = event.detail; + const logical = (event.currentTarget as HTMLElement | null)?.getAttribute('data-placement'); + setStatusLog(log => + [ + `${new Date().toLocaleTimeString()} placement=${placement} (${logical}) escaped=${escaped} referenceHidden=${referenceHidden}`, + ...log, + ].slice(0, 8), + ); + }, []); + + return ( +
+
+ !data.open && setStatusLog([])} + positioning={{ positioningRef, onPositioningEnd, position: 'below', engine: floatingUIPositioningEngine }} + > + + + + + + + +
    + {statusLog.length === 0 ? ( +
  1. Open the popover to see position updates.
  2. + ) : ( + statusLog.map((entry, i) =>
  3. {entry}
  4. ) + )} +
+
+
+ ); +}; + +EngineListenToUpdates.parameters = { + docs: { + description: { + story: [ + 'Positioning happens outside of the React render lifecycle for performance purposes, so a position update', + 'does not need to be triggered by, or depend on, a re-render. To know when an element has been positioned,', + 'use the `onPositioningEnd` callback. `event.detail` carries the Floating UI `placement`, `escaped` and', + '`referenceHidden`; the engine has already mirrored the logical placement into `data-placement` on the surface', + 'by the time the callback runs.', + '', + 'CSS anchor positioning has no positioning lifecycle, so this callback requires a positioning engine.', + '', + '> ⚠️ _Very few use cases actually require listening to position updates. This is different from the_', + '_**open/close state**, which is handled in React._', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx new file mode 100644 index 0000000000000..253d29167b818 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; +import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; +// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole +// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. +// eslint-disable-next-line @fluentui/no-restricted-imports +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; + +import styles from './positioning.module.css'; + +export const EngineOverflowBoundary = (): React.ReactNode => { + const [boundary, setBoundary] = React.useState(null); + + return ( + +
+
+ + + + + + Shifted to stay within the overflow boundary + + + + + + + + + Overflows the dashed box + + +
+
+
+ ); +}; + +EngineOverflowBoundary.parameters = { + docs: { + description: { + story: [ + 'The overflow boundary can be configured manually so that the positioned element is shifted to stay within', + 'bounds for different alignments. `overflowBoundary` requires a positioning engine.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx new file mode 100644 index 0000000000000..2c2366e7be1e8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; +import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; +// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole +// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. +// eslint-disable-next-line @fluentui/no-restricted-imports +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; + +import styles from './positioning.module.css'; + +export const EngineOverflowBoundaryPadding = (): React.ReactNode => { + const [boundary, setBoundary] = React.useState(null); + const [padding, setPadding] = React.useState(8); + + return ( + +
+
+ +
+
+ + + + + + {padding}px padding from every boundary side + + + + + + + + + {padding}px padding from the boundary end only + + +
+
+
+ ); +}; + +EngineOverflowBoundaryPadding.parameters = { + docs: { + description: { + story: [ + 'The `overflowBoundaryPadding` property sets the padding between the positioned element and the chosen', + 'boundary. The padding can be a shorthand number which applies to all sides, or an object that explicitly', + 'sets the padding for each side. Requires a positioning engine.', + '', + '> _Design guidance recommends using **8px** or **4px** if a padding is required. Custom values are also_', + '_possible but should stay within a 4px grid._', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx new file mode 100644 index 0000000000000..3a88f25dc8d32 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; +import type { PositioningProps } from '@fluentui/react-headless-components-preview/positioning'; +// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole +// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. +// eslint-disable-next-line @fluentui/no-restricted-imports +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; + +import styles from './positioning.module.css'; + +export const EngineOverflowBoundaryRect = (): React.ReactNode => { + const boundaryRef = React.useRef(null); + const [boundaryRect, setBoundaryRect] = React.useState(null); + + // Measured once after mount; the popovers are closed at that point so no flicker is possible. + React.useEffect(() => { + setBoundaryRect(boundaryRef.current?.getBoundingClientRect() ?? null); + }, []); + + return ( +
+
+ Container +
+ Boundary +
+ + + + + + + Stays within the defined rect + + + + + + + + + Stays within the defined rect + + +
+
+ ); +}; + +EngineOverflowBoundaryRect.parameters = { + docs: { + description: { + story: [ + 'Boundaries can also be defined as `Rect` objects. This is useful when a boundary is not an actual element,', + 'but some kind of computed values. Requires a positioning engine.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx new file mode 100644 index 0000000000000..d55e5c923b46e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx @@ -0,0 +1,98 @@ +import * as React from 'react'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; +import type { Position, PositioningImperativeRef } from '@fluentui/react-headless-components-preview/positioning'; +// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole +// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. +// eslint-disable-next-line @fluentui/no-restricted-imports +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; + +import styles from './positioning.module.css'; +import menuStyles from '../../Menu/menu.module.css'; + +export const EngineShiftToCoverTarget = (): React.ReactNode => { + const [boundary, setBoundary] = React.useState(null); + const [open, setOpen] = React.useState(false); + const [itemCount, setItemCount] = React.useState(6); + const [position, setPosition] = React.useState('above'); + const positioningRef = React.useRef(null); + + // The boundary is user-resizable (`resize: both`); re-position when its size changes. + React.useEffect(() => { + if (!boundary) { + return; + } + const observer = new ResizeObserver(() => positioningRef.current?.updatePosition()); + observer.observe(boundary); + return () => observer.disconnect(); + }, [boundary]); + + return ( +
+
+ + + +
+
+
+ setOpen(data.open)} + positioning={{ + positioningRef, + overflowBoundary: boundary, + flipBoundary: boundary, + autoSize: true, + shiftToCoverTarget: true, + position, + engine: floatingUIPositioningEngine, + }} + > + + + + + + {Array.from({ length: itemCount }, (_, i) => ( + + Item {i} + + ))} + + + +
+
+
+ ); +}; + +EngineShiftToCoverTarget.parameters = { + docs: { + description: { + story: [ + "`shiftToCoverTarget` allows the positioned element to shift and cover the target element when there isn't", + 'enough space available to fit it. Drag the resize handle of the dashed box to shrink the available space.', + 'Requires a positioning engine.', + ].join('\n'), + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx index 8e74a9671f3eb..535b526dbd29c 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/index.stories.tsx @@ -13,6 +13,14 @@ export { FlippingBlock } from './PositioningFlippingBlock.stories'; export { FlippingInline } from './PositioningFlippingInline.stories'; export { FlippingCorner } from './PositioningFlippingCorner.stories'; export { Engine } from './PositioningEngine.stories'; +export { EngineFlipBoundary } from './PositioningEngineFlipBoundary.stories'; +export { EngineOverflowBoundary } from './PositioningEngineOverflowBoundary.stories'; +export { EngineOverflowBoundaryRect } from './PositioningEngineOverflowBoundaryRect.stories'; +export { EngineOverflowBoundaryPadding } from './PositioningEngineOverflowBoundaryPadding.stories'; +export { EngineAutoSize } from './PositioningEngineAutoSize.stories'; +export { EngineShiftToCoverTarget } from './PositioningEngineShiftToCoverTarget.stories'; +export { EngineDisableTransform } from './PositioningEngineDisableTransform.stories'; +export { EngineListenToUpdates } from './PositioningEngineListenToUpdates.stories'; export default { title: 'Concepts/Positioning', diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/positioning.module.css b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/positioning.module.css index f58e3bd8b437b..dc0bd1e0da73f 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/positioning.module.css +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/positioning.module.css @@ -327,6 +327,166 @@ font-size: 13px; } +/* ===== Engine stories (mirror the v9 positioning docs) ===== */ + +/* 300×300 boundary box, as in the v9 docsite. Modifiers arrange the triggers inside it. */ +.boundary { + box-sizing: border-box; + width: 300px; + height: 300px; + padding: var(--space-5); + border: var(--stroke-thick) dashed var(--danger); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; +} + +.boundarySpaceBetween { + justify-content: space-between; +} + +.boundaryAlignEnd { + align-items: flex-end; +} + +.boundaryCenter { + align-items: center; +} + +.boundaryScroll { + padding: 0; + overflow: auto; + resize: both; +} + +.boundaryScrollInner { + height: 600px; + padding-top: 200px; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.triggerBlock { + width: 150px; +} + +.triggerPushed { + margin-top: 60px; +} + +/* Overlay outline used by the `Rect` boundary demo: not an element boundary, just something to look at */ +.rectArea { + position: relative; + box-sizing: border-box; + width: 300px; + height: 300px; + padding: 60px var(--space-5) var(--space-5); + border: var(--stroke-thick) solid var(--danger); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; +} + +.rectAreaLabel, +.rectBoundaryLabel { + position: absolute; + top: 0; + left: 0; + padding: 2px var(--space-2); + font-size: 11px; + font-weight: 600; +} + +.rectAreaLabel { + color: var(--text-on-accent); + background: var(--danger); +} + +.rectBoundary { + position: absolute; + top: 50px; + left: 10px; + width: 320px; + height: 320px; + outline: var(--stroke-thick) solid var(--accent); + pointer-events: none; +} + +.rectBoundaryLabel { + color: var(--text-on-accent); + background: var(--accent); +} + +.badge { + display: inline-block; + margin-inline-end: var(--space-2); + padding: 0 var(--space-2); + border-radius: var(--radius-sm); + background: var(--accent); + color: var(--accent-contrast); + font-family: monospace; + font-size: 11px; + line-height: 18px; +} + +.log { + min-width: 320px; + margin: 0; + padding: var(--space-2) var(--space-3); + border: var(--stroke-thin) dashed var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-soft); + list-style: none; + font-family: monospace; + font-size: 11px; + color: var(--text); + white-space: pre; +} + +.logEmpty { + color: var(--text-muted); + white-space: normal; +} + +/* + Arrow for engine-positioned surfaces. The engine writes the arrow's inline `left`/`top` along the + anchored edge, so CSS only paints the square and pushes it half-way out of the side named by + `data-placement`. Unlike the CSS-anchored arrow, no `margin: auto` centering must be applied. +*/ +.surfaceEngineArrow { + overflow: visible; + border: 0; + box-shadow: none; + filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.12)) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.14)); +} + +.surfaceEngineArrow [data-arrow] { + position: absolute; + width: 12px; + height: 12px; + background: var(--bg-elev); + transform: rotate(45deg); +} + +.surfaceEngineArrow[data-placement^='above'] [data-arrow] { + bottom: -6px; +} + +.surfaceEngineArrow[data-placement^='below'] [data-arrow] { + top: -6px; +} + +.surfaceEngineArrow[data-placement^='before'] [data-arrow] { + right: -6px; +} + +.surfaceEngineArrow[data-placement^='after'] [data-arrow] { + left: -6px; +} + /* Adds the "Actual: " readout under any surface */ .flipReadout::after { content: 'Actual: ' attr(data-placement); From 000184d92e27d6e5e8a992bf4b6c1bd643e7ab81 Mon Sep 17 00:00:00 2001 From: mainframev Date: Fri, 25 Sep 2026 16:14:46 +0200 Subject: [PATCH 05/11] docs(react-headless-components): drop import rationale comment from engine stories --- .../src/Concepts/Positioning/PositioningEngine.stories.tsx | 2 -- .../Concepts/Positioning/PositioningEngineAutoSize.stories.tsx | 2 -- .../Positioning/PositioningEngineDisableTransform.stories.tsx | 2 -- .../Positioning/PositioningEngineFlipBoundary.stories.tsx | 2 -- .../Positioning/PositioningEngineListenToUpdates.stories.tsx | 2 -- .../Positioning/PositioningEngineOverflowBoundary.stories.tsx | 2 -- .../PositioningEngineOverflowBoundaryPadding.stories.tsx | 2 -- .../PositioningEngineOverflowBoundaryRect.stories.tsx | 2 -- .../Positioning/PositioningEngineShiftToCoverTarget.stories.tsx | 2 -- 9 files changed, 18 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx index 84a57855ac54e..44cf4e158d7ba 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx @@ -1,8 +1,6 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; -// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole -// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. // eslint-disable-next-line @fluentui/no-restricted-imports import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx index 4d06ea2d4dc3b..cf5f9eb6a3364 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx @@ -1,7 +1,5 @@ import * as React from 'react'; import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; -// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole -// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. // eslint-disable-next-line @fluentui/no-restricted-imports import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx index 657da4c0fd076..f614b8829d43c 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx @@ -1,7 +1,5 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole -// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. // eslint-disable-next-line @fluentui/no-restricted-imports import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx index cacab6e57fcd7..16189c0cfabc4 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx @@ -1,8 +1,6 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; -// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole -// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. // eslint-disable-next-line @fluentui/no-restricted-imports import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx index ba3c0b66e88ba..3e3d4e49e5469 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx @@ -4,8 +4,6 @@ import type { PositioningImperativeRef, PositioningProps, } from '@fluentui/react-headless-components-preview/positioning'; -// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole -// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. // eslint-disable-next-line @fluentui/no-restricted-imports import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx index 253d29167b818..75f462f74a98b 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx @@ -1,8 +1,6 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; -// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole -// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. // eslint-disable-next-line @fluentui/no-restricted-imports import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx index 2c2366e7be1e8..1aa7cd4930e4d 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx @@ -1,8 +1,6 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; -// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole -// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. // eslint-disable-next-line @fluentui/no-restricted-imports import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx index 3a88f25dc8d32..a1dfdfa35e8f3 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx @@ -1,8 +1,6 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; import type { PositioningProps } from '@fluentui/react-headless-components-preview/positioning'; -// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole -// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. // eslint-disable-next-line @fluentui/no-restricted-imports import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx index d55e5c923b46e..ee10572b191c5 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx @@ -1,8 +1,6 @@ import * as React from 'react'; import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; import type { Position, PositioningImperativeRef } from '@fluentui/react-headless-components-preview/positioning'; -// Headless consumers import the engine from `@fluentui/react-positioning` directly; pulling the whole -// `@fluentui/react-components` barrel for one export would defeat the purpose of the headless package. // eslint-disable-next-line @fluentui/no-restricted-imports import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; From 782f60f0eb497bb6775097890006e1d45952c2b2 Mon Sep 17 00:00:00 2001 From: mainframev Date: Sat, 26 Sep 2026 02:37:26 +0200 Subject: [PATCH 06/11] test(react-headless-components): guard CSS anchor assertions behind CSS.supports The React 17 integration run uses Cypress 13 / Electron 118, which predates CSS anchor positioning, so position-anchor is dropped and the observer reports the centred placement. --- .../Popover/PositioningEngine.cy.tsx | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx index f223ad542c069..5fb58951351b9 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx @@ -24,12 +24,23 @@ describe('Popover positioning engine', () => {
, ); - cy.get(surfaceSelector) - .should('have.attr', 'data-placement', 'below-start') - .and($el => { - expect($el[0].style.getPropertyValue('position-anchor')).to.match(/^--popover-anchor-/); - expect($el[0]).not.to.have.attr('data-popper-placement'); - }); + cy.get(surfaceSelector).should($el => { + expect($el[0]).not.to.have.attr('data-popper-placement'); + expect($el[0].style.transform).to.equal(''); + }); + + // `position-anchor` and the resolved placement only exist when the browser supports CSS anchor + // positioning (Chromium 125+). The React 17 integration run uses Cypress 13 / Electron 118, which + // drops the unknown property and leaves the surface at the UA's centred top-layer position. + cy.window().then(win => { + if (win.CSS?.supports?.('anchor-name: --x')) { + cy.get(surfaceSelector) + .should('have.attr', 'data-placement', 'below-start') + .and($el => { + expect($el[0].style.getPropertyValue('position-anchor')).to.match(/^--popover-anchor-/); + }); + } + }); }); it('delegates to an inline engine, which replaces CSS anchor positioning', () => { From 74bd46efe161a0e75c7beae1d5ad85b9b6b2ff81 Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 30 Sep 2026 17:13:59 +0200 Subject: [PATCH 07/11] fix(react-positioning): restore container on engine dispose and honor explicit strategy --- ...sitioning-8c1f2a3e-positioning-engine.json | 2 +- .../createFloatingUIPositioningEngine.test.ts | 50 ++++++++++++ .../src/createFloatingUIPositioningEngine.ts | 78 ++++++++++++++++++- .../library/src/resolvePositioningOptions.ts | 2 +- .../react-positioning/library/src/types.ts | 5 +- 5 files changed, 133 insertions(+), 4 deletions(-) diff --git a/change/@fluentui-react-positioning-8c1f2a3e-positioning-engine.json b/change/@fluentui-react-positioning-8c1f2a3e-positioning-engine.json index e3e061b4bb439..12e27ae2c419c 100644 --- a/change/@fluentui-react-positioning-8c1f2a3e-positioning-engine.json +++ b/change/@fluentui-react-positioning-8c1f2a3e-positioning-engine.json @@ -1,6 +1,6 @@ { "type": "minor", - "comment": "feat: add floatingUIPositioningEngine", + "comment": "feat: add floatingUIPositioningEngine; fix: honor an explicit `strategy` instead of always resolving it to `fixed`", "packageName": "@fluentui/react-positioning", "email": "vgenaev@gmail.com", "dependentChangeType": "patch" diff --git a/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts index ad97f92f3a093..e6b01e6053707 100644 --- a/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts +++ b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.test.ts @@ -140,7 +140,57 @@ describe('createFloatingUIPositioningEngine', () => { container.dispatchEvent(new CustomEvent(POSITIONING_END_EVENT, { detail: { placement: 'top' } })); expect(onPositioningEnd).toHaveBeenCalledTimes(1); + expect(container).not.toHaveAttribute('data-placement'); + }); + + it('restores the container and arrow to their previous state on dispose', async () => { + const { container, target } = createTestElements(); + const arrow = document.createElement('div'); + container.appendChild(arrow); + container.style.left = '5px'; + container.setAttribute('data-placement', 'below'); + + const manager = floatingUIPositioningEngine.create({ + container, + target, + arrow, + options: { position: 'above' }, + }); + await flushMicrotasks(); + + expect(container.style.transform).not.toBe(''); expect(container).toHaveAttribute('data-placement', 'below-start'); + expect(container).toHaveAttribute('data-popper-placement', 'bottom-start'); + + manager.dispose(); + + expect(container.style.left).toBe('5px'); + expect(container.style.top).toBe(''); + expect(container.style.position).toBe(''); + expect(container.style.transform).toBe(''); + expect(container.style.getPropertyValue('inset')).toBe(''); + expect(container).toHaveAttribute('data-placement', 'below'); + expect(container).not.toHaveAttribute('data-popper-placement'); + }); + + it('honors an explicit strategy: "absolute"', async () => { + const { container, target } = createTestElements(); + + const manager = floatingUIPositioningEngine.create({ + container, + target, + arrow: null, + options: { strategy: 'absolute' }, + }); + await flushMicrotasks(); + + expect(computePositionMock).toHaveBeenCalledWith( + target, + container, + expect.objectContaining({ strategy: 'absolute' }), + ); + + manager.dispose(); }); it('does nothing when enabled is false', async () => { diff --git a/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts index dc2f79b1d8593..5fc69fb6501e6 100644 --- a/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts +++ b/packages/react-components/react-positioning/library/src/createFloatingUIPositioningEngine.ts @@ -1,5 +1,13 @@ -import { DATA_PLACEMENT, POSITIONING_END_EVENT } from './constants'; +import { + DATA_PLACEMENT, + DATA_POSITIONING_ESCAPED, + DATA_POSITIONING_HIDDEN, + DATA_POSITIONING_INTERSECTING, + DATA_POSITIONING_PLACEMENT, + POSITIONING_END_EVENT, +} from './constants'; import { createPositionManager } from './createPositionManager'; +import { matchTargetSizeCssVar } from './middleware'; import { resolvePositioningOptions } from './resolvePositioningOptions'; import type { OnPositioningEndEvent, @@ -17,6 +25,69 @@ const NOOP_MANAGER: PositionManager = { const DEFAULT_CONFIGURATION: PositioningConfigurationFn = ({ options }) => options; +/** + * Inline styles the position manager and its middleware may write on the container. Shorthands come + * before their longhands so that restoring them in order does not wipe restored longhands. + */ +const CONTAINER_STYLE_PROPERTIES = [ + 'inset', + 'top', + 'right', + 'bottom', + 'left', + 'margin', + 'margin-top', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'position', + 'transform', + 'box-sizing', + 'width', + 'height', + 'max-width', + 'max-height', + 'overflow-x', + 'overflow-y', + matchTargetSizeCssVar, +]; + +const CONTAINER_ATTRIBUTES = [ + DATA_PLACEMENT, + DATA_POSITIONING_PLACEMENT, + DATA_POSITIONING_ESCAPED, + DATA_POSITIONING_HIDDEN, + DATA_POSITIONING_INTERSECTING, +]; + +const ARROW_STYLE_PROPERTIES = ['top', 'left']; + +/** + * Records the current values of the given inline styles and attributes, and returns a function that + * restores them. + */ +function snapshotElement(element: HTMLElement, styleProperties: string[], attributes: string[] = []): () => void { + const styles = styleProperties.map(property => [property, element.style.getPropertyValue(property)] as const); + const attributeValues = attributes.map(attribute => [attribute, element.getAttribute(attribute)] as const); + + return () => { + styles.forEach(([property, value]) => { + if (value) { + element.style.setProperty(property, value); + } else { + element.style.removeProperty(property); + } + }); + attributeValues.forEach(([attribute, value]) => { + if (value === null) { + element.removeAttribute(attribute); + } else { + element.setAttribute(attribute, value); + } + }); + }; +} + export type CreateFloatingUIPositioningEngineOptions = { /** * Equivalent of `PositioningConfigurationProvider` for engine consumers: lets you post-process the @@ -49,6 +120,9 @@ export function createFloatingUIPositioningEngine( return NOOP_MANAGER; } + const restoreContainer = snapshotElement(container, CONTAINER_STYLE_PROPERTIES, CONTAINER_ATTRIBUTES); + const restoreArrow = arrow ? snapshotElement(arrow, ARROW_STYLE_PROPERTIES) : undefined; + // Top-layer surfaces (`[popover]:popover-open`, `dialog:modal`) receive `inset: 0` from the UA // stylesheet. The manager only writes `left`/`top`, so `right`/`bottom` must be released first or // the surface stretches across the viewport. @@ -80,6 +154,8 @@ export function createFloatingUIPositioningEngine( dispose: () => { container.removeEventListener(POSITIONING_END_EVENT, handlePositioningEnd); manager.dispose(); + restoreContainer(); + restoreArrow?.(); }, }; }, diff --git a/packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts b/packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts index 57febae9217aa..828c113df5d60 100644 --- a/packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts +++ b/packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts @@ -159,7 +159,7 @@ export function resolvePositioningOptions(params: ResolvePositioningOptionsParam return { placement, middleware, - strategy: strategy ?? positionFixed ? ('fixed' as const) : ('absolute' as const), + strategy: strategy ?? (positionFixed ? 'fixed' : 'absolute'), disableUpdateOnResize, useTransform, diff --git a/packages/react-components/react-positioning/library/src/types.ts b/packages/react-components/react-positioning/library/src/types.ts index b550a739038f7..9c59e03653351 100644 --- a/packages/react-components/react-positioning/library/src/types.ts +++ b/packages/react-components/react-positioning/library/src/types.ts @@ -105,7 +105,10 @@ export type PositioningEngineCreateParams = { * - keep the container's `data-placement` attribute current with the resolved logical placement, as * a {@link PositioningShorthandValue} (e.g. `above-start`, `after-top`); * - invoke `options.onPositioningEnd` after each update, if provided; - * - undo all of the above in `dispose`. + * - undo all of the above in `dispose`: release listeners and observers, and restore the inline styles + * and attributes it wrote on the container and arrow to their previous values, so that another + * positioner (e.g. CSS anchor positioning) can take over a clean element. `dispose` also runs right + * before `create` is called again with new options, so both must be synchronous. */ export interface PositioningEngine { create: (params: PositioningEngineCreateParams) => PositionManager; From 61e25bc3d2b3120d00fe75ac92c6c0727e94f77a Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 30 Sep 2026 17:14:01 +0200 Subject: [PATCH 08/11] fix(react-headless-components): clean up anchor styles when handing over to an engine --- .../usePositioning/useCssAnchorPositioning.ts | 43 ++++++++++++++++++- .../usePositioning/useEnginePositioning.ts | 37 ++++++++++------ .../usePositioning/usePositioning.test.tsx | 21 ++++++++- .../hooks/usePositioning/usePositioning.ts | 2 + .../PositioningEngineDescription.md | 6 ++- 5 files changed, 92 insertions(+), 17 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useCssAnchorPositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useCssAnchorPositioning.ts index 1390eca19b641..f2f7982ea83dd 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useCssAnchorPositioning.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useCssAnchorPositioning.ts @@ -28,6 +28,36 @@ const readAnchorNames = (element: HTMLElement): string[] => { .filter(Boolean); }; +/** + * Inline styles written by `containerRef`, removed when CSS anchor positioning stops owning the + * container so that a positioning engine takes over a clean element. + */ +const CONTAINER_STYLE_PROPERTIES = [ + 'position', + 'inset', + 'margin', + 'margin-block-start', + 'margin-block-end', + 'margin-inline-start', + 'margin-inline-end', + 'position-anchor', + 'position-area', + 'position-try-fallbacks', + 'place-self', + 'align-self', + 'justify-self', +]; + +const MATCH_TARGET_WIDTH = 'anchor-size(width)'; + +const clearAnchorStyles = (container: HTMLElement): void => { + CONTAINER_STYLE_PROPERTIES.forEach(property => container.style.removeProperty(property)); + if (container.style.getPropertyValue('width') === MATCH_TARGET_WIDTH) { + container.style.removeProperty('width'); + } + container.removeAttribute('data-placement'); +}; + export type UseCssAnchorPositioningOptions = Pick< PositioningProps, | 'align' @@ -124,6 +154,17 @@ export function useCssAnchorPositioning(options: UseCssAnchorPositioningOptions) }; }, [effectiveTarget, anchorName]); + // Styles are written from `containerRef`; release them when this hook stops owning the container + // (an engine took over, or the container changed). Layout-effect cleanups run before the next + // positioner's layout effects, so the handover happens within a single commit. + useIsomorphicLayoutEffect(() => { + if (!enabled || !containerEl) { + return; + } + + return () => clearAnchorStyles(containerEl); + }, [enabled, containerEl]); + const targetRef: React.RefCallback = React.useCallback(node => { setTriggerEl(node); }, []); @@ -143,7 +184,7 @@ export function useCssAnchorPositioning(options: UseCssAnchorPositioningOptions) applyOffset(node, position, mainAxis, crossAxis); if (matchTargetSize === 'width') { - node.style.setProperty('width', 'anchor-size(width)'); + node.style.setProperty('width', MATCH_TARGET_WIDTH); } else { node.style.removeProperty('width'); } diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts index 73889aacbaf40..7746a8da6357a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useEnginePositioning.ts @@ -120,7 +120,15 @@ export function useEnginePositioning(options: UseEnginePositioningOptions): UseE const onPositioningEnd = useEventCallback((event: OnPositioningEndEvent) => options.onPositioningEnd?.(event)); useIsomorphicLayoutEffect(() => { - if (!engine || !containerEl || !effectiveTarget || !canUseDOM()) { + if (!engine || !containerEl || !canUseDOM()) { + return; + } + + // Seed the requested placement so placement-keyed styling is right before the engine's first + // (asynchronous) update reports the resolved one. The engine snapshots it and restores it on dispose. + containerEl.setAttribute('data-placement', initialPlacement); + + if (!effectiveTarget) { return; } @@ -138,7 +146,17 @@ export function useEnginePositioning(options: UseEnginePositioningOptions): UseE manager.dispose(); managerRef.current = null; }; - }, [engine, containerEl, effectiveTarget, arrowEl, stableOptions, onPositioningEnd, dir, targetDocument]); + }, [ + engine, + containerEl, + effectiveTarget, + arrowEl, + stableOptions, + onPositioningEnd, + dir, + targetDocument, + initialPlacement, + ]); const targetRef: React.RefCallback = React.useCallback(node => { setTriggerEl(node); @@ -148,18 +166,9 @@ export function useEnginePositioning(options: UseEnginePositioningOptions): UseE setArrowEl(node); }, []); - const containerRef: React.RefCallback = React.useCallback( - node => { - setContainerEl(node); - - // Seed the requested placement so placement-keyed styling is right before the engine's first - // (asynchronous) update reports the resolved one. - if (node && enabled) { - node.setAttribute('data-placement', initialPlacement); - } - }, - [enabled, initialPlacement], - ); + const containerRef: React.RefCallback = React.useCallback(node => { + setContainerEl(node); + }, []); const setTarget = React.useCallback((el: TargetElement | null) => { setImperativeTarget(el); 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 93fa0f8cb9abb..e532057951025 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 @@ -89,7 +89,7 @@ describe('usePositioning', () => { expect(node).toHaveStyle({ positionArea: 'block-end span-inline-end' }); }); - it('containerRef writes position: absolute by default and clears the UA inset/margin defaults', () => { + it('containerRef writes position: fixed by default and clears the UA inset/margin defaults', () => { const result = mountHook(); const node = document.createElement('div'); @@ -391,6 +391,25 @@ describe('usePositioning', () => { expect(engine.create).toHaveBeenCalledTimes(1); expect(getByTestId('target').style.getPropertyValue('anchor-name')).toBe(''); + + const container = getByTestId('container'); + expect(container.style.getPropertyValue('position-anchor')).toBe(''); + expect(container.style.getPropertyValue('position-area')).toBe(''); + expect(container.style.getPropertyValue('position-try-fallbacks')).toBe(''); + expect(container.style.getPropertyValue('place-self')).toBe(''); + expect(container.style.position).toBe(''); + expect(container).toHaveAttribute('data-placement', 'below'); + }); + + it('switches back to CSS anchor positioning when the engine is removed', () => { + const engine = createFakeEngine(); + const { rerender, getByTestId } = render(); + + rerender(); + + expect(engine.manager.dispose).toHaveBeenCalledTimes(1); + expect(getByTestId('target').style.getPropertyValue('anchor-name')).toMatch(/^--popover-anchor-/); + expect(getByTestId('container').style.getPropertyValue('position-anchor')).toMatch(/^--popover-anchor-/); }); it('warns in development when engine-only options are used without an engine', () => { 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 f59944c111ced..d42ac4d2db8b7 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 @@ -37,6 +37,8 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const { engine: engineFromOptions, positioningRef, ...positioningOptions } = options; const engineFromContext = usePositioningEngineContext(); const engine = engineFromOptions ?? engineFromContext; + // Headless surfaces render in the top layer, so both positioners default to `fixed` (the v9 engine + // default would otherwise be `absolute`). const strategy = positioningOptions.strategy ?? 'fixed'; const unsupportedOptions = engine diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md index bd8874877adad..d5f6b63eace5e 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md @@ -36,4 +36,8 @@ interface PositioningEngine { `create()` is called from a layout effect once the elements exist and disposed when they change or unmount. It is a plain object, never a hook, so it can come from props or context and change identity freely. An engine **replaces** CSS anchor positioning entirely: it owns every option, including the ones CSS also supports, so two positioners never mix. -The engine keeps the container positioned (including releasing the UA top-layer `inset: 0; margin: auto` that `[popover]` and `dialog:modal` receive) and keeps the container's `data-placement` current with the resolved **logical** placement (`above-start`, `after-top`, …), so placement-keyed CSS and arrows work the same way as with CSS anchor positioning. +The engine keeps the container positioned (including releasing the UA top-layer `inset: 0; margin: auto` that `[popover]` and `dialog:modal` receive) and keeps the container's `data-placement` current with the resolved **logical** placement (`above-start`, `after-top`, …), so placement-keyed CSS and arrows work the same way as with CSS anchor positioning. On `dispose` it restores the inline styles and attributes it wrote, so switching between an engine and CSS anchor positioning at runtime hands over a clean element. + +### Arrows + +With `withArrow`, the engine positions the arrow along the surface edge (`arrowPadding` keeps it away from rounded corners), but headless components have no built-in arrow size, so the gap between the surface and the target is not adjusted for it. Set `offset` to at least the arrow's protruding size, e.g. `positioning={{ offset: 8 }}` for an 8px arrow. From e55bc5dbdf78248e14382bcc2d9100fdf46d37e4 Mon Sep 17 00:00:00 2001 From: mainframev Date: Fri, 2 Oct 2026 16:50:02 +0200 Subject: [PATCH 09/11] feat(react-headless-components): add fallbackPositioningEngine Wraps an engine so surfaces keep CSS anchor positioning when the browser supports position-area and their options do not need an engine, and hand over to the engine otherwise. Implements the proposal from the positioning RFC (#36807). --- .../src/PolyfillsAndFallbacks.mdx | 25 ++-- ...s-preview-8c1f2a3e-positioning-engine.json | 2 +- .../library/etc/positioning.api.md | 3 + .../Popover/PositioningEngine.cy.tsx | 46 ++++++- .../PositioningEngineContext.ts | 10 +- .../fallbackPositioningEngine.ts | 41 ++++++ .../library/src/hooks/usePositioning/index.ts | 1 + .../library/src/hooks/usePositioning/types.ts | 4 +- .../usePositioning/usePositioning.test.tsx | 118 ++++++++++++++++++ .../hooks/usePositioning/usePositioning.ts | 37 ++++-- .../src/hooks/usePositioning/utils/index.ts | 1 + .../utils/supportsCssAnchorPositioning.ts | 7 ++ .../library/src/positioning.ts | 1 + .../Positioning/PositioningEngine.stories.tsx | 36 +++++- .../PositioningEngineDescription.md | 19 ++- 15 files changed, 327 insertions(+), 24 deletions(-) create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackPositioningEngine.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/supportsCssAnchorPositioning.ts diff --git a/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx b/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx index 3897597b7662a..715f4af57de7d 100644 --- a/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx +++ b/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx @@ -19,11 +19,12 @@ experience to provide. Fluent UI Headless leaves that tradeoff to the consuming anchor-positioning polyfill. This keeps the common path lean and requires little application code, although a polyfill may implement only part of the positioning behavior listed in the [browser support matrix](?path=/docs/overview-browser-support--docs#detailed-feature-support). -2. **Supply a JavaScript positioning engine.** When `CSS.supports('anchor-name', '--x')` returns `false`, - hand every surface to `floatingUIPositioningEngine` from `@fluentui/react-positioning` through - `PositioningEngineProvider`. This is the same Floating UI positioner Fluent UI React v9 uses, and it - also unlocks options CSS cannot express (`autoSize`, boundaries, …). The engine is only bundled when - you import it. +2. **Supply a JavaScript positioning engine as a fallback.** Wrap `floatingUIPositioningEngine` from + `@fluentui/react-positioning` with `fallbackPositioningEngine` and pass it to + `PositioningEngineProvider`. Surfaces keep native CSS anchor positioning where the browser supports + `position-area`, and hand over to the engine where it does not or where they use options CSS cannot + express (`autoSize`, boundaries, …). This is the same Floating UI positioner Fluent UI React v9 uses, + and it is only bundled when you import it. 3. **Allow static placement.** Keep the native fallback behavior: the surface opens, but it may appear at its document position rather than beside the trigger. Products aimed primarily at browsers with native support may find this sufficient. @@ -61,19 +62,23 @@ Overlays are positioned relative to their trigger with [CSS anchor positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning). The headless library does **not** bundle a JavaScript positioning fallback, so without support these overlays render at their static position. Either load a polyfill before Fluent UI Headless renders, or -opt into the Floating UI engine explicitly: +opt into the Floating UI engine as a fallback: ```tsx import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; -import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; +import { + PositioningEngineProvider, + fallbackPositioningEngine, +} from '@fluentui/react-headless-components-preview/positioning'; -const supportsAnchorPositioning = typeof CSS !== 'undefined' && CSS.supports('anchor-name', '--x'); - - + ; ``` +The fallback checks `position-area` rather than `anchor-name`: Chromium 125–128 support `anchor-name` +but not `position-area`, which the headless CSS path relies on. + Consult the [browser support matrix](?path=/docs/overview-browser-support--docs#detailed-feature-support) for the individual CSS properties Fluent UI Headless uses. diff --git a/change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json b/change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json index 84becd87a12f3..b4295959956ce 100644 --- a/change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json +++ b/change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "feat: support positioning engine injection", + "comment": "feat: support positioning engine injection and fallbackPositioningEngine", "packageName": "@fluentui/react-headless-components-preview", "email": "vgenaev@gmail.com", "dependentChangeType": "patch" diff --git a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md index a4c44dbbc4b4b..3aa33f78e1d34 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md @@ -21,6 +21,9 @@ export const ALIGNMENTS: { readonly end: "end"; }; +// @public +export function fallbackPositioningEngine(engine: PositioningEngine): PositioningEngine; + // @public export function getPlacementString(position: Position, align: Alignment): PositioningShorthandValue; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx index 5fb58951351b9..ac3b307c965f1 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx @@ -5,7 +5,8 @@ import type { JSXElement } from '@fluentui/react-utilities'; import { Popover } from './Popover'; import { PopoverTrigger } from './PopoverTrigger/PopoverTrigger'; import { PopoverSurface } from './PopoverSurface/PopoverSurface'; -import { PositioningEngineProvider } from '../../positioning'; +import { PositioningEngineProvider, fallbackPositioningEngine } from '../../positioning'; +import type { PositioningProps } from '../../positioning'; const mount = (element: JSXElement) => { mountBase(element); @@ -113,4 +114,47 @@ describe('Popover positioning engine', () => { expect($arrow[0].style.top).to.match(/px$/); }); }); + + describe('fallbackPositioningEngine', () => { + const mountWithFallback = (positioning: PositioningProps) => + mount( + +
+ + + + + Surface + +
+
, + ); + + it('keeps CSS anchor positioning for a surface that does not need the engine', () => { + mountWithFallback({ position: 'below' }); + + // Browsers without `position-area` (the React 17 run's Electron 118) take the engine for every surface. + cy.window().then(win => { + if (win.CSS?.supports?.('position-area', 'bottom')) { + cy.get(surfaceSelector).should($el => { + expect($el).to.have.length(1); + expect($el[0]).not.to.have.attr('data-popper-placement'); + expect($el[0].style.getPropertyValue('position-anchor')).to.match(/^--popover-anchor-/); + }); + } else { + cy.get(surfaceSelector).should('have.attr', 'data-popper-placement', 'bottom'); + } + }); + }); + + it('hands over to the engine for a surface with engine-only options', () => { + mountWithFallback({ position: 'below', autoSize: true }); + + cy.get(surfaceSelector) + .should('have.attr', 'data-popper-placement', 'bottom') + .and($el => { + expect($el[0].style.getPropertyValue('position-anchor')).to.equal(''); + }); + }); + }); }); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts index 296327928c8b8..5f05db392bb50 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts @@ -9,12 +9,18 @@ const PositioningEngineContext = React.createContext + * * * * ``` diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackPositioningEngine.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackPositioningEngine.ts new file mode 100644 index 0000000000000..a68fb1fea58d8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackPositioningEngine.ts @@ -0,0 +1,41 @@ +import type { PositioningEngine } from '@fluentui/react-positioning'; + +const fallbackEngines = new WeakSet(); +const fallbackEnginesByEngine = new WeakMap(); + +/** + * Marks a positioning engine as a fallback. Surfaces then use native CSS anchor positioning when the + * browser supports it and their options do not need an engine, and hand over to `engine` otherwise. + * + * Calling it again with the same engine returns the same wrapper, so it is safe to call during render. + * + * @example + * ```tsx + * import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; + * import { + * PositioningEngineProvider, + * fallbackPositioningEngine, + * } from '@fluentui/react-headless-components-preview/positioning'; + * + * + * + * + * ``` + */ +export function fallbackPositioningEngine(engine: PositioningEngine): PositioningEngine { + if (fallbackEngines.has(engine)) { + return engine; + } + + let fallback = fallbackEnginesByEngine.get(engine); + if (!fallback) { + fallback = { create: params => engine.create(params) }; + fallbackEngines.add(fallback); + fallbackEnginesByEngine.set(engine, fallback); + } + + return fallback; +} + +export const isFallbackPositioningEngine = (engine: PositioningEngine | undefined): boolean => + engine !== undefined && fallbackEngines.has(engine); 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 b9755067353fb..f25997d57996c 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 @@ -1,6 +1,7 @@ export { resolvePositioningShorthand } from './resolvePositioningShorthand'; export { usePositioning } from './usePositioning'; export { PositioningEngineProvider } from './PositioningEngineContext'; +export { fallbackPositioningEngine } from './fallbackPositioningEngine'; export { getPlacementString } from './utils'; export { POSITIONS, ALIGNMENTS } from './constants'; export type { PositioningEngine, PositioningProps, PositioningShorthand, PositioningReturn } from './types'; 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 a34c112b58f8a..7b3e1e5a0be14 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 @@ -30,7 +30,9 @@ export type PositioningProps = CanonicalPositioningProps & { /** * A JavaScript positioning engine that replaces CSS anchor positioning for this surface entirely. * When set (or provided through `PositioningEngineProvider`), the engine owns every positioning - * option, including the ones the CSS engine also supports. + * option, including the ones the CSS engine also supports. An engine wrapped with + * `fallbackPositioningEngine` is only used when the browser lacks CSS anchor positioning or an + * option needs an engine. */ engine?: PositioningEngine; }; 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 e532057951025..e3c9b269cb115 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 @@ -3,6 +3,7 @@ import { act, render } from '@testing-library/react'; import type { PositioningEngine, PositioningImperativeRef, PositionManager } from '@fluentui/react-positioning'; import { usePositioning } from './usePositioning'; import { PositioningEngineProvider } from './PositioningEngineContext'; +import { fallbackPositioningEngine } from './fallbackPositioningEngine'; import { getPlacementString } from './utils/placement'; import type { PositioningProps, PositioningReturn } from './types'; @@ -430,6 +431,123 @@ describe('usePositioning', () => { expect(warn).not.toHaveBeenCalled(); warn.mockRestore(); }); + + describe('fallbackPositioningEngine', () => { + const originalCSS = Object.getOwnPropertyDescriptor(window, 'CSS'); + const supports = jest.fn(); + + beforeEach(() => { + supports.mockReset().mockImplementation(property => property === 'position-area'); + Object.defineProperty(window, 'CSS', { value: { supports }, configurable: true }); + }); + + afterEach(() => { + if (originalCSS) { + Object.defineProperty(window, 'CSS', originalCSS); + } else { + delete (window as Partial).CSS; + } + }); + + const renderWithFallback = (engine: FakeEngine, positioning: PositioningProps) => + render( + + + , + ); + + it('returns the same wrapper for the same engine', () => { + const engine = createFakeEngine(); + const fallback = fallbackPositioningEngine(engine); + + expect(fallbackPositioningEngine(engine)).toBe(fallback); + expect(fallbackPositioningEngine(fallback)).toBe(fallback); + }); + + it('uses CSS anchor positioning when the browser supports it and the options do not need an engine', () => { + const engine = createFakeEngine(); + const { getByTestId } = renderWithFallback(engine, { position: 'below', offset: 4 }); + + expect(engine.create).not.toHaveBeenCalled(); + expect(getByTestId('container').style.getPropertyValue('position-anchor')).toMatch(/^--popover-anchor-/); + expect(supports).toHaveBeenCalledWith('position-area', 'bottom'); + }); + + it('stays on CSS anchor positioning for engine tuning options', () => { + const engine = createFakeEngine(); + renderWithFallback(engine, { useTransform: false, disableUpdateOnResize: true }); + + expect(engine.create).not.toHaveBeenCalled(); + }); + + it('hands over to the engine when an option needs one', () => { + const engine = createFakeEngine(); + const { getByTestId } = renderWithFallback(engine, { position: 'below', autoSize: true }); + + expect(engine.create).toHaveBeenCalledTimes(1); + expect(engine.create.mock.calls[0][0].options).toEqual(expect.objectContaining({ autoSize: true })); + expect(getByTestId('container').style.getPropertyValue('position-anchor')).toBe(''); + }); + + it('hands over to the engine for a function offset', () => { + const engine = createFakeEngine(); + renderWithFallback(engine, { offset: () => ({ mainAxis: 4 }) }); + + expect(engine.create).toHaveBeenCalledTimes(1); + }); + + it('hands over to the engine when the browser does not support position-area', () => { + supports.mockReturnValue(false); + const engine = createFakeEngine(); + renderWithFallback(engine, { position: 'below' }); + + expect(engine.create).toHaveBeenCalledTimes(1); + }); + + it('switches between CSS anchor positioning and the engine as options change', () => { + const engine = createFakeEngine(); + const fallback = fallbackPositioningEngine(engine); + const { rerender, getByTestId } = render( + + + , + ); + + rerender( + + + , + ); + expect(engine.create).toHaveBeenCalledTimes(1); + expect(getByTestId('container').style.getPropertyValue('position-anchor')).toBe(''); + + rerender( + + + , + ); + expect(engine.manager.dispose).toHaveBeenCalledTimes(1); + expect(getByTestId('container').style.getPropertyValue('position-anchor')).toMatch(/^--popover-anchor-/); + }); + + it('lets an inline engine override a fallback engine from context', () => { + const contextEngine = createFakeEngine(); + const inlineEngine = createFakeEngine(); + renderWithFallback(contextEngine, { engine: inlineEngine }); + + expect(inlineEngine.create).toHaveBeenCalledTimes(1); + expect(contextEngine.create).not.toHaveBeenCalled(); + }); + + it('does not warn about engine-only options', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + renderWithFallback(createFakeEngine(), { useTransform: false }); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + }); }); }); 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 d42ac4d2db8b7..949bd749e28ee 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,29 +1,39 @@ 'use client'; import * as React from 'react'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import type { PositioningImperativeRef } from '@fluentui/react-positioning'; import type { PositioningProps, PositioningReturn } from './types'; import { useCssAnchorPositioning } from './useCssAnchorPositioning'; import { useEnginePositioning } from './useEnginePositioning'; import { usePositioningEngineContext } from './PositioningEngineContext'; +import { isFallbackPositioningEngine } from './fallbackPositioningEngine'; +import { supportsCssAnchorPositioning } from './utils'; export type { TargetElement } from './useCssAnchorPositioning'; /** - * Options that CSS anchor positioning cannot express. They only take effect with an engine. + * Options that CSS anchor positioning cannot express. A fallback engine takes over when any is set. */ -const ENGINE_ONLY_OPTIONS = [ +const REQUIRES_ENGINE_OPTIONS = [ 'arrowPadding', 'autoSize', - 'disableUpdateOnResize', 'flipBoundary', 'onPositioningEnd', 'overflowBoundary', 'overflowBoundaryPadding', 'shiftToCoverTarget', - 'useTransform', ] as const satisfies ReadonlyArray; +/** + * Options that only tune an engine. They have no effect on the CSS path, but do not need an engine. + */ +const ENGINE_TUNING_OPTIONS = ['disableUpdateOnResize', 'useTransform'] as const satisfies ReadonlyArray< + keyof PositioningProps +>; + +const ENGINE_ONLY_OPTIONS = [...REQUIRES_ENGINE_OPTIONS, ...ENGINE_TUNING_OPTIONS].sort(); + const noopRef: React.RefCallback = () => undefined; /** @@ -31,17 +41,30 @@ const noopRef: React.RefCallback = () => undefined; * * By default this uses native CSS anchor positioning. When an engine is supplied — inline through * `options.engine` or app-wide through `PositioningEngineProvider` — the engine owns positioning - * entirely and the CSS path is not applied. + * entirely and the CSS path is not applied. An engine wrapped with `fallbackPositioningEngine` is only + * used when the browser lacks CSS anchor positioning or the options need an engine. */ export function usePositioning(options: PositioningProps): PositioningReturn { const { engine: engineFromOptions, positioningRef, ...positioningOptions } = options; const engineFromContext = usePositioningEngineContext(); - const engine = engineFromOptions ?? engineFromContext; + const requestedEngine = engineFromOptions ?? engineFromContext; + const { targetDocument } = useFluent(); + + const requiresEngine = + typeof positioningOptions.offset === 'function' || + REQUIRES_ENGINE_OPTIONS.some(key => positioningOptions[key] !== undefined); + const engine = + isFallbackPositioningEngine(requestedEngine) && + !requiresEngine && + supportsCssAnchorPositioning(targetDocument?.defaultView) + ? undefined + : requestedEngine; + // Headless surfaces render in the top layer, so both positioners default to `fixed` (the v9 engine // default would otherwise be `absolute`). const strategy = positioningOptions.strategy ?? 'fixed'; - const unsupportedOptions = engine + const unsupportedOptions = requestedEngine ? '' : ENGINE_ONLY_OPTIONS.filter(key => positioningOptions[key] !== undefined) .map(key => `"${key}"`) 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..651f6ac1d8c46 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 @@ -4,3 +4,4 @@ export { debounce } from './debounce'; export { applyOffset, resolveOffset } from './offset'; export { getCoverSelfAlignment, getPlacementString, shorthandToPositionArea } from './placement'; export { resolveElementRef } from './resolveElementRef'; +export { supportsCssAnchorPositioning } from './supportsCssAnchorPositioning'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/supportsCssAnchorPositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/supportsCssAnchorPositioning.ts new file mode 100644 index 0000000000000..2d2d139989964 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/supportsCssAnchorPositioning.ts @@ -0,0 +1,7 @@ +/** + * Whether the CSS path can position surfaces in this window. Checks `position-area` rather than + * `anchor-name`: Chromium 125–128 support `anchor-name` but only shipped `position-area` in 129. + */ +export function supportsCssAnchorPositioning(win: (Window & typeof globalThis) | null | undefined): boolean { + return typeof win?.CSS?.supports === 'function' && win.CSS.supports('position-area', 'bottom'); +} 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 fa568bb603e12..f68a50b37966d 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 @@ -11,6 +11,7 @@ export type { export { usePositioning, PositioningEngineProvider, + fallbackPositioningEngine, POSITIONS, ALIGNMENTS, getPlacementString, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx index 44cf4e158d7ba..be3e82cddf3b5 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx @@ -1,6 +1,9 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; +import { + PositioningEngineProvider, + fallbackPositioningEngine, +} from '@fluentui/react-headless-components-preview/positioning'; // eslint-disable-next-line @fluentui/no-restricted-imports import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; @@ -37,6 +40,37 @@ export const Engine = (): React.ReactNode => (
+
+

App-wide fallback: fallbackPositioningEngine

+

+ The recommended app-wide setup. Wrapped with fallbackPositioningEngine, the provider's engine is + only used by surfaces that need it: the left surface keeps native CSS anchor positioning, the right one sets{' '} + autoSize and hands over to Floating UI. In a browser without position-area both use + the engine. +

+ +
+ + + + + + position-area Positioned by the browser. + + + + + + + + + engine Positioned by Floating UI. + + +
+
+
+

App-wide: PositioningEngineProvider

diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md index d5f6b63eace5e..b0f9d6d029f95 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md @@ -2,7 +2,15 @@ By default headless surfaces are positioned by the browser with native CSS ancho ```tsx import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; -import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; +import { + PositioningEngineProvider, + fallbackPositioningEngine, +} from '@fluentui/react-headless-components-preview/positioning'; + +// app-wide, recommended: CSS where it is enough, the engine where it is not + + +; // one surface ; @@ -17,6 +25,15 @@ import { PositioningEngineProvider } from '@fluentui/react-headless-components-p Every option of the canonical `positioning` contract is accepted, but these have no CSS equivalent and only take effect with an engine: `autoSize`, `flipBoundary`, `overflowBoundary`, `overflowBoundaryPadding`, `shiftToCoverTarget`, `arrowPadding`, `useTransform`, `disableUpdateOnResize`, `onPositioningEnd`. Passing them without an engine logs a development warning and does nothing. +### Fallback engine + +A plain engine in `PositioningEngineProvider` positions every surface below it, so one part of an app that needs `autoSize` would opt the whole subtree out of native anchoring. Wrap the engine with `fallbackPositioningEngine` instead: each surface keeps CSS anchor positioning and only hands over to the engine when + +- the browser does not support `position-area` (Chromium before 129, Safari before 26, Firefox before 147), or +- it sets an option that needs an engine: `autoSize`, `flipBoundary`, `overflowBoundary`, `overflowBoundaryPadding`, `shiftToCoverTarget`, `arrowPadding`, `onPositioningEnd`, or a function `offset`. + +`useTransform` and `disableUpdateOnResize` only tune an engine, so they do not trigger the handover on their own. If the options change at runtime the surface switches between the two paths. + `floatingUIPositioningEngine` is the Floating UI implementation that powers Fluent UI React v9, so a headless app that imports it gets full v9 parity; the `Engine…` stories below reproduce the corresponding v9 positioning examples. `@floating-ui/*` is only bundled if you import the engine; the headless package's bundle-isolation check forbids it. ### What an engine is From 7228abc8a1134a2b66fbbcccc49191bb24681115 Mon Sep 17 00:00:00 2001 From: mainframev Date: Mon, 5 Oct 2026 17:03:58 +0200 Subject: [PATCH 10/11] feat(react-headless-components): replace engine API with PositioningProvider mode Adds a positioning-floating-ui entry exporting PositioningProvider with fallback, floating-ui and css modes. The engine option, PositioningEngineProvider and fallbackPositioningEngine become internal, so Floating UI can be replaced later without a breaking change. --- .../src/PolyfillsAndFallbacks.mdx | 23 +-- ...s-preview-8c1f2a3e-positioning-engine.json | 2 +- .../library/etc/avatar-group.api.md | 3 +- .../library/etc/combobox.api.md | 3 +- .../library/etc/dropdown.api.md | 3 +- .../library/etc/info-label.api.md | 3 +- .../library/etc/menu.api.md | 3 +- .../library/etc/popover.api.md | 3 +- .../etc/positioning-floating-ui.api.md | 27 +++ .../library/etc/positioning.api.md | 18 +- .../library/etc/tag-picker.api.md | 3 +- .../library/etc/teaching-popover.api.md | 3 +- .../library/etc/tooltip.api.md | 3 +- .../library/package.json | 10 + .../Popover/PositioningEngine.cy.tsx | 190 +++++++++--------- .../PositioningEngineContext.ts | 22 +- .../PositioningProvider.test.tsx | 63 ++++++ .../usePositioning/PositioningProvider.tsx | 46 +++++ .../fallbackPositioningEngine.ts | 20 +- .../library/src/hooks/usePositioning/index.ts | 4 +- .../library/src/hooks/usePositioning/types.ts | 32 +-- .../usePositioning/usePositioning.test.tsx | 105 +++++----- .../hooks/usePositioning/usePositioning.ts | 17 +- .../library/src/positioning-floating-ui.ts | 2 + .../library/src/positioning.ts | 3 - .../Positioning/PositioningDescription.md | 2 +- .../Positioning/PositioningEngine.stories.tsx | 96 +++++---- .../PositioningEngineAutoSize.stories.tsx | 90 ++++----- .../PositioningEngineDescription.md | 67 +++--- ...itioningEngineDisableTransform.stories.tsx | 43 ++-- .../PositioningEngineFlipBoundary.stories.tsx | 12 +- ...sitioningEngineListenToUpdates.stories.tsx | 59 +++--- ...itioningEngineOverflowBoundary.stories.tsx | 10 +- ...gEngineOverflowBoundaryPadding.stories.tsx | 10 +- ...ningEngineOverflowBoundaryRect.stories.tsx | 79 ++++---- ...ioningEngineShiftToCoverTarget.stories.tsx | 112 +++++------ .../Concepts/Positioning/utils.stories.tsx | 2 +- 37 files changed, 618 insertions(+), 575 deletions(-) create mode 100644 packages/react-components/react-headless-components-preview/library/etc/positioning-floating-ui.api.md create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningProvider.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningProvider.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/positioning-floating-ui.ts diff --git a/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx b/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx index 715f4af57de7d..b6624bb407f7d 100644 --- a/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx +++ b/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx @@ -19,12 +19,11 @@ experience to provide. Fluent UI Headless leaves that tradeoff to the consuming anchor-positioning polyfill. This keeps the common path lean and requires little application code, although a polyfill may implement only part of the positioning behavior listed in the [browser support matrix](?path=/docs/overview-browser-support--docs#detailed-feature-support). -2. **Supply a JavaScript positioning engine as a fallback.** Wrap `floatingUIPositioningEngine` from - `@fluentui/react-positioning` with `fallbackPositioningEngine` and pass it to - `PositioningEngineProvider`. Surfaces keep native CSS anchor positioning where the browser supports - `position-area`, and hand over to the engine where it does not or where they use options CSS cannot - express (`autoSize`, boundaries, …). This is the same Floating UI positioner Fluent UI React v9 uses, - and it is only bundled when you import it. +2. **Use Floating UI as a fallback.** Wrap the app in `PositioningProvider mode="fallback"` from + `@fluentui/react-headless-components-preview/positioning-floating-ui`. Surfaces keep native CSS anchor + positioning where the browser supports `position-area`, and hand over to Floating UI where it does not + or where they use options CSS cannot express (`autoSize`, boundaries, …). This is the same positioner + Fluent UI React v9 uses, and it is only bundled when you import that entry. 3. **Allow static placement.** Keep the native fallback behavior: the surface opens, but it may appear at its document position rather than beside the trigger. Products aimed primarily at browsers with native support may find this sufficient. @@ -62,18 +61,14 @@ Overlays are positioned relative to their trigger with [CSS anchor positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning). The headless library does **not** bundle a JavaScript positioning fallback, so without support these overlays render at their static position. Either load a polyfill before Fluent UI Headless renders, or -opt into the Floating UI engine as a fallback: +opt into Floating UI as a fallback: ```tsx -import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; -import { - PositioningEngineProvider, - fallbackPositioningEngine, -} from '@fluentui/react-headless-components-preview/positioning'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; - + -; +; ``` The fallback checks `position-area` rather than `anchor-name`: Chromium 125–128 support `anchor-name` diff --git a/change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json b/change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json index b4295959956ce..619a176ba8194 100644 --- a/change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json +++ b/change/@fluentui-react-headless-components-preview-8c1f2a3e-positioning-engine.json @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "feat: support positioning engine injection and fallbackPositioningEngine", + "comment": "feat: add PositioningProvider (positioning-floating-ui entry) to position surfaces with Floating UI as a fallback or everywhere", "packageName": "@fluentui/react-headless-components-preview", "email": "vgenaev@gmail.com", "dependentChangeType": "patch" diff --git a/packages/react-components/react-headless-components-preview/library/etc/avatar-group.api.md b/packages/react-components/react-headless-components-preview/library/etc/avatar-group.api.md index d6a47605031dd..bd3982fdc8796 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/avatar-group.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/avatar-group.api.md @@ -23,8 +23,7 @@ import type { JSXElement } from '@fluentui/react-utilities'; import { PartitionAvatarGroupItems } from '@fluentui/react-avatar'; import { partitionAvatarGroupItems } from '@fluentui/react-avatar'; import { PartitionAvatarGroupItemsOptions } from '@fluentui/react-avatar'; -import type { PositioningEngine } from '@fluentui/react-positioning'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; import { renderAvatarGroup_unstable as renderAvatarGroup } from '@fluentui/react-avatar'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md b/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md index 1d495d5d8b8f5..8acbd833422ff 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md @@ -20,8 +20,7 @@ import { OptionGroupState } from '@fluentui/react-combobox'; import { OptionProps } from '@fluentui/react-combobox'; import { OptionSlots } from '@fluentui/react-combobox'; import type { OptionState as OptionState_2 } from '@fluentui/react-combobox'; -import type { PositioningEngine } from '@fluentui/react-positioning'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; import { renderListbox_unstable as renderListbox } from '@fluentui/react-combobox'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md b/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md index 6a9d8c9f65a65..e9a93c41f7c33 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md @@ -19,8 +19,7 @@ import { OptionGroupState } from '@fluentui/react-combobox'; import { OptionProps } from '@fluentui/react-combobox'; import { OptionSlots } from '@fluentui/react-combobox'; import type { OptionState as OptionState_2 } from '@fluentui/react-combobox'; -import type { PositioningEngine } from '@fluentui/react-positioning'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; import { renderListbox_unstable as renderListbox } from '@fluentui/react-combobox'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/info-label.api.md b/packages/react-components/react-headless-components-preview/library/etc/info-label.api.md index ceea1a2350542..9da669c2d4dc4 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/info-label.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/info-label.api.md @@ -11,8 +11,7 @@ import type { EventHandler } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import { LabelBaseProps } from '@fluentui/react-label'; -import type { PositioningEngine } from '@fluentui/react-positioning'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/menu.api.md b/packages/react-components/react-headless-components-preview/library/etc/menu.api.md index ce418893a56c2..a637e8c9b475a 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/menu.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/menu.api.md @@ -49,8 +49,7 @@ import { MenuBaseState as MenuState } from '@fluentui/react-menu'; import { MenuTriggerChildProps } from '@fluentui/react-menu'; import { MenuTriggerProps } from '@fluentui/react-menu'; import { MenuTriggerState } from '@fluentui/react-menu'; -import type { PositioningEngine } from '@fluentui/react-positioning'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import * as React_2 from 'react'; import { renderMenuDivider_unstable as renderMenuDivider } from '@fluentui/react-menu'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/popover.api.md b/packages/react-components/react-headless-components-preview/library/etc/popover.api.md index 3c04e91050b31..1f125597b3315 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/popover.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/popover.api.md @@ -13,8 +13,7 @@ import type { EventHandler } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import type { PopoverTriggerChildProps as PopoverTriggerChildProps_2 } from '@fluentui/react-popover'; -import type { PositioningEngine } from '@fluentui/react-positioning'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; diff --git a/packages/react-components/react-headless-components-preview/library/etc/positioning-floating-ui.api.md b/packages/react-components/react-headless-components-preview/library/etc/positioning-floating-ui.api.md new file mode 100644 index 0000000000000..19fd120018665 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/positioning-floating-ui.api.md @@ -0,0 +1,27 @@ +## API Report File for "@fluentui/react-headless-components-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { JSXElement } from '@fluentui/react-utilities'; +import * as React_2 from 'react'; + +// @public +export type PositioningMode = 'fallback' | 'floating-ui' | 'css'; + +// @public +export const PositioningProvider: { + (props: PositioningProviderProps): JSXElement; + displayName: string; +}; + +// @public (undocumented) +export type PositioningProviderProps = { + mode: PositioningMode; + children?: React_2.ReactNode; +}; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md index 3aa33f78e1d34..76d1a11f00ca4 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md @@ -6,11 +6,10 @@ import { Alignment } from '@fluentui/react-positioning'; import { Position } from '@fluentui/react-positioning'; -import type { PositioningEngine } from '@fluentui/react-positioning'; import { PositioningImperativeRef } from '@fluentui/react-positioning'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import { PositioningProps } from '@fluentui/react-positioning'; import { PositioningShorthandValue } from '@fluentui/react-positioning'; -import * as React_2 from 'react'; +import type * as React_2 from 'react'; export { Alignment } @@ -21,25 +20,14 @@ export const ALIGNMENTS: { readonly end: "end"; }; -// @public -export function fallbackPositioningEngine(engine: PositioningEngine): PositioningEngine; - // @public export function getPlacementString(position: Position, align: Alignment): PositioningShorthandValue; export { Position } -export { PositioningEngine } - -// @public -export const PositioningEngineProvider: React_2.Provider; - export { PositioningImperativeRef } -// @public -export type PositioningProps = PositioningProps_2 & { - engine?: PositioningEngine; -}; +export { PositioningProps } // @public (undocumented) export type PositioningReturn = { 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 cbe122c79baea..7dd3b39c2892c 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 { PositioningEngine } from '@fluentui/react-positioning'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; import { renderTagPickerButton_unstable as renderTagPickerButton } 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 3f2cd915822e3..0b8addfca1460 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 { PositioningEngine } from '@fluentui/react-positioning'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import * as React_2 from 'react'; import { renderTeachingPopoverBody_unstable as renderTeachingPopoverBody } 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 89e1bd7e33e31..cf375dd742cd8 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 { PositioningEngine } from '@fluentui/react-positioning'; -import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; +import type { PositioningProps } from '@fluentui/react-positioning'; import type { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; import type { TooltipBaseProps } from '@fluentui/react-tooltip'; diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index d8df5de297cff..85f7bbe97bc8b 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -392,6 +392,16 @@ "default": "./lib-commonjs/positioning.cjs" } }, + "./positioning-floating-ui": { + "import": { + "types": "./dist/positioning-floating-ui.d.ts", + "default": "./lib/positioning-floating-ui.js" + }, + "require": { + "types": "./dist/positioning-floating-ui.d.cts", + "default": "./lib-commonjs/positioning-floating-ui.cjs" + } + }, "./progress-bar": { "import": { "types": "./dist/progress-bar.d.ts", diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx index ac3b307c965f1..e058c008fe06e 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PositioningEngine.cy.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; import { mount as mountBase } from '@fluentui/scripts-cypress'; -import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; import type { JSXElement } from '@fluentui/react-utilities'; import { Popover } from './Popover'; import { PopoverTrigger } from './PopoverTrigger/PopoverTrigger'; import { PopoverSurface } from './PopoverSurface/PopoverSurface'; -import { PositioningEngineProvider, fallbackPositioningEngine } from '../../positioning'; -import type { PositioningProps } from '../../positioning'; +import { PositioningProvider } from '../../positioning-floating-ui'; +import type { PositioningMode } from '../../positioning-floating-ui'; +import type { PositioningShorthand } from '../../positioning'; const mount = (element: JSXElement) => { mountBase(element); @@ -14,7 +14,36 @@ const mount = (element: JSXElement) => { const surfaceSelector = '[data-popover-surface]'; -describe('Popover positioning engine', () => { +const PositionedPopover = (props: { positioning: PositioningShorthand; withArrow?: boolean; tall?: boolean }) => ( +

+ + + + + {props.tall ?
Tall content
: 'Surface'}
+
+
+); + +const mountWithMode = (mode: PositioningMode, props: React.ComponentProps) => + mount( + + + , + ); + +/** `position-area` is missing in the React 17 run's Electron 118, where every fallback surface takes the engine. */ +const supportsPositionArea = (win: Window & typeof globalThis) => + Boolean(win.CSS?.supports?.('position-area', 'bottom')); + +const expectCssAnchorPositioning = () => + cy.get(surfaceSelector).should($el => { + expect($el).to.have.length(1); + expect($el[0]).not.to.have.attr('data-popper-placement'); + expect($el[0].style.getPropertyValue('position-anchor')).to.match(/^--popover-anchor-/); + }); + +describe('Popover positioning', () => { it('positions with CSS anchor positioning by default', () => { mount( @@ -30,125 +59,71 @@ describe('Popover positioning engine', () => { expect($el[0].style.transform).to.equal(''); }); - // `position-anchor` and the resolved placement only exist when the browser supports CSS anchor - // positioning (Chromium 125+). The React 17 integration run uses Cypress 13 / Electron 118, which - // drops the unknown property and leaves the surface at the UA's centred top-layer position. cy.window().then(win => { - if (win.CSS?.supports?.('anchor-name: --x')) { - cy.get(surfaceSelector) - .should('have.attr', 'data-placement', 'below-start') - .and($el => { - expect($el[0].style.getPropertyValue('position-anchor')).to.match(/^--popover-anchor-/); - }); + if (supportsPositionArea(win)) { + cy.get(surfaceSelector).should('have.attr', 'data-placement', 'below-start'); + expectCssAnchorPositioning(); } }); }); - it('delegates to an inline engine, which replaces CSS anchor positioning', () => { - mount( -
- - - - - Surface - -
, - ); + describe('PositioningProvider mode="floating-ui"', () => { + it('positions every surface with Floating UI', () => { + mountWithMode('floating-ui', { positioning: { position: 'below', align: 'start' } }); - cy.get(surfaceSelector) - .should('have.attr', 'data-popper-placement', 'bottom-start') - .and('have.attr', 'data-placement', 'below-start') - .and($el => { - expect($el[0].style.getPropertyValue('position-anchor')).to.equal(''); - expect($el[0].style.transform).to.match(/translate/); - expect($el[0].style.position).to.equal('fixed'); - }); + cy.get(surfaceSelector) + .should('have.attr', 'data-popper-placement', 'bottom-start') + .and('have.attr', 'data-placement', 'below-start') + .and($el => { + expect($el[0].style.getPropertyValue('position-anchor')).to.equal(''); + expect($el[0].style.transform).to.match(/translate/); + expect($el[0].style.position).to.equal('fixed'); + }); - cy.get('button').then($trigger => { - const trigger = $trigger[0].getBoundingClientRect(); - cy.get(surfaceSelector).then($surface => { - const surface = $surface[0].getBoundingClientRect(); - expect(Math.round(surface.left)).to.equal(Math.round(trigger.left)); - expect(surface.top).to.be.closeTo(trigger.bottom, 1); + cy.get('button').then($trigger => { + const trigger = $trigger[0].getBoundingClientRect(); + cy.get(surfaceSelector).then($surface => { + const surface = $surface[0].getBoundingClientRect(); + expect(Math.round(surface.left)).to.equal(Math.round(trigger.left)); + expect(surface.top).to.be.closeTo(trigger.bottom, 1); + }); }); }); - }); - it('applies engine-only options such as autoSize', () => { - mount( -
- - - - - -
Tall content
-
-
-
, - ); + it('applies engine-only options such as autoSize', () => { + mountWithMode('floating-ui', { positioning: { position: 'below', autoSize: true }, tall: true }); - cy.get(surfaceSelector).should($el => { - expect($el[0].style.maxHeight).to.match(/px$/); - expect($el[0].getBoundingClientRect().bottom).to.be.at.most(window.innerHeight + 1); + cy.get(surfaceSelector).should($el => { + expect($el[0].style.maxHeight).to.match(/px$/); + expect($el[0].getBoundingClientRect().bottom).to.be.at.most(window.innerHeight + 1); + }); }); - }); - it('falls back to the engine supplied by PositioningEngineProvider and positions the arrow', () => { - mount( - -
- - - - - Surface - -
-
, - ); + it('positions the arrow', () => { + mountWithMode('floating-ui', { positioning: 'after', withArrow: true }); - cy.get(surfaceSelector).should('have.attr', 'data-popper-placement', 'right'); - cy.get('[data-arrow]').should($arrow => { - expect($arrow[0].style.top).to.match(/px$/); + cy.get(surfaceSelector).should('have.attr', 'data-popper-placement', 'right'); + cy.get('[data-arrow]').should($arrow => { + expect($arrow[0].style.top).to.match(/px$/); + }); }); }); - describe('fallbackPositioningEngine', () => { - const mountWithFallback = (positioning: PositioningProps) => - mount( - -
- - - - - Surface - -
-
, - ); - - it('keeps CSS anchor positioning for a surface that does not need the engine', () => { - mountWithFallback({ position: 'below' }); + describe('PositioningProvider mode="fallback"', () => { + it('keeps CSS anchor positioning for a surface that does not need Floating UI', () => { + mountWithMode('fallback', { positioning: { position: 'below' } }); - // Browsers without `position-area` (the React 17 run's Electron 118) take the engine for every surface. cy.window().then(win => { - if (win.CSS?.supports?.('position-area', 'bottom')) { - cy.get(surfaceSelector).should($el => { - expect($el).to.have.length(1); - expect($el[0]).not.to.have.attr('data-popper-placement'); - expect($el[0].style.getPropertyValue('position-anchor')).to.match(/^--popover-anchor-/); - }); + if (supportsPositionArea(win)) { + expectCssAnchorPositioning(); } else { cy.get(surfaceSelector).should('have.attr', 'data-popper-placement', 'bottom'); } }); }); - it('hands over to the engine for a surface with engine-only options', () => { - mountWithFallback({ position: 'below', autoSize: true }); + it('hands over to Floating UI for a surface with engine-only options', () => { + mountWithMode('fallback', { positioning: { position: 'below', autoSize: true } }); cy.get(surfaceSelector) .should('have.attr', 'data-popper-placement', 'bottom') @@ -157,4 +132,23 @@ describe('Popover positioning engine', () => { }); }); }); + + describe('PositioningProvider mode="css"', () => { + it('opts a subtree back to CSS anchor positioning inside another provider', () => { + mount( + + + + + , + ); + + cy.get(surfaceSelector).should('not.have.attr', 'data-popper-placement'); + cy.window().then(win => { + if (supportsPositionArea(win)) { + expectCssAnchorPositioning(); + } + }); + }); + }); }); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts index 5f05db392bb50..5bbb0f349b769 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningEngineContext.ts @@ -6,26 +6,10 @@ import type { PositioningEngine } from '@fluentui/react-positioning'; const PositioningEngineContext = React.createContext(undefined); /** - * Supplies a default positioning engine to every headless component below it. A component-level - * `positioning={{ engine }}` still takes precedence. - * - * Wrap the engine with `fallbackPositioningEngine` to keep native CSS anchor positioning for surfaces - * that do not need an engine; a plain engine positions every surface below the provider. - * - * @example - * ```tsx - * import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; - * import { - * PositioningEngineProvider, - * fallbackPositioningEngine, - * } from '@fluentui/react-headless-components-preview/positioning'; - * - * - * - * - * ``` + * Internal: supplies the positioning engine to every headless component below it. Consumers select + * an engine through `PositioningProvider` from the `positioning-floating-ui` entry. */ -export const PositioningEngineProvider: React.Provider = +export const PositioningEngineContextProvider: React.Provider = PositioningEngineContext.Provider; export const usePositioningEngineContext = (): PositioningEngine | undefined => diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningProvider.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningProvider.test.tsx new file mode 100644 index 0000000000000..40cd408d1cbcc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningProvider.test.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import type { PositioningEngine } from '@fluentui/react-positioning'; +import { PositioningProvider } from './PositioningProvider'; +import { usePositioningEngineContext } from './PositioningEngineContext'; +import { isFallbackPositioningEngine } from './fallbackPositioningEngine'; + +function readEngine(element: (capture: React.ReactNode) => React.ReactElement) { + let engine: PositioningEngine | undefined; + const Capture = () => { + engine = usePositioningEngineContext(); + return null; + }; + render(element()); + return engine; +} + +describe('PositioningProvider', () => { + it('supplies no engine without a provider', () => { + expect(readEngine(capture => <>{capture})).toBeUndefined(); + }); + + it('supplies a fallback-wrapped Floating UI engine in fallback mode', () => { + const engine = readEngine(capture => {capture}); + + expect(engine).toBeDefined(); + expect(isFallbackPositioningEngine(engine)).toBe(true); + }); + + it('supplies the Floating UI engine as is in floating-ui mode', () => { + const engine = readEngine(capture => {capture}); + + expect(engine).toBe(floatingUIPositioningEngine); + }); + + it('supplies no engine in css mode', () => { + expect(readEngine(capture => {capture})).toBeUndefined(); + }); + + it('lets a nested provider override the mode for its subtree', () => { + const cssInsideFallback = readEngine(capture => ( + + {capture} + + )); + const floatingInsideFallback = readEngine(capture => ( + + {capture} + + )); + + expect(cssInsideFallback).toBeUndefined(); + expect(floatingInsideFallback).toBe(floatingUIPositioningEngine); + }); + + it('keeps the same engine identity across renders', () => { + const first = readEngine(capture => {capture}); + const second = readEngine(capture => {capture}); + + expect(first).toBe(second); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningProvider.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningProvider.tsx new file mode 100644 index 0000000000000..f95c74f288526 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/PositioningProvider.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import type { PositioningEngine } from '@fluentui/react-positioning'; +import type { JSXElement } from '@fluentui/react-utilities'; +import { PositioningEngineContextProvider } from './PositioningEngineContext'; +import { fallbackPositioningEngine } from './fallbackPositioningEngine'; + +/** + * How headless surfaces below a `PositioningProvider` are positioned. + * + * - `fallback`: native CSS anchor positioning, handing over to Floating UI when the browser does not + * support `position-area` or a surface uses an option CSS cannot express (`autoSize`, boundaries, …). + * - `floating-ui`: Floating UI for every surface, the same positioner Fluent UI React v9 uses. + * - `css`: native CSS anchor positioning only, the default without a provider. + */ +export type PositioningMode = 'fallback' | 'floating-ui' | 'css'; + +export type PositioningProviderProps = { + mode: PositioningMode; + children?: React.ReactNode; +}; + +const ENGINES: Record = { + fallback: fallbackPositioningEngine(floatingUIPositioningEngine), + 'floating-ui': floatingUIPositioningEngine, + css: undefined, +}; + +/** + * Selects how headless surfaces below it are positioned. The nearest provider wins, so a nested + * provider overrides the mode for its subtree. + * + * @example + * ```tsx + * import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; + * + * + * + * + * ``` + */ +export const PositioningProvider = (props: PositioningProviderProps): JSXElement => ( + {props.children} +); + +PositioningProvider.displayName = 'PositioningProvider'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackPositioningEngine.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackPositioningEngine.ts index a68fb1fea58d8..8882e7580e037 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackPositioningEngine.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackPositioningEngine.ts @@ -4,23 +4,11 @@ const fallbackEngines = new WeakSet(); const fallbackEnginesByEngine = new WeakMap(); /** - * Marks a positioning engine as a fallback. Surfaces then use native CSS anchor positioning when the - * browser supports it and their options do not need an engine, and hand over to `engine` otherwise. + * Internal: marks a positioning engine as a fallback. Surfaces then use native CSS anchor positioning + * when the browser supports it and their options do not need an engine, and hand over to `engine` + * otherwise. Backs `PositioningProvider mode="fallback"`. * - * Calling it again with the same engine returns the same wrapper, so it is safe to call during render. - * - * @example - * ```tsx - * import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; - * import { - * PositioningEngineProvider, - * fallbackPositioningEngine, - * } from '@fluentui/react-headless-components-preview/positioning'; - * - * - * - * - * ``` + * Calling it again with the same engine returns the same wrapper. */ export function fallbackPositioningEngine(engine: PositioningEngine): PositioningEngine { if (fallbackEngines.has(engine)) { 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 f25997d57996c..fbc862d4f13a4 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 @@ -1,10 +1,8 @@ export { resolvePositioningShorthand } from './resolvePositioningShorthand'; export { usePositioning } from './usePositioning'; -export { PositioningEngineProvider } from './PositioningEngineContext'; -export { fallbackPositioningEngine } from './fallbackPositioningEngine'; export { getPlacementString } from './utils'; export { POSITIONS, ALIGNMENTS } from './constants'; -export type { PositioningEngine, PositioningProps, PositioningShorthand, PositioningReturn } from './types'; +export type { PositioningProps, PositioningShorthand, PositioningReturn } from './types'; export type { Alignment, 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 7b3e1e5a0be14..2c3121d400772 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,11 +1,5 @@ import type * as React from 'react'; -import type { - PositioningEngine, - PositioningProps as CanonicalPositioningProps, - PositioningShorthandValue, -} from '@fluentui/react-positioning'; - -export type { PositioningEngine }; +import type { PositioningProps, PositioningShorthandValue } from '@fluentui/react-positioning'; export type LogicalAlignment = 'start' | 'center' | 'end'; @@ -15,26 +9,8 @@ export type PositioningReturn = { arrowRef: React.RefCallback; }; -/** - * Positioning options accepted by headless components. - * - * This is the canonical `PositioningProps` contract from `@fluentui/react-positioning` plus an optional - * `engine`. Without an engine, surfaces are positioned with CSS anchor positioning, which honors - * `position`, `align`, `offset`, `coverTarget`, `matchTargetSize`, `pinned`, `fallbackPositions`, - * `strategy`, `target` and `positioningRef`. The remaining options (`autoSize`, `flipBoundary`, - * `overflowBoundary`, `overflowBoundaryPadding`, `shiftToCoverTarget`, `arrowPadding`, `useTransform`, - * `disableUpdateOnResize`, `onPositioningEnd`) require an engine and are ignored otherwise — a warning - * is logged in development. - */ -export type PositioningProps = CanonicalPositioningProps & { - /** - * A JavaScript positioning engine that replaces CSS anchor positioning for this surface entirely. - * When set (or provided through `PositioningEngineProvider`), the engine owns every positioning - * option, including the ones the CSS engine also supports. An engine wrapped with - * `fallbackPositioningEngine` is only used when the browser lacks CSS anchor positioning or an - * option needs an engine. - */ - engine?: PositioningEngine; -}; +// Headless accepts the canonical `PositioningProps` contract. Without `PositioningProvider` (from the +// `positioning-floating-ui` entry) only the options CSS anchor positioning can express take effect. +export type { PositioningProps } from '@fluentui/react-positioning'; export type PositioningShorthand = PositioningProps | PositioningShorthandValue; 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 e3c9b269cb115..f77176808d9cc 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,7 +2,7 @@ import * as React from 'react'; import { act, render } from '@testing-library/react'; import type { PositioningEngine, PositioningImperativeRef, PositionManager } from '@fluentui/react-positioning'; import { usePositioning } from './usePositioning'; -import { PositioningEngineProvider } from './PositioningEngineContext'; +import { PositioningEngineContextProvider, usePositioningEngineContext } from './PositioningEngineContext'; import { fallbackPositioningEngine } from './fallbackPositioningEngine'; import { getPlacementString } from './utils/placement'; import type { PositioningProps, PositioningReturn } from './types'; @@ -246,7 +246,7 @@ describe('usePositioning', () => { return engine as unknown as FakeEngine; }; - const Surface = (props: { + const Positioned = (props: { positioning: PositioningProps; withArrow?: boolean; onResult?: (result: PositioningReturn) => void; @@ -264,10 +264,26 @@ describe('usePositioning', () => { ); }; + /** + * Supplies `engine` through context, the way `PositioningProvider` does. Without `engine` it keeps the + * outer context, so the tree shape stays the same when a test adds or removes the engine. + */ + const Surface = ({ + engine, + ...props + }: React.ComponentProps & { engine?: PositioningEngine }) => { + const outerEngine = usePositioningEngineContext(); + return ( + + + + ); + }; + it('invokes engine.create with the merged options, target, container and arrow', () => { const engine = createFakeEngine(); const { getByTestId } = render( - , + , ); expect(engine.create).toHaveBeenCalledTimes(1); @@ -284,7 +300,7 @@ describe('usePositioning', () => { it('does not apply CSS anchor positioning when an engine owns placement', () => { const engine = createFakeEngine(); - const { getByTestId } = render(); + const { getByTestId } = render(); expect(getByTestId('target').style.getPropertyValue('anchor-name')).toBe(''); expect(getByTestId('container').style.getPropertyValue('position-anchor')).toBe(''); @@ -293,7 +309,7 @@ describe('usePositioning', () => { it('seeds data-placement with the requested placement before the engine runs', () => { const engine = createFakeEngine(); - const { getByTestId } = render(); + const { getByTestId } = render(); expect(getByTestId('container')).toHaveAttribute('data-placement', 'after-top'); }); @@ -302,9 +318,9 @@ describe('usePositioning', () => { const engine = createFakeEngine(); const first = jest.fn(); const second = jest.fn(); - const { rerender } = render(); + const { rerender } = render(); - rerender(); + rerender(); expect(engine.create).toHaveBeenCalledTimes(1); const event = new CustomEvent('fui-positioningend', { @@ -318,7 +334,7 @@ describe('usePositioning', () => { it('disposes the manager on unmount', () => { const engine = createFakeEngine(); - const { unmount } = render(); + const { unmount } = render(); unmount(); @@ -327,13 +343,13 @@ describe('usePositioning', () => { it('recreates the manager when an option changes and not when values are equal', () => { const engine = createFakeEngine(); - const { rerender } = render(); + const { rerender } = render(); expect(engine.create).toHaveBeenCalledTimes(1); - rerender(); + rerender(); expect(engine.create).toHaveBeenCalledTimes(1); - rerender(); + rerender(); expect(engine.manager.dispose).toHaveBeenCalledTimes(1); expect(engine.create).toHaveBeenCalledTimes(2); expect(engine.create.mock.calls[1][0].options.offset).toBe(8); @@ -342,7 +358,7 @@ describe('usePositioning', () => { it('delegates the imperative ref to the manager', () => { const engine = createFakeEngine(); const positioningRef = React.createRef(); - render(); + render(); act(() => { positioningRef.current?.updatePosition(); @@ -357,28 +373,17 @@ describe('usePositioning', () => { expect(engine.create.mock.calls[1][0].target).toBe(virtualTarget); }); - it('falls back to the engine supplied by PositioningEngineProvider', () => { - const engine = createFakeEngine(); + it('uses the engine from the nearest provider', () => { + const outerEngine = createFakeEngine(); + const innerEngine = createFakeEngine(); render( - - - , + + + , ); - expect(engine.create).toHaveBeenCalledTimes(1); - }); - - it('prefers an inline engine over the one from context', () => { - const contextEngine = createFakeEngine(); - const inlineEngine = createFakeEngine(); - render( - - - , - ); - - expect(inlineEngine.create).toHaveBeenCalledTimes(1); - expect(contextEngine.create).not.toHaveBeenCalled(); + expect(innerEngine.create).toHaveBeenCalledTimes(1); + expect(outerEngine.create).not.toHaveBeenCalled(); }); it('switches from CSS anchor positioning to the engine when one is added later', () => { @@ -388,7 +393,7 @@ describe('usePositioning', () => { expect(getByTestId('container').style.getPropertyValue('position-anchor')).toMatch(/^--popover-anchor-/); expect(engine.create).not.toHaveBeenCalled(); - rerender(); + rerender(); expect(engine.create).toHaveBeenCalledTimes(1); expect(getByTestId('target').style.getPropertyValue('anchor-name')).toBe(''); @@ -404,7 +409,7 @@ describe('usePositioning', () => { it('switches back to CSS anchor positioning when the engine is removed', () => { const engine = createFakeEngine(); - const { rerender, getByTestId } = render(); + const { rerender, getByTestId } = render(); rerender(); @@ -426,7 +431,7 @@ describe('usePositioning', () => { it('does not warn when the same options are used with an engine', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); - render(); + render(); expect(warn).not.toHaveBeenCalled(); warn.mockRestore(); @@ -451,9 +456,9 @@ describe('usePositioning', () => { const renderWithFallback = (engine: FakeEngine, positioning: PositioningProps) => render( - + - , + , ); it('returns the same wrapper for the same engine', () => { @@ -508,35 +513,39 @@ describe('usePositioning', () => { const engine = createFakeEngine(); const fallback = fallbackPositioningEngine(engine); const { rerender, getByTestId } = render( - + - , + , ); rerender( - + - , + , ); expect(engine.create).toHaveBeenCalledTimes(1); expect(getByTestId('container').style.getPropertyValue('position-anchor')).toBe(''); rerender( - + - , + , ); expect(engine.manager.dispose).toHaveBeenCalledTimes(1); expect(getByTestId('container').style.getPropertyValue('position-anchor')).toMatch(/^--popover-anchor-/); }); - it('lets an inline engine override a fallback engine from context', () => { - const contextEngine = createFakeEngine(); - const inlineEngine = createFakeEngine(); - renderWithFallback(contextEngine, { engine: inlineEngine }); + it('lets a nested plain engine override a fallback engine', () => { + const fallbackEngine = createFakeEngine(); + const plainEngine = createFakeEngine(); + render( + + + , + ); - expect(inlineEngine.create).toHaveBeenCalledTimes(1); - expect(contextEngine.create).not.toHaveBeenCalled(); + expect(plainEngine.create).toHaveBeenCalledTimes(1); + expect(fallbackEngine.create).not.toHaveBeenCalled(); }); it('does not warn about engine-only options', () => { 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 949bd749e28ee..e110fb5ccc320 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 @@ -39,15 +39,14 @@ const noopRef: React.RefCallback = () => undefined; /** * Positions a surface relative to a target. * - * By default this uses native CSS anchor positioning. When an engine is supplied — inline through - * `options.engine` or app-wide through `PositioningEngineProvider` — the engine owns positioning - * entirely and the CSS path is not applied. An engine wrapped with `fallbackPositioningEngine` is only - * used when the browser lacks CSS anchor positioning or the options need an engine. + * By default this uses native CSS anchor positioning. `PositioningProvider` (from the + * `positioning-floating-ui` entry) supplies an engine through context: in `floating-ui` mode the engine + * owns positioning entirely, in `fallback` mode it is only used when the browser lacks CSS anchor + * positioning or the options need an engine. */ export function usePositioning(options: PositioningProps): PositioningReturn { - const { engine: engineFromOptions, positioningRef, ...positioningOptions } = options; - const engineFromContext = usePositioningEngineContext(); - const requestedEngine = engineFromOptions ?? engineFromContext; + const { positioningRef, ...positioningOptions } = options; + const requestedEngine = usePositioningEngineContext(); const { targetDocument } = useFluent(); const requiresEngine = @@ -78,8 +77,8 @@ export function usePositioning(options: PositioningProps): PositioningReturn { console.warn( '@fluentui/react-headless-components-preview [usePositioning]: ' + `${unsupportedOptions} require a JavaScript positioning engine and have no effect with CSS anchor ` + - 'positioning. Pass `positioning={{ engine }}` or wrap the tree in `PositioningEngineProvider` ' + - '(for example with `floatingUIPositioningEngine` from `@fluentui/react-positioning`).', + 'positioning. Wrap the tree in `` from ' + + '`@fluentui/react-headless-components-preview/positioning-floating-ui`.', ); } }, [unsupportedOptions]); diff --git a/packages/react-components/react-headless-components-preview/library/src/positioning-floating-ui.ts b/packages/react-components/react-headless-components-preview/library/src/positioning-floating-ui.ts new file mode 100644 index 0000000000000..acbe73d953d84 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/positioning-floating-ui.ts @@ -0,0 +1,2 @@ +export { PositioningProvider } from './hooks/usePositioning/PositioningProvider'; +export type { PositioningMode, PositioningProviderProps } from './hooks/usePositioning/PositioningProvider'; 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 f68a50b37966d..3c24fa062d54b 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,7 +1,6 @@ export type { Position, Alignment, - PositioningEngine, PositioningProps, PositioningReturn, PositioningImperativeRef, @@ -10,8 +9,6 @@ export type { } from './hooks/usePositioning'; export { usePositioning, - PositioningEngineProvider, - fallbackPositioningEngine, POSITIONS, ALIGNMENTS, getPlacementString, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningDescription.md index a1cfdafed09fc..69306b6069f6f 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningDescription.md +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningDescription.md @@ -7,6 +7,6 @@ Headless Fluent components that make use of positioning can all be configured in Components that have slots which are positioned will always expose a `positioning` prop where the positioning of the slot can be configured. The prop accepts the same `PositioningProps` contract as Fluent UI React v9. -Surfaces are positioned with native CSS anchor positioning by default. Options that CSS cannot express (`autoSize`, boundaries, …) require a JavaScript positioning **engine** — see the _Engine_ example below. +Surfaces are positioned with native CSS anchor positioning by default. Options that CSS cannot express (`autoSize`, boundaries, …) require Floating UI through `PositioningProvider` — see the _Engine_ example below. Below you can try out the different positioning options in the playground. Further examples try to explain more clearly different configuration options for the `positioning` prop. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx index be3e82cddf3b5..95ae453e3048c 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngine.stories.tsx @@ -1,11 +1,6 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -import { - PositioningEngineProvider, - fallbackPositioningEngine, -} from '@fluentui/react-headless-components-preview/positioning'; -// eslint-disable-next-line @fluentui/no-restricted-imports -import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; import descriptionMd from './PositioningEngineDescription.md'; import styles from './positioning.module.css'; @@ -13,42 +8,13 @@ import styles from './positioning.module.css'; export const Engine = (): React.ReactNode => (
-

Per surface: the engine replaces CSS anchor positioning

+

mode="fallback": CSS where it is enough, Floating UI where not

- Identical positioning props. The left surface is laid out by the browser with{' '} - position-area; the right one passes engine: floatingUIPositioningEngine, so Floating - UI computes left/top instead and no anchor CSS is written. + The recommended app-wide setup. The left surface keeps native CSS anchor positioning; the right one sets{' '} + autoSize, which CSS cannot express, and hands over to Floating UI. In a browser without{' '} + position-area both use Floating UI.

-
- - - - - - position-area Positioned by the browser. - - - - - - - - - engine Positioned by Floating UI. - - -
-
- -
-

App-wide fallback: fallbackPositioningEngine

-

- The recommended app-wide setup. Wrapped with fallbackPositioningEngine, the provider's engine is - only used by surfaces that need it: the left surface keeps native CSS anchor positioning, the right one sets{' '} - autoSize and hands over to Floating UI. In a browser without position-area both use - the engine. -

- +
@@ -64,29 +30,59 @@ export const Engine = (): React.ReactNode => ( - engine Positioned by Floating UI. + floating-ui Positioned by Floating UI.
-
+ +
+ +
+

mode="floating-ui": Floating UI for every surface

+

+ Identical positioning props. The left surface is laid out by the browser with{' '} + position-area; the right one is wrapped in{' '} + PositioningProvider mode="floating-ui", so Floating UI computes left/ + top instead and no anchor CSS is written. The nearest provider wins, so wrapping a single surface + overrides the mode for it alone. +

+
+ + + + + + position-area Positioned by the browser. + + + + + + + + + + floating-ui Positioned by Floating UI. + + + +
-

App-wide: PositioningEngineProvider

+

Arrows

- The provider makes the engine the default for every headless surface beneath it; none of the surfaces below sets{' '} - engine. A component-level engine still wins over the provider. The engine also - receives the withArrow element and writes its left/top along the anchored - edge; CSS only paints it. + Floating UI also receives the withArrow element and writes its left/top{' '} + along the anchored edge; CSS only paints it.

- +
- Engine from context. + Positioned by Floating UI. @@ -99,7 +95,7 @@ export const Engine = (): React.ReactNode => (
-
+
); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx index cf5f9eb6a3364..c6a315d16bbe1 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineAutoSize.stories.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; -// eslint-disable-next-line @fluentui/no-restricted-imports -import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; import styles from './positioning.module.css'; import menuStyles from '../../Menu/menu.module.css'; @@ -12,49 +11,50 @@ export const EngineAutoSize = (): React.ReactNode => { const [itemCount, setItemCount] = React.useState(10); return ( -
-
- - + +
+
+ + +
+
+ setOpen(data.open)} + positioning={{ + overflowBoundary: boundary, + flipBoundary: boundary, + autoSize: true, + }} + > + + + + + + {Array.from({ length: itemCount }, (_, i) => ( + + Item {i} + + ))} + + + +
-
- setOpen(data.open)} - positioning={{ - overflowBoundary: boundary, - flipBoundary: boundary, - autoSize: true, - engine: floatingUIPositioningEngine, - }} - > - - - - - - {Array.from({ length: itemCount }, (_, i) => ( - - Item {i} - - ))} - - - -
-
+ ); }; @@ -64,7 +64,7 @@ EngineAutoSize.parameters = { story: [ '`autoSize` sets inline `max-width` and `max-height` styles on the element to ensure it fits within the', 'available space, and makes it scroll. Increase the item count past what fits in the dashed box to see it', - 'kick in. Requires a positioning engine; component-derived options (the menu still opens `below-start`) are', + 'kick in. Requires `PositioningProvider` in `floating-ui` or `fallback` mode; component-derived options (the menu still opens `below-start`) are', 'merged before the engine is invoked.', ].join('\n'), }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md index b0f9d6d029f95..597839dc522db 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDescription.md @@ -1,60 +1,45 @@ -By default headless surfaces are positioned by the browser with native CSS anchor positioning: no JavaScript positioner runs. A **positioning engine** swaps that for an imperative positioner, per surface or app-wide. +By default headless surfaces are positioned by the browser with native CSS anchor positioning: no JavaScript positioner runs. `PositioningProvider` switches the surfaces below it to Floating UI, the positioner that powers Fluent UI React v9, either where CSS is not enough or everywhere. ```tsx -import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; -import { - PositioningEngineProvider, - fallbackPositioningEngine, -} from '@fluentui/react-headless-components-preview/positioning'; - -// app-wide, recommended: CSS where it is enough, the engine where it is not - - -; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; -// one surface -; +// recommended: CSS where it is enough, Floating UI where it is not + + +; -// every surface below the provider (a component-level `engine` still wins) - +// Floating UI for every surface below + -; +; + +// back to CSS only for a subtree + +
+; ``` -### When you need one +The nearest provider wins, so a nested provider overrides the mode for its subtree, down to a single surface. `@floating-ui/*` is only bundled by apps that import the `positioning-floating-ui` entry; the rest of the headless package never includes it. -Every option of the canonical `positioning` contract is accepted, but these have no CSS equivalent and only take effect with an engine: `autoSize`, `flipBoundary`, `overflowBoundary`, `overflowBoundaryPadding`, `shiftToCoverTarget`, `arrowPadding`, `useTransform`, `disableUpdateOnResize`, `onPositioningEnd`. Passing them without an engine logs a development warning and does nothing. +### When you need it -### Fallback engine +Every option of the canonical `positioning` contract is accepted, but these have no CSS equivalent and only take effect under `PositioningProvider` (`fallback` or `floating-ui` mode): `autoSize`, `flipBoundary`, `overflowBoundary`, `overflowBoundaryPadding`, `shiftToCoverTarget`, `arrowPadding`, `useTransform`, `disableUpdateOnResize`, `onPositioningEnd`. Without it they log a development warning and do nothing. Browsers without CSS anchor positioning also need it, otherwise surfaces open unpositioned. -A plain engine in `PositioningEngineProvider` positions every surface below it, so one part of an app that needs `autoSize` would opt the whole subtree out of native anchoring. Wrap the engine with `fallbackPositioningEngine` instead: each surface keeps CSS anchor positioning and only hands over to the engine when +### `fallback` mode -- the browser does not support `position-area` (Chromium before 129, Safari before 26, Firefox before 147), or -- it sets an option that needs an engine: `autoSize`, `flipBoundary`, `overflowBoundary`, `overflowBoundaryPadding`, `shiftToCoverTarget`, `arrowPadding`, `onPositioningEnd`, or a function `offset`. +Each surface keeps CSS anchor positioning and only hands over to Floating UI when -`useTransform` and `disableUpdateOnResize` only tune an engine, so they do not trigger the handover on their own. If the options change at runtime the surface switches between the two paths. - -`floatingUIPositioningEngine` is the Floating UI implementation that powers Fluent UI React v9, so a headless app that imports it gets full v9 parity; the `Engine…` stories below reproduce the corresponding v9 positioning examples. `@floating-ui/*` is only bundled if you import the engine; the headless package's bundle-isolation check forbids it. +- the browser does not support `position-area` (Chromium before 129, Safari before 26, Firefox before 147), or +- it sets an option CSS cannot express: `autoSize`, `flipBoundary`, `overflowBoundary`, `overflowBoundaryPadding`, `shiftToCoverTarget`, `arrowPadding`, `onPositioningEnd`, or a function `offset`. -### What an engine is +`useTransform` and `disableUpdateOnResize` only tune Floating UI, so they do not trigger the handover on their own. If the options change at runtime the surface switches between the two paths. One part of an app that needs `autoSize` therefore does not opt the rest of the app out of native anchoring. -```ts -interface PositioningEngine { - create(params: { - container: HTMLElement; // the surface - target: HTMLElement | PositioningVirtualElement; // trigger, or a virtual element for context menus - arrow: HTMLElement | null; // present when `withArrow` - options: PositioningOptions; // props merged with component-derived options (submenu side, pointer target…) - dir?: 'ltr' | 'rtl'; - targetDocument?: Document; - }): { updatePosition(): void; dispose(): void }; -} -``` +### `floating-ui` mode -`create()` is called from a layout effect once the elements exist and disposed when they change or unmount. It is a plain object, never a hook, so it can come from props or context and change identity freely. An engine **replaces** CSS anchor positioning entirely: it owns every option, including the ones CSS also supports, so two positioners never mix. +Every surface below is positioned by Floating UI, including those CSS could handle, so the two positioners never mix on a surface. Use it to match v9 behaviour exactly or to work around a browser issue with native anchoring; the `Engine…` stories below use it to reproduce the corresponding v9 positioning examples. -The engine keeps the container positioned (including releasing the UA top-layer `inset: 0; margin: auto` that `[popover]` and `dialog:modal` receive) and keeps the container's `data-placement` current with the resolved **logical** placement (`above-start`, `after-top`, …), so placement-keyed CSS and arrows work the same way as with CSS anchor positioning. On `dispose` it restores the inline styles and attributes it wrote, so switching between an engine and CSS anchor positioning at runtime hands over a clean element. +Floating UI keeps the container positioned (including releasing the UA top-layer `inset: 0; margin: auto` that `[popover]` and `dialog:modal` receive) and keeps the container's `data-placement` current with the resolved **logical** placement (`above-start`, `after-top`, …), so placement-keyed CSS and arrows work the same way as with CSS anchor positioning. When a surface switches back to CSS it restores the inline styles and attributes it wrote. ### Arrows -With `withArrow`, the engine positions the arrow along the surface edge (`arrowPadding` keeps it away from rounded corners), but headless components have no built-in arrow size, so the gap between the surface and the target is not adjusted for it. Set `offset` to at least the arrow's protruding size, e.g. `positioning={{ offset: 8 }}` for an 8px arrow. +With `withArrow`, Floating UI positions the arrow along the surface edge (`arrowPadding` keeps it away from rounded corners), but headless components have no built-in arrow size, so the gap between the surface and the target is not adjusted for it. Set `offset` to at least the arrow's protruding size, e.g. `positioning={{ offset: 8 }}` for an 8px arrow. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx index f614b8829d43c..7562897cfbe46 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineDisableTransform.stories.tsx @@ -1,32 +1,33 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -// eslint-disable-next-line @fluentui/no-restricted-imports -import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; import styles from './positioning.module.css'; export const EngineDisableTransform = (): React.ReactNode => ( -
-
- - - - - - Positioned with transform: translate3d() - - + +
+
+ + + + + + Positioned with transform: translate3d() + + - - - - - - Positioned with left/top - - + + + + + + Positioned with left/top + + +
-
+ ); EngineDisableTransform.parameters = { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx index 16189c0cfabc4..256778d241036 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineFlipBoundary.stories.tsx @@ -1,8 +1,6 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; -// eslint-disable-next-line @fluentui/no-restricted-imports -import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; import styles from './positioning.module.css'; @@ -10,7 +8,7 @@ export const EngineFlipBoundary = (): React.ReactNode => { const [boundary, setBoundary] = React.useState(null); return ( - +
@@ -32,7 +30,7 @@ export const EngineFlipBoundary = (): React.ReactNode => {
-
+ ); }; @@ -42,8 +40,8 @@ EngineFlipBoundary.parameters = { story: [ 'The flip boundary can be configured manually so that the positioned element stays within bounds for', 'different positions. `flipBoundary` has no CSS anchor positioning equivalent (`position-try-fallbacks` always', - 'flips against the containing block), so it requires a positioning engine — here supplied app-wide through', - '`PositioningEngineProvider`.', + 'flips against the containing block), so it requires `PositioningProvider` in `floating-ui` or `fallback` mode — here supplied app-wide through', + '``.', ].join('\n'), }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx index 3e3d4e49e5469..9b8767d1cdbca 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineListenToUpdates.stories.tsx @@ -4,8 +4,7 @@ import type { PositioningImperativeRef, PositioningProps, } from '@fluentui/react-headless-components-preview/positioning'; -// eslint-disable-next-line @fluentui/no-restricted-imports -import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; import styles from './positioning.module.css'; @@ -27,33 +26,35 @@ export const EngineListenToUpdates = (): React.ReactNode => { }, []); return ( -
-
- !data.open && setStatusLog([])} - positioning={{ positioningRef, onPositioningEnd, position: 'below', engine: floatingUIPositioningEngine }} - > - - - - - - - -
    - {statusLog.length === 0 ? ( -
  1. Open the popover to see position updates.
  2. - ) : ( - statusLog.map((entry, i) =>
  3. {entry}
  4. ) - )} -
+ +
+
+ !data.open && setStatusLog([])} + positioning={{ positioningRef, onPositioningEnd, position: 'below' }} + > + + + + + + + +
    + {statusLog.length === 0 ? ( +
  1. Open the popover to see position updates.
  2. + ) : ( + statusLog.map((entry, i) =>
  3. {entry}
  4. ) + )} +
+
-
+ ); }; @@ -67,7 +68,7 @@ EngineListenToUpdates.parameters = { '`referenceHidden`; the engine has already mirrored the logical placement into `data-placement` on the surface', 'by the time the callback runs.', '', - 'CSS anchor positioning has no positioning lifecycle, so this callback requires a positioning engine.', + 'CSS anchor positioning has no positioning lifecycle, so this callback requires `PositioningProvider` in `floating-ui` or `fallback` mode.', '', '> ⚠️ _Very few use cases actually require listening to position updates. This is different from the_', '_**open/close state**, which is handled in React._', diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx index 75f462f74a98b..1d8e8cbf63e83 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundary.stories.tsx @@ -1,8 +1,6 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; -// eslint-disable-next-line @fluentui/no-restricted-imports -import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; import styles from './positioning.module.css'; @@ -10,7 +8,7 @@ export const EngineOverflowBoundary = (): React.ReactNode => { const [boundary, setBoundary] = React.useState(null); return ( - +
{
-
+ ); }; @@ -44,7 +42,7 @@ EngineOverflowBoundary.parameters = { description: { story: [ 'The overflow boundary can be configured manually so that the positioned element is shifted to stay within', - 'bounds for different alignments. `overflowBoundary` requires a positioning engine.', + 'bounds for different alignments. `overflowBoundary` requires `PositioningProvider` in `floating-ui` or `fallback` mode.', ].join('\n'), }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx index 1aa7cd4930e4d..2129bff1c4aa5 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryPadding.stories.tsx @@ -1,8 +1,6 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; -import { PositioningEngineProvider } from '@fluentui/react-headless-components-preview/positioning'; -// eslint-disable-next-line @fluentui/no-restricted-imports -import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; import styles from './positioning.module.css'; @@ -11,7 +9,7 @@ export const EngineOverflowBoundaryPadding = (): React.ReactNode => { const [padding, setPadding] = React.useState(8); return ( - +
-
+ ); }; @@ -72,7 +70,7 @@ EngineOverflowBoundaryPadding.parameters = { story: [ 'The `overflowBoundaryPadding` property sets the padding between the positioned element and the chosen', 'boundary. The padding can be a shorthand number which applies to all sides, or an object that explicitly', - 'sets the padding for each side. Requires a positioning engine.', + 'sets the padding for each side. Requires `PositioningProvider` in `floating-ui` or `fallback` mode.', '', '> _Design guidance recommends using **8px** or **4px** if a padding is required. Custom values are also_', '_possible but should stay within a 4px grid._', diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx index a1dfdfa35e8f3..843e11f96461c 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineOverflowBoundaryRect.stories.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; import type { PositioningProps } from '@fluentui/react-headless-components-preview/positioning'; -// eslint-disable-next-line @fluentui/no-restricted-imports -import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; import styles from './positioning.module.css'; @@ -16,46 +15,46 @@ export const EngineOverflowBoundaryRect = (): React.ReactNode => { }, []); return ( -
-
- Container -
- Boundary -
+ +
+
+ Container +
+ Boundary +
- - - - - - Stays within the defined rect - - + + + + + + Stays within the defined rect + + - - - - - - Stays within the defined rect - - + + + + + + Stays within the defined rect + + +
-
+ ); }; @@ -64,7 +63,7 @@ EngineOverflowBoundaryRect.parameters = { description: { story: [ 'Boundaries can also be defined as `Rect` objects. This is useful when a boundary is not an actual element,', - 'but some kind of computed values. Requires a positioning engine.', + 'but some kind of computed values. Requires `PositioningProvider` in `floating-ui` or `fallback` mode.', ].join('\n'), }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx index ee10572b191c5..8d4a484f4afca 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/PositioningEngineShiftToCoverTarget.stories.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu'; import type { Position, PositioningImperativeRef } from '@fluentui/react-headless-components-preview/positioning'; -// eslint-disable-next-line @fluentui/no-restricted-imports -import { floatingUIPositioningEngine } from '@fluentui/react-positioning'; +import { PositioningProvider } from '@fluentui/react-headless-components-preview/positioning-floating-ui'; import styles from './positioning.module.css'; import menuStyles from '../../Menu/menu.module.css'; @@ -25,61 +24,62 @@ export const EngineShiftToCoverTarget = (): React.ReactNode => { }, [boundary]); return ( -
-
- - - -
-
-
- setOpen(data.open)} - positioning={{ - positioningRef, - overflowBoundary: boundary, - flipBoundary: boundary, - autoSize: true, - shiftToCoverTarget: true, - position, - engine: floatingUIPositioningEngine, - }} - > - - - - - - {Array.from({ length: itemCount }, (_, i) => ( - - Item {i} - - ))} - - - + +
+
+ + + +
+
+
+ setOpen(data.open)} + positioning={{ + positioningRef, + overflowBoundary: boundary, + flipBoundary: boundary, + autoSize: true, + shiftToCoverTarget: true, + position, + }} + > + + + + + + {Array.from({ length: itemCount }, (_, i) => ( + + Item {i} + + ))} + + + +
-
+ ); }; @@ -89,7 +89,7 @@ EngineShiftToCoverTarget.parameters = { story: [ "`shiftToCoverTarget` allows the positioned element to shift and cover the target element when there isn't", 'enough space available to fit it. Drag the resize handle of the dashed box to shrink the available space.', - 'Requires a positioning engine.', + 'Requires `PositioningProvider` in `floating-ui` or `fallback` mode.', ].join('\n'), }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/utils.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/utils.stories.tsx index ef567aa6f5a7f..1d84b50256ca6 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/utils.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Concepts/Positioning/utils.stories.tsx @@ -5,6 +5,6 @@ import type { PositioningProps } from '@fluentui/react-headless-components-previ * Helper component used by Storybook to auto-generate the positioning props args table. * * Every option of the canonical contract is accepted; options that CSS anchor positioning cannot - * express take effect only when a positioning `engine` is supplied (see the Engine example). + * express take effect only under `PositioningProvider` (see the Engine example). */ export const Positioning: React.FC = () =>
; From 3ac25e9cdb37e1480eb544bc10a11c77684bcb18 Mon Sep 17 00:00:00 2001 From: mainframev Date: Mon, 5 Oct 2026 17:11:39 +0200 Subject: [PATCH 11/11] docs(react-positioning): tighten PositioningEngine JSDoc --- .../react-positioning/library/src/types.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-components/react-positioning/library/src/types.ts b/packages/react-components/react-positioning/library/src/types.ts index 9c59e03653351..2a7384bd96f05 100644 --- a/packages/react-components/react-positioning/library/src/types.ts +++ b/packages/react-components/react-positioning/library/src/types.ts @@ -96,8 +96,8 @@ export type PositioningEngineCreateParams = { * * Engines are plain objects, not hooks: `create` is invoked imperatively (typically from a layout * effect) once the container and target elements are known, and the returned {@link PositionManager} - * is disposed when they change or unmount. Because no React hook is involved, an engine can be - * supplied through props or context without any rules-of-hooks constraints. + * is disposed when they change or unmount. An engine can therefore be supplied through props or + * context without breaking the rules of hooks. * * Contract for engine authors — `create` must: * - leave the container fully positioned, including releasing any UA-provided `inset` when the @@ -105,8 +105,8 @@ export type PositioningEngineCreateParams = { * - keep the container's `data-placement` attribute current with the resolved logical placement, as * a {@link PositioningShorthandValue} (e.g. `above-start`, `after-top`); * - invoke `options.onPositioningEnd` after each update, if provided; - * - undo all of the above in `dispose`: release listeners and observers, and restore the inline styles - * and attributes it wrote on the container and arrow to their previous values, so that another + * - in `dispose`, release listeners and observers, and restore the inline styles and attributes it + * wrote on the container and arrow to their previous values, so that another * positioner (e.g. CSS anchor positioning) can take over a clean element. `dispose` also runs right * before `create` is called again with new options, so both must be synchronous. */