diff --git a/change/@fluentui-react-headless-components-preview-5e27c02f-ef47-43c0-a31b-db8de5c216d7.json b/change/@fluentui-react-headless-components-preview-5e27c02f-ef47-43c0-a31b-db8de5c216d7.json new file mode 100644 index 00000000000000..8f0cf6f110bd10 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-5e27c02f-ef47-43c0-a31b-db8de5c216d7.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat(react-headless-components-preview): hybrid usePositioning that prefers CSS Anchor Positioning and lazy-loads floating-ui as a fallback", + "packageName": "@fluentui/react-headless-components-preview", + "email": "dmytrokirpa@microsoft.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 0fa49ee74b6813..5c5401cd9c8a7e 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 @@ -35,6 +35,7 @@ export { PositioningProps } export type PositioningReturn = { targetRef: React_2.RefCallback; containerRef: React_2.RefCallback; + arrowRef: React_2.RefCallback; }; export { PositioningShorthand } @@ -49,9 +50,12 @@ export const POSITIONS: { readonly after: "after"; }; +// @public +export function preloadPositioning(): Promise; + export { 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/package.json b/packages/react-components/react-headless-components-preview/library/package.json index 2f6af185720d55..124063eb8bbfa9 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -60,6 +60,7 @@ "@fluentui/react-toolbar": "^9.8.0", "@fluentui/react-tooltip": "^9.10.1", "@fluentui/react-utilities": "^9.26.3", + "@floating-ui/dom": "^1.6.12", "@swc/helpers": "^0.5.1" }, "peerDependencies": { diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts index 14bbafd814b9b7..a67f1604751829 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts @@ -1,4 +1,4 @@ -export { usePositioning } from './usePositioning'; +export { usePositioning, preloadPositioning } from './usePositioning'; export type { Position, Alignment, diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/applyAnchorPositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/applyAnchorPositioning.ts new file mode 100644 index 00000000000000..84ca5adba51684 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/applyAnchorPositioning.ts @@ -0,0 +1,275 @@ +import type { PositioningProps } from '@fluentui/react-positioning'; +import { ALIGNMENTS, POSITIONS, POSITION_AREA_MAP } from './constants'; +import { + applyOffset, + getCoverSelfAlignment, + getPlacementString, + resolveOffset, + shorthandToPositionArea, +} from './utils'; +import { normalizeAlign } from './utils/placement'; +import type { LogicalAlignment } from './types'; + +// alert('Anchor Positioning'); + +const DEFAULT_FLIP = ['flip-block', 'flip-inline', 'flip-block flip-inline']; + +const PLACEMENT_TOLERANCE = 2; + +const closeTo = (a: number, b: number) => Math.abs(a - b) <= PLACEMENT_TOLERANCE; + +function detectPosition(surfaceRect: DOMRect, targetRect: DOMRect) { + if (surfaceRect.bottom <= targetRect.top + PLACEMENT_TOLERANCE) { + return POSITIONS.above; + } + if (surfaceRect.top >= targetRect.bottom - PLACEMENT_TOLERANCE) { + return POSITIONS.below; + } + if (surfaceRect.right <= targetRect.left + PLACEMENT_TOLERANCE) { + return POSITIONS.before; + } + if (surfaceRect.left >= targetRect.right - PLACEMENT_TOLERANCE) { + return POSITIONS.after; + } + return null; +} + +function detectAlign( + position: 'above' | 'below' | 'before' | 'after', + surfaceRect: DOMRect, + targetRect: DOMRect, +): LogicalAlignment { + const isBlockMain = position === POSITIONS.above || position === POSITIONS.below; + const startAligned = isBlockMain + ? closeTo(surfaceRect.left, targetRect.left) + : closeTo(surfaceRect.top, targetRect.top); + + if (startAligned) { + return ALIGNMENTS.start; + } + + const endAligned = isBlockMain + ? closeTo(surfaceRect.right, targetRect.right) + : closeTo(surfaceRect.bottom, targetRect.bottom); + + if (endAligned) { + return ALIGNMENTS.end; + } + + return ALIGNMENTS.center; +} + +const ANCHOR_PROPS = [ + 'position', + 'inset', + 'margin', + 'margin-block-start', + 'margin-block-end', + 'margin-inline-start', + 'margin-inline-end', + 'width', + 'position-anchor', + 'position-area', + 'place-self', + 'align-self', + 'justify-self', + 'position-try-fallbacks', + 'visibility', +] as const; + +const ARROW_PROPS = ['position', 'position-anchor', 'top', 'right', 'bottom', 'left', 'translate'] as const; + +export interface ApplyAnchorPositioningArgs { + target: HTMLElement; + container: HTMLElement; + arrow: HTMLElement | null; + anchorName: string; + options: PositioningProps; + targetDocument: Document | undefined; +} + +/** + * Applies CSS Anchor Positioning to the container, anchoring it to the target. + * All work is imperative — no React. Returns a cleanup that reverses every + * style mutation and removes any subscribed observers. + */ +export function applyAnchorPositioning({ + target, + container, + arrow, + anchorName, + options, + targetDocument, +}: ApplyAnchorPositioningArgs): () => void { + const align = normalizeAlign(options.align ?? ALIGNMENTS.center); + const position = options.position ?? POSITIONS.above; + const { mainAxis, crossAxis } = resolveOffset(options.offset); + const coverAlignment = options.coverTarget ? getCoverSelfAlignment(position, align) : null; + const positionArea = POSITION_AREA_MAP[position][align]; + const placement = getPlacementString(position, align); + const fallbackAreas = (options.fallbackPositions ?? []).map(shorthandToPositionArea); + const strategy = options.strategy ?? 'absolute'; + + // Anchor name on the target. + target.style.setProperty('anchor-name', anchorName); + + // Container styles. + container.style.setProperty('position', strategy); + container.style.setProperty('inset', 'auto'); + container.style.setProperty('margin', '0'); + + applyOffset(container, position, mainAxis, crossAxis); + + if (options.matchTargetSize === 'width') { + container.style.setProperty('width', 'anchor-size(width)'); + } else { + container.style.removeProperty('width'); + } + + container.style.setProperty('position-anchor', anchorName); + container.setAttribute('data-placement', placement); + + if (coverAlignment) { + container.style.setProperty('position-area', 'center'); + container.style.setProperty('align-self', coverAlignment.alignSelf); + container.style.setProperty('justify-self', coverAlignment.justifySelf); + container.style.removeProperty('position-try-fallbacks'); + } else { + container.style.setProperty('position-area', positionArea); + + // Workaround for https://crbug.com/438334710: Chromium (<=130-ish) doesn't + // apply implicit `anchor-center` self-alignment for single-keyword + // `position-area` values. + if (align === ALIGNMENTS.center) { + container.style.setProperty('place-self', 'anchor-center'); + } else { + container.style.removeProperty('place-self'); + container.style.removeProperty('align-self'); + container.style.removeProperty('justify-self'); + } + + if (options.pinned) { + container.style.removeProperty('position-try-fallbacks'); + } else if (fallbackAreas.length > 0) { + container.style.setProperty('position-try-fallbacks', fallbackAreas.join(', ')); + } else { + container.style.setProperty('position-try-fallbacks', DEFAULT_FLIP.join(', ')); + } + } + + // Mirror the browser-resolved placement into data-placement after flip fires. + const observerCleanup = observePlacement(container, target, targetDocument, !!options.coverTarget); + + // Position the arrow (if any) at the popover edge nearest the trigger, + // centered on the trigger's cross-axis. The arrow becomes a CSS-anchored + // element pointing at the same trigger, so `anchor(center)` resolves to the + // trigger's center coordinate translated into the popover's containing + // block. + if (arrow) { + applyArrowAnchor(arrow, anchorName, position); + } + + // Reveal the container — `usePositioning` hid it before scheduling this + // helper to avoid a flash at the default location while the chunk loaded. + // CSS Anchor Positioning resolves before the next paint, so by the time + // this line runs the anchored position is already what the browser will + // render. + container.style.removeProperty('visibility'); + + return () => { + observerCleanup(); + target.style.removeProperty('anchor-name'); + container.removeAttribute('data-placement'); + for (const prop of ANCHOR_PROPS) { + container.style.removeProperty(prop); + } + if (arrow) { + for (const prop of ARROW_PROPS) { + arrow.style.removeProperty(prop); + } + } + }; +} + +function applyArrowAnchor( + arrow: HTMLElement, + anchorName: string, + position: 'above' | 'below' | 'before' | 'after', +): void { + arrow.style.setProperty('position', 'absolute'); + arrow.style.setProperty('position-anchor', anchorName); + + // Reset any leftover edge offsets from a previous placement. + arrow.style.removeProperty('top'); + arrow.style.removeProperty('right'); + arrow.style.removeProperty('bottom'); + arrow.style.removeProperty('left'); + + switch (position) { + case POSITIONS.above: + arrow.style.setProperty('bottom', '0'); + arrow.style.setProperty('left', 'anchor(center)'); + arrow.style.setProperty('translate', '-50% 50%'); + break; + case POSITIONS.below: + arrow.style.setProperty('top', '0'); + arrow.style.setProperty('left', 'anchor(center)'); + arrow.style.setProperty('translate', '-50% -50%'); + break; + case POSITIONS.before: + arrow.style.setProperty('right', '0'); + arrow.style.setProperty('top', 'anchor(center)'); + arrow.style.setProperty('translate', '50% -50%'); + break; + case POSITIONS.after: + arrow.style.setProperty('left', '0'); + arrow.style.setProperty('top', 'anchor(center)'); + arrow.style.setProperty('translate', '-50% -50%'); + break; + } +} + +function observePlacement( + container: HTMLElement, + target: HTMLElement, + targetDocument: Document | undefined, + disabled: boolean, +): () => void { + if (disabled) { + return () => undefined; + } + + const win = targetDocument?.defaultView; + if (!win) { + return () => undefined; + } + + const update = () => { + const surfaceRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const detectedPosition = detectPosition(surfaceRect, targetRect); + if (!detectedPosition) { + return; + } + const detectedAlign = detectAlign(detectedPosition, surfaceRect, targetRect); + const next = getPlacementString(detectedPosition, detectedAlign); + if (container.getAttribute('data-placement') !== next) { + container.setAttribute('data-placement', next); + } + }; + + update(); + + const ResizeObserverCtor = win.ResizeObserver; + const observer = ResizeObserverCtor ? new ResizeObserverCtor(update) : null; + observer?.observe(container); + observer?.observe(target); + win.addEventListener('scroll', update, true); + win.addEventListener('resize', update); + + return () => { + observer?.disconnect(); + win.removeEventListener('scroll', update, true); + win.removeEventListener('resize', update); + }; +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/applyFloatingUIPositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/applyFloatingUIPositioning.ts new file mode 100644 index 00000000000000..c33c49284f7f27 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/applyFloatingUIPositioning.ts @@ -0,0 +1,223 @@ +import type { Middleware, Placement, Strategy } from '@floating-ui/dom'; +import type { PositioningProps } from '@fluentui/react-positioning'; +import { ALIGNMENTS, POSITIONS } from './constants'; +import { resolveOffset } from './utils'; +import { normalizeAlign } from './utils/placement'; + +// alert('Floating UI'); + +type FloatingUIDom = typeof import('@floating-ui/dom'); +let floatingModule: FloatingUIDom | null = null; +let floatingPromise: Promise | null = null; + +function loadFloatingUI(): Promise { + if (floatingModule) { + return Promise.resolve(floatingModule); + } + if (!floatingPromise) { + floatingPromise = import( + /* webpackChunkName: "floating-ui-dom" */ + '@floating-ui/dom' + ).then(m => { + floatingModule = m; + return m; + }); + } + return floatingPromise; +} + +/** + * Eagerly load the floating-ui chunk. Useful for warming the chunk before a + * popover/tooltip first opens (e.g. on app boot or hover-intent). + */ +export function preloadFloatingUI(): Promise { + return loadFloatingUI(); +} + +/** + * For tests only. + * + * @internal + */ +export function resetFloatingUIForTests(): void { + floatingModule = null; + floatingPromise = null; +} + +const PHYSICAL_BLOCK: Record = { + above: 'top', + below: 'bottom', + before: 'left', + after: 'right', +}; + +const PLACEMENT_ALIGN_SUFFIX: Record = { + start: '-start', + end: '-end', + center: '', +}; + +function toPlacement(props: PositioningProps): Placement { + const position = props.position ?? POSITIONS.above; + const align = normalizeAlign(props.align ?? ALIGNMENTS.center); + const main = PHYSICAL_BLOCK[position]; + return `${main}${PLACEMENT_ALIGN_SUFFIX[align]}` as Placement; +} + +function shorthandToPlacement(value: string): Placement { + const [pos, alignRaw] = value.split('-'); + const main = PHYSICAL_BLOCK[pos]; + if (!alignRaw) { + return main as Placement; + } + const align = normalizeAlign(alignRaw); + return `${main}${PLACEMENT_ALIGN_SUFFIX[align]}` as Placement; +} + +export interface ApplyFloatingUIPositioningArgs { + target: HTMLElement; + container: HTMLElement; + arrow: HTMLElement | null; + options: PositioningProps; +} + +/** + * Applies floating-ui-based positioning to the container, anchoring it to the + * target. The floating-ui module is loaded lazily — until it resolves, no + * positioning is applied. Returns a cleanup that detaches all listeners and + * cancels any in-flight load. + */ +export function applyFloatingUIPositioning({ + target, + container, + arrow, + options, +}: ApplyFloatingUIPositioningArgs): () => void { + let cancelled = false; + let detach: (() => void) | undefined; + + loadFloatingUI() + .then(mod => { + if (cancelled) { + return; + } + detach = setupFloatingUI(mod, target, container, arrow, options); + }) + .catch(err => { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.error('[usePositioning]: Failed to load floating-ui fallback', err); + } + }); + + return () => { + cancelled = true; + detach?.(); + }; +} + +function setupFloatingUI( + mod: FloatingUIDom, + target: HTMLElement, + container: HTMLElement, + arrow: HTMLElement | null, + options: PositioningProps, +): () => void { + const placement = toPlacement(options); + const strategy: Strategy = options.strategy ?? 'absolute'; + const { mainAxis, crossAxis } = resolveOffset(options.offset); + const middleware: Middleware[] = []; + + if (mainAxis || crossAxis) { + middleware.push(mod.offset({ mainAxis, crossAxis })); + } + + if (!options.pinned) { + const fallbackPlacements = options.fallbackPositions?.map(shorthandToPlacement); + middleware.push(mod.flip(fallbackPlacements ? { fallbackPlacements } : {})); + } + + middleware.push(mod.shift()); + + if (options.matchTargetSize === 'width') { + middleware.push( + mod.size({ + apply({ rects, elements }) { + Object.assign(elements.floating.style, { + width: `${rects.reference.width}px`, + }); + }, + }), + ); + } + + // Arrow middleware must come last so it sees the final, post-flip/shift + // coordinates — that's what positions the arrow against the trigger. + if (arrow) { + arrow.style.setProperty('position', 'absolute'); + middleware.push(mod.arrow({ element: arrow, padding: options.arrowPadding })); + } + + // Reset transient styles each setup so a previous fallback's leftovers don't leak. + Object.assign(container.style, { position: strategy, inset: 'auto', margin: '0' }); + + let disposed = false; + + const update = () => { + if (disposed) { + return; + } + mod + .computePosition(target, container, { placement, strategy, middleware }) + .then(({ x, y, placement: computed, middlewareData }) => { + if (disposed) { + return; + } + Object.assign(container.style, { + left: `${x}px`, + top: `${y}px`, + }); + container.setAttribute('data-placement', computed); + + if (arrow && middlewareData.arrow) { + const { x: arrowX, y: arrowY } = middlewareData.arrow; + Object.assign(arrow.style, { + left: arrowX !== null && arrowX !== undefined ? `${arrowX}px` : '', + top: arrowY !== null && arrowY !== undefined ? `${arrowY}px` : '', + }); + } + + // Reveal the container — `usePositioning` hid it before scheduling + // this helper, and computePosition is async, so the surface stays + // invisible until the first set of coordinates lands. + container.style.removeProperty('visibility'); + }) + .catch(err => { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.error('[usePositioning]: computePosition failed', err); + } + }); + }; + + // autoUpdate handles scroll, resize, and ResizeObserver under one disposer. + const stopAutoUpdate = mod.autoUpdate(target, container, update); + update(); + + return () => { + disposed = true; + stopAutoUpdate(); + + container.style.removeProperty('left'); + container.style.removeProperty('top'); + container.style.removeProperty('width'); + container.style.removeProperty('visibility'); + container.removeAttribute('data-placement'); + + if (arrow) { + arrow.style.removeProperty('position'); + arrow.style.removeProperty('left'); + arrow.style.removeProperty('top'); + } + }; +} 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 299ed046a07e76..1edcde480fb647 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,4 +1,5 @@ -export { usePositioning } from './usePositioning'; +export { usePositioning, preloadPositioning, resetPositioningForTests } from './usePositioning'; +export { resetLazyApplyForTests } from './lazyApply'; export { getPlacementString, resolvePositioningShorthand } from './utils'; export { POSITIONS, ALIGNMENTS } from './constants'; export type { PositioningReturn } from './types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/lazyApply.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/lazyApply.ts new file mode 100644 index 00000000000000..21e4f8aa689b4c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/lazyApply.ts @@ -0,0 +1,128 @@ +import type { ApplyAnchorPositioningArgs } from './applyAnchorPositioning'; +import type { ApplyFloatingUIPositioningArgs } from './applyFloatingUIPositioning'; + +type ApplyAnchorFn = (args: ApplyAnchorPositioningArgs) => () => void; +type ApplyFloatingFn = (args: ApplyFloatingUIPositioningArgs) => () => void; + +let cachedAnchor: ApplyAnchorFn | null = null; +let anchorPromise: Promise | null = null; + +let cachedFloating: ApplyFloatingFn | null = null; +let floatingPromise: Promise | null = null; + +function loadAnchor(): Promise { + if (cachedAnchor) { + return Promise.resolve(cachedAnchor); + } + if (!anchorPromise) { + anchorPromise = import( + /* webpackChunkName: "fluentui-anchor-positioning" */ + './applyAnchorPositioning' + ).then(m => { + cachedAnchor = m.applyAnchorPositioning; + return cachedAnchor; + }); + } + return anchorPromise; +} + +function loadFloating(): Promise { + if (cachedFloating) { + return Promise.resolve(cachedFloating); + } + if (!floatingPromise) { + floatingPromise = import( + /* webpackChunkName: "fluentui-floating-ui-positioning" */ + './applyFloatingUIPositioning' + ).then(m => { + cachedFloating = m.applyFloatingUIPositioning; + return cachedFloating; + }); + } + return floatingPromise; +} + +/** + * Eagerly fetch the CSS Anchor Positioning helper chunk. + */ +export function preloadAnchorImpl(): Promise { + return loadAnchor(); +} + +/** + * Eagerly fetch the floating-ui-based fallback helper chunk (and, transitively, + * `@floating-ui/dom`). + */ +export function preloadFloatingImpl(): Promise { + return loadFloating(); +} + +/** + * For tests only. + * + * @internal + */ +export function resetLazyApplyForTests(): void { + cachedAnchor = null; + anchorPromise = null; + cachedFloating = null; + floatingPromise = null; +} + +export type ApplyMode = 'anchor' | 'floating'; + +/** + * Schedules the appropriate `apply…` helper, lazily loading its chunk on first + * use. If the chunk is already cached it runs synchronously; otherwise it + * defers until the import resolves. Returns a disposer that cancels any + * pending load and tears down the helper if it had a chance to run. + */ +export function scheduleApply( + mode: ApplyMode, + args: ApplyAnchorPositioningArgs | ApplyFloatingUIPositioningArgs, +): () => void { + if (mode === 'anchor') { + if (cachedAnchor) { + return cachedAnchor(args as ApplyAnchorPositioningArgs); + } + let cancelled = false; + let detach: (() => void) | undefined; + loadAnchor() + .then(fn => { + if (cancelled) { + return; + } + detach = fn(args as ApplyAnchorPositioningArgs); + }) + .catch(err => logLoadFailure('anchor', err)); + return () => { + cancelled = true; + detach?.(); + }; + } + + if (cachedFloating) { + return cachedFloating(args as ApplyFloatingUIPositioningArgs); + } + let cancelled = false; + let detach: (() => void) | undefined; + loadFloating() + .then(fn => { + if (cancelled) { + return; + } + detach = fn(args as ApplyFloatingUIPositioningArgs); + }) + .catch(err => logLoadFailure('floating', err)); + return () => { + cancelled = true; + detach?.(); + }; +} + +function logLoadFailure(mode: ApplyMode, err: unknown): void { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.error(`[usePositioning]: Failed to load ${mode} chunk`, err); + } +} 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 89e3c0f391f900..e74650c364164c 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 @@ -5,4 +5,5 @@ export type LogicalAlignment = 'start' | 'center' | 'end'; export type PositioningReturn = { targetRef: React.RefCallback; containerRef: React.RefCallback; + arrowRef: React.RefCallback; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementObserver.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementObserver.ts deleted file mode 100644 index d08822d454d537..00000000000000 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementObserver.ts +++ /dev/null @@ -1,111 +0,0 @@ -'use client'; - -import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; -import type { Position } from '@fluentui/react-positioning'; -import type { LogicalAlignment } from './types'; -import { ALIGNMENTS, POSITIONS } from './constants'; -import { getPlacementString } from './utils/placement'; - -const PLACEMENT_TOLERANCE = 2; - -const closeTo = (a: number, b: number): boolean => Math.abs(a - b) <= PLACEMENT_TOLERANCE; - -function detectPosition(surfaceRect: DOMRect, targetRect: DOMRect): Position | null { - if (surfaceRect.bottom <= targetRect.top + PLACEMENT_TOLERANCE) { - return POSITIONS.above; - } - - if (surfaceRect.top >= targetRect.bottom - PLACEMENT_TOLERANCE) { - return POSITIONS.below; - } - - if (surfaceRect.right <= targetRect.left + PLACEMENT_TOLERANCE) { - return POSITIONS.before; - } - - if (surfaceRect.left >= targetRect.right - PLACEMENT_TOLERANCE) { - return POSITIONS.after; - } - - return null; -} - -function detectAlign(position: Position, surfaceRect: DOMRect, targetRect: DOMRect): LogicalAlignment { - const isBlockMain = position === POSITIONS.above || position === POSITIONS.below; - - const startAligned = isBlockMain - ? closeTo(surfaceRect.left, targetRect.left) - : closeTo(surfaceRect.top, targetRect.top); - - if (startAligned) { - return ALIGNMENTS.start; - } - - const endAligned = isBlockMain - ? closeTo(surfaceRect.right, targetRect.right) - : closeTo(surfaceRect.bottom, targetRect.bottom); - - if (endAligned) { - return ALIGNMENTS.end; - } - - return ALIGNMENTS.center; -} - -/** - * Pure-observation hook: reads the rendered rects of the surface and anchor - * and mirrors the resolved placement into the surface's `data-placement` - * attribute. This keeps the attribute in sync with the browser's decision - * after native flip fires (scroll, resize, ResizeObserver tick). - * - */ -export function usePlacementObserver( - containerEl: HTMLElement | null, - targetEl: HTMLElement | null, - targetDocument: Document | undefined, - disabled = false, -): void { - useIsomorphicLayoutEffect(() => { - if (disabled || !containerEl || !targetEl) { - return; - } - - const win = targetDocument?.defaultView; - - if (!win) { - return; - } - - const update = () => { - const surfaceRect = containerEl.getBoundingClientRect(); - const targetRect = targetEl.getBoundingClientRect(); - const position = detectPosition(surfaceRect, targetRect); - - if (!position) { - return; - } - - const align = detectAlign(position, surfaceRect, targetRect); - const next = getPlacementString(position, align); - - if (containerEl.getAttribute('data-placement') !== next) { - containerEl.setAttribute('data-placement', next); - } - }; - - update(); - - const ResizeObserverCtor = win.ResizeObserver; - const observer = ResizeObserverCtor ? new ResizeObserverCtor(update) : null; - observer?.observe(containerEl); - observer?.observe(targetEl); - win.addEventListener('scroll', update, true); - win.addEventListener('resize', update); - - return () => { - observer?.disconnect(); - win.removeEventListener('scroll', update, true); - win.removeEventListener('resize', update); - }; - }, [containerEl, targetEl, targetDocument, disabled]); -} 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 da160557099bca..fddb5223656d37 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,187 +1,508 @@ import * as React from 'react'; -import { act, render } from '@testing-library/react'; -import { usePositioning } from './usePositioning'; -import { getPlacementString } from './utils/placement'; +import { act, render, waitFor } from '@testing-library/react'; import type { PositioningProps } from '@fluentui/react-positioning'; +import { preloadPositioning, resetPositioningForTests, resetLazyApplyForTests, usePositioning } from './index'; import type { PositioningReturn } from './types'; +type CapturedResult = { current: PositioningReturn | null }; + function mountHook(options: PositioningProps = {}) { - const resultRef = React.createRef<{ current: PositioningReturn }>(); + const result: CapturedResult = { current: null }; const Capture = () => { - const result = usePositioning(options); - (resultRef as unknown as { current: PositioningReturn }).current = result; + result.current = usePositioning(options); return null; }; - render(); - return resultRef as unknown as { current: PositioningReturn }; + const utils = render(); + return { result, ...utils }; +} + +function attachRefs(result: CapturedResult, { withArrow = false }: { withArrow?: boolean } = {}) { + const target = document.createElement('div'); + const container = document.createElement('div'); + const arrow = withArrow ? document.createElement('div') : null; + + document.body.appendChild(target); + document.body.appendChild(container); + if (arrow) { + container.appendChild(arrow); + } + + act(() => { + if (arrow) { + result.current?.arrowRef(arrow); + } + result.current?.targetRef(target); + result.current?.containerRef(container); + }); + + return { + target, + container, + arrow, + cleanup: () => { + document.body.removeChild(target); + document.body.removeChild(container); + }, + }; } describe('usePositioning', () => { - it('returns targetRef and containerRef callbacks', () => { - const result = mountHook(); + let originalSupports: typeof CSS.supports | undefined; + let supportsMock: jest.Mock | undefined; + + function mockCssSupports(impl: (property: string, value?: string) => boolean) { + if (typeof CSS === 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).CSS = {}; + } + originalSupports = CSS.supports; + supportsMock = jest.fn(impl); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (CSS as any).supports = supportsMock; + } + + beforeEach(() => { + resetPositioningForTests(); + resetLazyApplyForTests(); + }); - expect(typeof result.current.targetRef).toBe('function'); - expect(typeof result.current.containerRef).toBe('function'); + afterEach(() => { + if (supportsMock) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (CSS as any).supports = originalSupports; + supportsMock = undefined; + } }); - it('targetRef writes anchor-name onto the trigger element', () => { - const result = mountHook(); - const node = document.createElement('div'); + it('returns stable callback refs across renders', () => { + mockCssSupports(() => true); - act(() => { - result.current.targetRef(node); - }); + const refs: PositioningReturn[] = []; + const Capture = ({ position }: { position: 'above' | 'below' }) => { + refs.push(usePositioning({ position })); + return null; + }; + + const { rerender } = render(); + rerender(); - expect(node.style.getPropertyValue('anchor-name')).toMatch(/^--popover-anchor-/); + expect(refs.length).toBeGreaterThanOrEqual(2); + expect(refs[0].targetRef).toBe(refs[1].targetRef); + expect(refs[0].containerRef).toBe(refs[1].containerRef); }); - it('containerRef writes position-anchor and position-area matching the props', () => { - const result = mountHook({ position: 'below', align: 'start' }); - const node = document.createElement('div'); + it('does not call the floating-ui chunk loader when CSS Anchor is supported', async () => { + mockCssSupports((property: string) => property === 'anchor-name'); - act(() => { - result.current.containerRef(node); + const { result } = mountHook({ position: 'below' }); + const { target, container, cleanup } = attachRefs(result); + + await waitFor(() => { + expect(target.style.getPropertyValue('anchor-name')).not.toBe(''); }); - expect(node.style.getPropertyValue('position-anchor')).toMatch(/^--popover-anchor-/); - expect(node).toHaveStyle({ positionArea: 'block-end span-inline-end' }); + // Hallmark of the anchor branch: the container received `position-anchor` + // pointing at the target's `anchor-name` (a CSS-only mechanism). The + // floating-ui branch instead writes `left`/`top` numeric coordinates. + expect(container.style.getPropertyValue('position-anchor')).not.toBe(''); + expect(container.style.getPropertyValue('left')).toBe(''); + expect(container.style.getPropertyValue('top')).toBe(''); + + cleanup(); }); - it('containerRef writes position: absolute by default and clears the UA inset/margin defaults', () => { - const result = mountHook(); - const node = document.createElement('div'); + it('does not write CSS-Anchor properties when anchor is not supported', async () => { + mockCssSupports(() => false); + + const { result } = mountHook({ position: 'below' }); + const { target, container, cleanup } = attachRefs(result); - act(() => { - result.current.containerRef(node); + await waitFor(() => { + expect(container.getAttribute('data-placement')).not.toBeNull(); }); - expect(node).toHaveStyle({ position: 'absolute', inset: 'auto', margin: '0px' }); + // Hallmark of the floating-ui branch: numeric coordinates, no + // `position-anchor` reference. + expect(target.style.getPropertyValue('anchor-name')).toBe(''); + expect(container.style.getPropertyValue('position-anchor')).toBe(''); + expect(container.style.getPropertyValue('left')).not.toBe(''); + expect(container.style.getPropertyValue('top')).not.toBe(''); + + cleanup(); }); - it('containerRef honors strategy: "fixed"', () => { - const result = mountHook({ strategy: 'fixed' }); - const node = document.createElement('div'); + describe('CSS Anchor Positioning branch', () => { + beforeEach(() => { + mockCssSupports((property: string) => property === 'anchor-name'); + }); - act(() => { - result.current.containerRef(node); + it.each([ + { position: 'above', align: 'start', expected: 'block-start span-inline-end' }, + { position: 'above', align: 'end', expected: 'block-start span-inline-start' }, + { position: 'below', align: 'start', expected: 'block-end span-inline-end' }, + { position: 'below', align: 'end', expected: 'block-end span-inline-start' }, + { position: 'before', align: 'start', expected: 'inline-start span-block-end' }, + { position: 'after', align: 'end', expected: 'inline-end span-block-start' }, + ] as const)( + 'maps (position=$position, align=$align) to position-area=$expected', + async ({ position, align, expected }) => { + const { result } = mountHook({ position, align }); + const { target, container, cleanup } = attachRefs(result); + + await waitFor(() => { + expect(target.style.getPropertyValue('anchor-name')).not.toBe(''); + }); + + expect(container).toHaveStyle({ positionArea: expected }); + + cleanup(); + }, + ); + + it('writes place-self: anchor-center for center alignment (crbug 438334710 workaround)', async () => { + const { result } = mountHook({ position: 'above', align: 'center' }); + const { target, container, cleanup } = attachRefs(result); + + await waitFor(() => { + expect(target.style.getPropertyValue('anchor-name')).not.toBe(''); + }); + + expect(container).toHaveStyle({ placeSelf: 'anchor-center' }); + cleanup(); }); - expect(node).toHaveStyle({ position: 'fixed' }); - }); + it('does not write place-self for non-center alignments', async () => { + const { result } = mountHook({ position: 'above', align: 'start' }); + const { target, container, cleanup } = attachRefs(result); - it('containerRef writes data-placement matching (position, align)', () => { - const result = mountHook({ position: 'below', align: 'start' }); - const node = document.createElement('div'); + await waitFor(() => { + expect(target.style.getPropertyValue('anchor-name')).not.toBe(''); + }); - act(() => { - result.current.containerRef(node); + expect(container.style.getPropertyValue('place-self')).toBe(''); + cleanup(); }); - expect(node).toHaveAttribute('data-placement', 'below-start'); - }); + it('honors strategy: "fixed"', async () => { + const { result } = mountHook({ strategy: 'fixed' }); + const { target, container, cleanup } = attachRefs(result); - it('containerRef sets position-try-fallbacks to the three-try flip chain by default', () => { - const result = mountHook(); - const node = document.createElement('div'); + await waitFor(() => { + expect(target.style.getPropertyValue('anchor-name')).not.toBe(''); + }); - act(() => { - result.current.containerRef(node); + expect(container.style.getPropertyValue('position')).toBe('fixed'); + cleanup(); }); - expect(node).toHaveStyle({ positionTryFallbacks: 'flip-block, flip-inline, flip-block flip-inline' }); - }); + it('writes matchTargetSize width via anchor-size()', async () => { + const { result } = mountHook({ matchTargetSize: 'width' }); + const { target, container, cleanup } = attachRefs(result); - it('containerRef uses custom fallbackPositions verbatim when provided', () => { - const result = mountHook({ fallbackPositions: ['below-start', 'after'] }); - const node = document.createElement('div'); + await waitFor(() => { + expect(target.style.getPropertyValue('anchor-name')).not.toBe(''); + }); - act(() => { - result.current.containerRef(node); + // JSDOM rejects `anchor-size(width)` when read via getPropertyValue, so + // assert through jest-dom's parsed-style matcher instead. + expect(container).toHaveStyle({ width: 'anchor-size(width)' }); + cleanup(); }); - expect(node).toHaveStyle({ positionTryFallbacks: 'block-end span-inline-end, inline-end' }); - }); + it('applies offset as logical margins', async () => { + const { result } = mountHook({ + position: 'below', + offset: { mainAxis: 8, crossAxis: 4 }, + }); + const { target, container, cleanup } = attachRefs(result); - it('containerRef removes position-try-fallbacks when pinned', () => { - const result = mountHook({ pinned: true }); - const node = document.createElement('div'); - node.style.setProperty('position-try-fallbacks', 'flip-block'); + await waitFor(() => { + expect(target.style.getPropertyValue('anchor-name')).not.toBe(''); + }); - act(() => { - result.current.containerRef(node); + expect(container).toHaveStyle({ marginBlockStart: '8px', marginInlineStart: '4px' }); + cleanup(); }); - expect(node.style.getPropertyValue('position-try-fallbacks')).toBe(''); - }); + it('uses the default flip chain when no fallbackPositions are given', async () => { + const { result } = mountHook(); + const { target, container, cleanup } = attachRefs(result); - it('containerRef writes cover self-alignment when coverTarget is true', () => { - const result = mountHook({ coverTarget: true, position: 'above', align: 'start' }); - const node = document.createElement('div'); + await waitFor(() => { + expect(target.style.getPropertyValue('anchor-name')).not.toBe(''); + }); - act(() => { - result.current.containerRef(node); + expect(container).toHaveStyle({ + positionTryFallbacks: 'flip-block, flip-inline, flip-block flip-inline', + }); + cleanup(); }); - expect(node).toHaveStyle({ positionArea: 'center', alignSelf: 'end', justifySelf: 'start' }); - }); + it('uses custom fallbackPositions verbatim when provided', async () => { + const { result } = mountHook({ fallbackPositions: ['below-start', 'after'] }); + const { target, container, cleanup } = attachRefs(result); - it('containerRef writes place-self: anchor-center for center alignment (crbug 438334710 workaround)', () => { - const result = mountHook({ position: 'above', align: 'center' }); - const node = document.createElement('div'); + await waitFor(() => { + expect(target.style.getPropertyValue('anchor-name')).not.toBe(''); + }); - act(() => { - result.current.containerRef(node); + expect(container).toHaveStyle({ + positionTryFallbacks: 'block-end span-inline-end, inline-end', + }); + cleanup(); }); - expect(node).toHaveStyle({ placeSelf: 'anchor-center' }); - }); + it('removes position-try-fallbacks when pinned', async () => { + const { result } = mountHook({ pinned: true }); + const { target, container, cleanup } = attachRefs(result); - it('containerRef does not write place-self for non-center alignments', () => { - const result = mountHook({ position: 'above', align: 'start' }); - const node = document.createElement('div'); + await waitFor(() => { + expect(target.style.getPropertyValue('anchor-name')).not.toBe(''); + }); - act(() => { - result.current.containerRef(node); + expect(container.style.getPropertyValue('position-try-fallbacks')).toBe(''); + cleanup(); }); - expect(node.style.getPropertyValue('place-self')).toBe(''); - expect(node.style.getPropertyValue('justify-self')).toBe(''); - expect(node.style.getPropertyValue('align-self')).toBe(''); - }); + it('writes cover-self alignment when coverTarget is true', async () => { + const { result } = mountHook({ coverTarget: true, position: 'above', align: 'start' }); + const { target, container, cleanup } = attachRefs(result); - it('containerRef writes matchTargetSize width via anchor-size()', () => { - const result = mountHook({ matchTargetSize: 'width' }); - const node = document.createElement('div'); + await waitFor(() => { + expect(target.style.getPropertyValue('anchor-name')).not.toBe(''); + }); - act(() => { - result.current.containerRef(node); + expect(container).toHaveStyle({ + positionArea: 'center', + alignSelf: 'end', + justifySelf: 'start', + }); + cleanup(); }); - expect(node).toHaveStyle({ width: 'anchor-size(width)' }); - }); + it('cleans up styles when the component unmounts', async () => { + // Warm the chunk so the apply is synchronous and unmount can clean up immediately. + await preloadPositioning(); - it('containerRef applies offset as logical margins', () => { - const result = mountHook({ position: 'below', offset: { mainAxis: 8, crossAxis: 4 } }); - const node = document.createElement('div'); + const { result, unmount } = mountHook({ position: 'below' }); + const { target, container, cleanup } = attachRefs(result); - act(() => { - result.current.containerRef(node); + expect(target.style.getPropertyValue('anchor-name')).not.toBe(''); + expect(container.style.getPropertyValue('position-anchor')).not.toBe(''); + + unmount(); + + expect(target.style.getPropertyValue('anchor-name')).toBe(''); + expect(container.style.getPropertyValue('position-anchor')).toBe(''); + expect(container.hasAttribute('data-placement')).toBe(false); + + cleanup(); }); - expect(node).toHaveStyle({ marginBlockStart: '8px', marginInlineStart: '4px' }); - }); -}); + it.each([ + { position: 'above', expectedEdge: 'bottom' }, + { position: 'below', expectedEdge: 'top' }, + { position: 'before', expectedEdge: 'right' }, + { position: 'after', expectedEdge: 'left' }, + ] as const)( + 'anchors the arrow to the trigger and pins it to the popover edge nearest the trigger ($expectedEdge edge for position=$position)', + async ({ position, expectedEdge }) => { + const { result } = mountHook({ position }); + const { target, arrow, cleanup } = attachRefs(result, { withArrow: true }); + + await waitFor(() => { + expect(target.style.getPropertyValue('anchor-name')).not.toBe(''); + }); + + expect(arrow!.style.getPropertyValue('position')).toBe('absolute'); + expect(arrow!.style.getPropertyValue('position-anchor')).toMatch(/^--popover-anchor-/); + expect(arrow!.style.getPropertyValue(expectedEdge)).toBe('0px'); + expect(arrow!.style.getPropertyValue('translate')).not.toBe(''); + cleanup(); + }, + ); + + it('cleans up arrow styles on unmount', async () => { + await preloadPositioning(); + const { result, unmount } = mountHook({ position: 'below' }); + const { arrow, cleanup } = attachRefs(result, { withArrow: true }); + + expect(arrow!.style.getPropertyValue('position')).toBe('absolute'); + + unmount(); + + expect(arrow!.style.getPropertyValue('position')).toBe(''); + expect(arrow!.style.getPropertyValue('position-anchor')).toBe(''); + expect(arrow!.style.getPropertyValue('translate')).toBe(''); + cleanup(); + }); + + it('hides the container while the chunk is loading and reveals it once positioned', async () => { + const { result } = mountHook({ position: 'below' }); + const { target, container, cleanup } = attachRefs(result); + + // Synchronously after refs are attached, before the chunk resolves, the + // container is hidden so it cannot paint at its default location. + expect(container.style.getPropertyValue('visibility')).toBe('hidden'); + + await waitFor(() => { + expect(target.style.getPropertyValue('anchor-name')).not.toBe(''); + }); + + // Once the apply commits, visibility is removed so the surface paints. + expect(container.style.getPropertyValue('visibility')).toBe(''); + + cleanup(); + }); -describe('getPlacementString', () => { - it('returns the bare position for center alignment', () => { - expect(getPlacementString('above', 'center')).toBe('above'); - expect(getPlacementString('below', 'center')).toBe('below'); + it('preloadPositioning resolves without rejecting', async () => { + await preloadPositioning(); + }); }); - it('returns position-align for non-center alignments', () => { - expect(getPlacementString('above', 'start')).toBe('above-start'); - expect(getPlacementString('below', 'end')).toBe('below-end'); - expect(getPlacementString('before', 'start')).toBe('before-top'); - expect(getPlacementString('after', 'end')).toBe('after-bottom'); + describe('floating-ui fallback branch', () => { + beforeEach(() => { + mockCssSupports(() => false); + }); + + it('does not throw or suspend on render', () => { + const { result } = mountHook({ position: 'below' }); + expect(result.current).not.toBeNull(); + expect(typeof result.current?.targetRef).toBe('function'); + expect(typeof result.current?.containerRef).toBe('function'); + }); + + it.each([ + { position: 'above', expected: 'top' }, + { position: 'below', expected: 'bottom' }, + { position: 'before', expected: 'left' }, + { position: 'after', expected: 'right' }, + ] as const)( + 'maps position=$position to floating-ui placement starting with "$expected"', + async ({ position, expected }) => { + const { result } = mountHook({ position }); + const { container, cleanup } = attachRefs(result); + + await waitFor(() => { + expect(container.getAttribute('data-placement')).not.toBeNull(); + }); + + expect(container.getAttribute('data-placement')).toMatch(new RegExp(`^${expected}`)); + cleanup(); + }, + ); + + it('applies absolute strategy by default and writes numeric coordinates', async () => { + const { result } = mountHook({ position: 'below' }); + const { container, cleanup } = attachRefs(result); + + await waitFor(() => { + expect(container.getAttribute('data-placement')).not.toBeNull(); + }); + + expect(container.style.getPropertyValue('position')).toBe('absolute'); + expect(container.style.getPropertyValue('left')).toMatch(/^-?\d+(\.\d+)?px$/); + expect(container.style.getPropertyValue('top')).toMatch(/^-?\d+(\.\d+)?px$/); + cleanup(); + }); + + it('honors strategy: "fixed"', async () => { + const { result } = mountHook({ strategy: 'fixed' }); + const { container, cleanup } = attachRefs(result); + + await waitFor(() => { + expect(container.getAttribute('data-placement')).not.toBeNull(); + }); + + expect(container.style.getPropertyValue('position')).toBe('fixed'); + cleanup(); + }); + + it('writes matchTargetSize width via the size middleware', async () => { + const { result } = mountHook({ matchTargetSize: 'width' }); + const { container, cleanup } = attachRefs(result); + + await waitFor(() => { + expect(container.getAttribute('data-placement')).not.toBeNull(); + }); + + // JSDOM rects are 0×0, so the matchTargetSize middleware writes "0px". + // The point is that *some* width was written by the size middleware. + expect(container.style.getPropertyValue('width')).toMatch(/^\d+(\.\d+)?px$/); + cleanup(); + }); + + it('cleans up coordinates and data-placement on unmount', async () => { + // Warm the chunk so the apply is synchronous and the writes happen before unmount. + await preloadPositioning(); + + const { result, unmount } = mountHook({ position: 'below' }); + const { container, cleanup } = attachRefs(result); + + await waitFor(() => { + expect(container.style.getPropertyValue('left')).not.toBe(''); + }); + + unmount(); + + expect(container.style.getPropertyValue('left')).toBe(''); + expect(container.style.getPropertyValue('top')).toBe(''); + expect(container.hasAttribute('data-placement')).toBe(false); + cleanup(); + }); + + it('drives the arrow via floating-ui middleware (position absolute + numeric left/top)', async () => { + const { result } = mountHook({ position: 'below' }); + const { container, arrow, cleanup } = attachRefs(result, { withArrow: true }); + + await waitFor(() => { + expect(container.getAttribute('data-placement')).not.toBeNull(); + }); + + // The arrow middleware sets `position: absolute` synchronously during + // setup and writes numeric `left`/`top` coordinates after the first + // computePosition resolves. + expect(arrow!.style.getPropertyValue('position')).toBe('absolute'); + // JSDOM produces 0×0 rects, so the middleware writes "0px"; the point + // is that *some* numeric coordinate landed. + expect(arrow!.style.getPropertyValue('left')).toMatch(/^-?\d+(\.\d+)?px$/); + cleanup(); + }); + + it('cleans up arrow styles on unmount', async () => { + await preloadPositioning(); + const { result, unmount } = mountHook({ position: 'below' }); + const { arrow, cleanup } = attachRefs(result, { withArrow: true }); + + await waitFor(() => { + expect(arrow!.style.getPropertyValue('left')).not.toBe(''); + }); + + unmount(); + + expect(arrow!.style.getPropertyValue('position')).toBe(''); + expect(arrow!.style.getPropertyValue('left')).toBe(''); + expect(arrow!.style.getPropertyValue('top')).toBe(''); + cleanup(); + }); + + it('hides the container while computePosition is in flight and reveals it once coordinates land', async () => { + const { result } = mountHook({ position: 'below' }); + const { container, cleanup } = attachRefs(result); + + // computePosition is async — visibility stays hidden until it resolves. + expect(container.style.getPropertyValue('visibility')).toBe('hidden'); + + await waitFor(() => { + expect(container.style.getPropertyValue('left')).not.toBe(''); + }); + + expect(container.style.getPropertyValue('visibility')).toBe(''); + cleanup(); + }); + + it('preloadPositioning resolves without rejecting', async () => { + await preloadPositioning(); + }); }); }); 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 793d7af6079534..7811eca508ee78 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,155 +1,208 @@ 'use client'; import * as React from 'react'; -import { useId, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { canUseDOM, useId, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import type { PositioningImperativeRef, PositioningProps, PositioningVirtualElement, } from '@fluentui/react-positioning'; + import type { PositioningReturn } 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'; +import { resolveElementRef } from './utils'; +import { preloadAnchorImpl, preloadFloatingImpl, scheduleApply } from './lazyApply'; + +let supportsAnchorCached: boolean | undefined; +// Set to `true` to force the floating-ui fallback path even in browsers that +// support CSS Anchor Positioning. Useful for local debugging of the fallback +// chunk; native detection runs when this is `false`. +const FORCE_FALLBACK_FOR_DEBUG = false; + +/** + * Detects support for CSS Anchor Positioning. The result is stable per page + * session, so the first call is memoised at module level. + */ +function supportsAnchorPositioning(): boolean { + // Honour the debug toggle only outside the test environment, so the test + // suite can still exercise both branches via `mockCssSupports` regardless + // of how this flag is set for browser debugging. + if (FORCE_FALLBACK_FOR_DEBUG && process.env.NODE_ENV !== 'test') { + return false; + } + if (supportsAnchorCached !== undefined) { + return supportsAnchorCached; + } + if (typeof CSS === 'undefined' || typeof CSS.supports !== 'function') { + supportsAnchorCached = false; + } else { + supportsAnchorCached = CSS.supports('anchor-name', '--x'); + } + return supportsAnchorCached; +} -export type TargetElement = HTMLElement | PositioningVirtualElement; +/** + * Eagerly load the positioning chunk that this browser will end up using — + * the CSS Anchor Positioning helper when supported, otherwise the floating-ui + * fallback. Useful for warming the bundle before the first popover/tooltip + * opens (e.g. on app boot, or on hover-intent over a trigger). Safe to call + * during SSR — resolves immediately without fetching anything. + */ +export function preloadPositioning(): Promise { + if (!canUseDOM()) { + return Promise.resolve(); + } + return supportsAnchorPositioning() ? preloadAnchorImpl() : preloadFloatingImpl(); +} -const DEFAULT_FLIP = ['flip-block', 'flip-inline', 'flip-block flip-inline']; +/** + * Resets module-level caches. For tests only. + * + * @internal + */ +export function resetPositioningForTests(): void { + supportsAnchorCached = undefined; +} +/** + * Anchors a surface to a target element. Prefers CSS Anchor Positioning when + * the browser supports it, and falls back to a dynamically-imported + * floating-ui implementation otherwise. Each strategy ships in its own + * webpack chunk and is fetched on first use, so a browser only ever loads + * the path it needs. + * + * The hook is fully synchronous and always calls the same set of React + * hooks — all positioning work happens imperatively inside a single layout + * effect. The first time a strategy is used, its chunk is fetched via + * dynamic `import()`; until it resolves the surface is unstyled but the + * rest of the tree renders normally. Use `preloadPositioning()` to warm + * the chunk proactively (e.g. on app boot or hover-intent over a trigger). + */ export function usePositioning(options: PositioningProps): PositioningReturn { - const { - pinned, - target: customTarget = null, - align: alignInput = ALIGNMENTS.center, - position = POSITIONS.above, - fallbackPositions = [], - offset, - coverTarget = false, - strategy = 'absolute', - matchTargetSize, - positioningRef, - } = options; - - const align = normalizeAlign(alignInput); - - const { mainAxis, crossAxis } = resolveOffset(offset); - const coverAlignment = coverTarget ? getCoverSelfAlignment(position, align) : null; - - const [triggerEl, setTriggerEl] = React.useState(null); - const [containerEl, setContainerEl] = React.useState(null); - const [imperativeTarget, setImperativeTarget] = React.useState(null); - const effectiveTarget = imperativeTarget ?? resolveElementRef(customTarget) ?? triggerEl; - - const anchorName = `--${useId('popover-anchor-')}`; - const positionArea = POSITION_AREA_MAP[position][align]; - const placement = getPlacementString(position, align); + 'use no memo'; const { targetDocument } = useFluent(); - const fallbackAreas = React.useMemo(() => fallbackPositions.map(shorthandToPositionArea), [fallbackPositions]); + const anchorName = `--${useId('popover-anchor-')}`; - React.useImperativeHandle( - positioningRef, - () => ({ - setTarget: (el: TargetElement | null) => { - setImperativeTarget(resolveElementRef(el)); - }, - updatePosition: () => undefined, - }), - [], - ); + const triggerElRef = React.useRef(null); + const containerElRef = React.useRef(null); + const arrowElRef = React.useRef(null); + const imperativeTargetRef = React.useRef(null); - useIsomorphicLayoutEffect(() => { - if (!effectiveTarget) { - return; - } - effectiveTarget.style.setProperty('anchor-name', anchorName); - return () => { - effectiveTarget.style.removeProperty('anchor-name'); - }; - }, [effectiveTarget, anchorName]); - - const targetRef: React.RefCallback = React.useCallback(node => { - setTriggerEl(node); + // Bumped whenever a ref changes — re-runs the positioning effect since refs + // alone don't cause renders. + const [refsVersion, setRefsVersion] = React.useState(0); + const bumpRefsVersion = React.useCallback(() => { + setRefsVersion(v => v + 1); }, []); - const containerRef: React.RefCallback = React.useCallback( + const targetRef = React.useCallback>( node => { - setContainerEl(node); - - if (!node) { - return; + if (triggerElRef.current !== node) { + triggerElRef.current = node; + bumpRefsVersion(); } + }, + [bumpRefsVersion], + ); - 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'); + const containerRef = React.useCallback>( + node => { + if (containerElRef.current !== node) { + containerElRef.current = node; + bumpRefsVersion(); } + }, + [bumpRefsVersion], + ); - 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; + const arrowRef = React.useCallback>( + node => { + if (arrowElRef.current !== node) { + arrowElRef.current = node; + bumpRefsVersion(); } + }, + [bumpRefsVersion], + ); - 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'); - } + React.useImperativeHandle( + options.positioningRef, + () => ({ + setTarget: (el: HTMLElement | PositioningVirtualElement | null) => { + const resolved = resolveElementRef(el); + if (imperativeTargetRef.current !== resolved) { + imperativeTargetRef.current = resolved; + bumpRefsVersion(); + } + }, + updatePosition: () => undefined, + }), + [bumpRefsVersion], + ); - if (pinned) { - node.style.removeProperty('position-try-fallbacks'); - return; - } + const customTarget = options.target ?? null; + const { position, align, fallbackPositions, offset, coverTarget, strategy, matchTargetSize, pinned, arrowPadding } = + options; - if (fallbackAreas.length > 0) { - node.style.setProperty('position-try-fallbacks', fallbackAreas.join(', ')); - } else { - node.style.setProperty('position-try-fallbacks', DEFAULT_FLIP.join(', ')); - } - }, - [ - anchorName, - positionArea, - placement, - fallbackAreas, - pinned, + // Snapshot of the option fields that actually drive positioning. Stable + // identity until one of those fields changes, so the layout effect doesn't + // re-run on unrelated `options` identity changes (e.g. a parent passing a + // new object literal each render). + const positioningOptions = React.useMemo( + () => ({ position, align, - mainAxis, - crossAxis, - coverAlignment, + fallbackPositions, + offset, + coverTarget, strategy, matchTargetSize, - ], + pinned, + arrowPadding, + }), + [position, align, fallbackPositions, offset, coverTarget, strategy, matchTargetSize, pinned, arrowPadding], ); - usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget); + useIsomorphicLayoutEffect(() => { + const container = containerElRef.current; + const target = imperativeTargetRef.current ?? resolveElementRef(customTarget) ?? triggerElRef.current; + + if (!container || !target) { + return; + } + + // Hide the container until positioning is fully loaded and committed. + // The gap can be many frames: the strategy chunk loads via dynamic + // `import()`, and on the floating-ui path `computePosition` is also + // async. Without this, the surface paints at its default location for + // however long the load takes and then snaps into place. The apply + // helpers clear `visibility` themselves the moment they've written the + // committed coordinates. + container.style.setProperty('visibility', 'hidden'); + + const arrow = arrowElRef.current; + const dispose = + canUseDOM() && supportsAnchorPositioning() + ? scheduleApply('anchor', { + target, + container, + arrow, + anchorName, + options: positioningOptions, + targetDocument, + }) + : scheduleApply('floating', { target, container, arrow, options: positioningOptions }); + + return () => { + dispose(); + // Safety net for the case where the apply never committed (chunk still + // loading at unmount, computePosition rejected, etc.) so the surface + // doesn't get stuck invisible if it lingers in the DOM. + container.style.removeProperty('visibility'); + }; + }, [refsVersion, customTarget, anchorName, targetDocument, positioningOptions]); - return { targetRef, containerRef }; + return { targetRef, containerRef, arrowRef }; } 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 3c24fa062d54bf..e4695f9d01a9c8 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 @@ -9,6 +9,7 @@ export type { } from './hooks/usePositioning'; export { usePositioning, + preloadPositioning, POSITIONS, ALIGNMENTS, getPlacementString,