diff --git a/change/@fluentui-react-headless-components-preview-8553b315-6293-4a03-b4b5-392c4b1f09b2.json b/change/@fluentui-react-headless-components-preview-8553b315-6293-4a03-b4b5-392c4b1f09b2.json new file mode 100644 index 0000000000000..c1976cb8b77d2 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-8553b315-6293-4a03-b4b5-392c4b1f09b2.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat(Popover): adopt anchored container queries for arrow placement", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-headless-components-preview-fa7a6438-e4cf-4a58-9c0b-5d4d44798e1e.json b/change/@fluentui-react-headless-components-preview-fa7a6438-e4cf-4a58-9c0b-5d4d44798e1e.json new file mode 100644 index 0000000000000..3a03d46e567b1 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-fa7a6438-e4cf-4a58-9c0b-5d4d44798e1e.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix(positioning): fix positioning types and performance issues", + "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 0fa49ee74b681..07d315e12e20a 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 @@ -7,7 +7,7 @@ import { Alignment } from '@fluentui/react-positioning'; import { Position } from '@fluentui/react-positioning'; import { PositioningImperativeRef } from '@fluentui/react-positioning'; -import { PositioningProps } from '@fluentui/react-positioning'; +import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-positioning'; import { PositioningShorthand } from '@fluentui/react-positioning'; import { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; @@ -23,13 +23,14 @@ export const ALIGNMENTS: { }; // @public -export function getPlacementString(position: Position, align: LogicalAlignment): string; +export function getPlacementString(position: Position, align: Alignment): PositioningShorthandValue; export { Position } export { PositioningImperativeRef } -export { PositioningProps } +// @public (undocumented) +export type PositioningProps = Pick; // @public (undocumented) export type PositioningReturn = { diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx index 290c8700de2c8..ff62f1c780877 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx @@ -5,6 +5,7 @@ import { PopoverTrigger } from './PopoverTrigger/PopoverTrigger'; import { PopoverSurface } from './PopoverSurface/PopoverSurface'; import type { PopoverProps } from './Popover.types'; import type { JSXElement } from '@fluentui/react-utilities'; +import type { PositioningImperativeRef } from '@fluentui/react-positioning'; const mount = (element: JSXElement) => { mountBase(element); @@ -379,3 +380,30 @@ describe('Popover', () => { }); }); }); + +describe('positioning observer', () => { + const surfaceSelector = popoverContentSelector; + + it('imperative updatePosition() is callable while the surface is open and does not throw', () => { + const positioningRef = React.createRef(); + + const Fixture = () => ( +
+ + + + + Surface + +
+ ); + + cy.viewport(1000, 600); + mount(); + cy.get(surfaceSelector).should('be.visible'); + cy.then(() => { + expect(() => positioningRef.current?.updatePosition()).not.to.throw(); + }); + cy.get(surfaceSelector).should('have.attr', 'data-position', 'above'); + }); +}); 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 299ed046a07e7..f2d52d9a89aa0 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,12 +1,12 @@ +export { resolvePositioningShorthand } from '@fluentui/react-positioning'; export { usePositioning } from './usePositioning'; -export { getPlacementString, resolvePositioningShorthand } from './utils'; +export { getPlacementString } from './utils'; export { POSITIONS, ALIGNMENTS } from './constants'; -export type { PositioningReturn } from './types'; +export type { PositioningProps, PositioningReturn } from './types'; export type { Alignment, Position, PositioningImperativeRef, - PositioningProps, PositioningShorthand, PositioningShorthandValue, } from '@fluentui/react-positioning'; 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 89e3c0f391f90..5659e64d61cc1 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,4 +1,5 @@ import type * as React from 'react'; +import type { PositioningProps as CanonicalPositioningProps } from '@fluentui/react-positioning'; export type LogicalAlignment = 'start' | 'center' | 'end'; @@ -6,3 +7,17 @@ export type PositioningReturn = { targetRef: React.RefCallback; containerRef: React.RefCallback; }; + +export type PositioningProps = Pick< + CanonicalPositioningProps, + | 'align' + | 'coverTarget' + | 'fallbackPositions' + | 'matchTargetSize' + | 'offset' + | 'pinned' + | 'position' + | 'positioningRef' + | 'strategy' + | 'target' +>; 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 index d08822d454d53..bd90abc4fc02c 100644 --- 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 @@ -1,70 +1,41 @@ '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; -} +import { useIsomorphicLayoutEffect, useEventCallback } from '@fluentui/react-utilities'; +import { computePosition, debounce, supportsAnchoredContainerQueries } from './utils'; /** - * 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). + * Mirrors the placement that the browser actually resolves for an + * anchored element into its `data-placement` attribute. Useful when CSS + * `position-try-fallbacks` flips the surface after a layout shift, scroll, + * or content reflow — consumers can style the surface (arrows, animations) + * via `[data-placement^="above"]` and friends and stay in sync. * + * On browsers that support anchored container queries + * (`@container anchored(fallback: …)`), this observer is a no-op: consumers + * are expected to react to fallback activations in pure CSS instead. */ export function usePlacementObserver( containerEl: HTMLElement | null, targetEl: HTMLElement | null, targetDocument: Document | undefined, disabled = false, -): void { +): () => void { + const update = useEventCallback(() => { + if (!containerEl || !targetEl) { + return; + } + + const result = computePosition(targetEl, containerEl); + + if (!result) { + return; + } + + if (containerEl.getAttribute('data-placement') !== result.placement) { + containerEl.setAttribute('data-placement', result.placement); + } + }); + useIsomorphicLayoutEffect(() => { if (disabled || !containerEl || !targetEl) { return; @@ -76,36 +47,36 @@ export function usePlacementObserver( return; } - const update = () => { - const surfaceRect = containerEl.getBoundingClientRect(); - const targetRect = targetEl.getBoundingClientRect(); - const position = detectPosition(surfaceRect, targetRect); + if (supportsAnchoredContainerQueries(win)) { + return; + } - if (!position) { - return; - } + const debouncedUpdate = debounce(update); - const align = detectAlign(position, surfaceRect, targetRect); - const next = getPlacementString(position, align); + const ResizeObserverCtor = win.ResizeObserver; + const resizeObserver = ResizeObserverCtor + ? new ResizeObserverCtor(entries => { + const allLaidOut = entries.every(entry => entry.contentRect.width > 0 && entry.contentRect.height > 0); + if (allLaidOut) { + debouncedUpdate(); + } + }) + : null; - if (containerEl.getAttribute('data-placement') !== next) { - containerEl.setAttribute('data-placement', next); - } - }; + resizeObserver?.observe(containerEl); + resizeObserver?.observe(targetEl); - update(); + win.addEventListener('scroll', debouncedUpdate, { capture: true, passive: true }); + win.addEventListener('resize', debouncedUpdate); - 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); + debouncedUpdate(); return () => { - observer?.disconnect(); - win.removeEventListener('scroll', update, true); - win.removeEventListener('resize', update); + resizeObserver?.disconnect(); + win.removeEventListener('scroll', debouncedUpdate, { capture: true }); + win.removeEventListener('resize', debouncedUpdate); }; - }, [containerEl, targetEl, targetDocument, disabled]); + }, [containerEl, targetEl, targetDocument, disabled, update]); + + return update; } diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx index da160557099bc..81ebfcb70b88d 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,8 +2,7 @@ import * as React from 'react'; import { act, render } from '@testing-library/react'; import { usePositioning } from './usePositioning'; import { getPlacementString } from './utils/placement'; -import type { PositioningProps } from '@fluentui/react-positioning'; -import type { PositioningReturn } from './types'; +import type { PositioningProps, PositioningReturn } from './types'; function mountHook(options: PositioningProps = {}) { const resultRef = React.createRef<{ current: PositioningReturn }>(); @@ -69,7 +68,7 @@ describe('usePositioning', () => { expect(node).toHaveStyle({ position: 'fixed' }); }); - it('containerRef writes data-placement matching (position, align)', () => { + it('containerRef writes data-position and data-align matching the requested (position, align)', () => { const result = mountHook({ position: 'below', align: 'start' }); const node = document.createElement('div'); @@ -77,9 +76,50 @@ describe('usePositioning', () => { result.current.containerRef(node); }); + expect(node).toHaveAttribute('data-position', 'below'); + expect(node).toHaveAttribute('data-align', 'start'); + }); + + it('containerRef writes data-placement on browsers without anchored container queries (jsdom default)', () => { + const result = mountHook({ position: 'below', align: 'start' }); + const node = document.createElement('div'); + + act(() => { + result.current.containerRef(node); + }); + + // jsdom's CSS.supports returns false for `container-type: anchored`, so the + // observer-maintained `data-placement` is set with the resolved-on-mount value. expect(node).toHaveAttribute('data-placement', 'below-start'); }); + it('containerRef keeps data-placement and adds container-type when anchored CQ is supported', () => { + type WindowWithCSS = Window & { CSS?: { supports?: (prop: string, value: string) => boolean } }; + const originalCSS = (window as WindowWithCSS).CSS; + (window as WindowWithCSS).CSS = { + supports: (prop: string, value: string) => prop === 'container-type' && value === 'anchored', + }; + + try { + const result = mountHook({ position: 'below', align: 'start' }); + const node = document.createElement('div'); + + act(() => { + result.current.containerRef(node); + }); + + // `data-placement` is always set so consumers can author a single set of + // `[data-placement="…"]` rules; `container-type: anchored` then lets them + // add optional `@container anchored(fallback: …)` overrides on top. + expect(node).toHaveAttribute('data-placement', 'below-start'); + expect(node.style.getPropertyValue('container-type')).toBe('anchored'); + expect(node).toHaveAttribute('data-position', 'below'); + expect(node).toHaveAttribute('data-align', 'start'); + } finally { + (window as WindowWithCSS).CSS = originalCSS; + } + }); + it('containerRef sets position-try-fallbacks to the three-try flip chain by default', () => { const result = mountHook(); const node = document.createElement('div'); @@ -160,7 +200,7 @@ describe('usePositioning', () => { expect(node).toHaveStyle({ width: 'anchor-size(width)' }); }); - it('containerRef applies offset as logical margins', () => { + it('containerRef applies offset as a logical margin on the side facing the anchor', () => { const result = mountHook({ position: 'below', offset: { mainAxis: 8, crossAxis: 4 } }); const node = document.createElement('div'); @@ -168,7 +208,25 @@ describe('usePositioning', () => { result.current.containerRef(node); }); - expect(node).toHaveStyle({ marginBlockStart: '8px', marginInlineStart: '4px' }); + expect(node).toHaveStyle({ + marginBlockStart: '8px', + marginInlineStart: '4px', + }); + }); + + describe('imperative ref', () => { + it('exposes a callable updatePosition()', () => { + const positioningRef = React.createRef<{ + updatePosition: () => void; + setTarget: (el: HTMLElement | null) => void; + }>(); + mountHook({ + positioningRef: positioningRef as unknown as PositioningProps['positioningRef'], + }); + + expect(positioningRef.current).not.toBeNull(); + expect(() => positioningRef.current?.updatePosition()).not.toThrow(); + }); }); }); @@ -185,3 +243,82 @@ describe('getPlacementString', () => { expect(getPlacementString('after', 'end')).toBe('after-bottom'); }); }); + +describe('placement observer feature detection', () => { + type WindowWithCSS = Window & { CSS?: { supports?: (prop: string, value: string) => boolean } }; + + let originalCSS: WindowWithCSS['CSS']; + let originalRO: typeof ResizeObserver | undefined; + let resizeObserverCount: number; + + const ObserverHarness = () => { + const { targetRef, containerRef } = usePositioning({}); + return ( + <> +
+
+ + ); + }; + + class CountingResizeObserver implements ResizeObserver { + constructor() { + resizeObserverCount++; + } + public observe(): void { + /* no-op */ + } + public unobserve(): void { + /* no-op */ + } + public disconnect(): void { + /* no-op */ + } + } + + beforeEach(() => { + originalCSS = (window as WindowWithCSS).CSS; + originalRO = window.ResizeObserver; + resizeObserverCount = 0; + window.ResizeObserver = CountingResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + (window as WindowWithCSS).CSS = originalCSS; + if (originalRO) { + window.ResizeObserver = originalRO; + } else { + delete (window as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; + } + }); + + it('skips attaching listeners and ResizeObserver when CSS supports container-type: anchored', () => { + (window as WindowWithCSS).CSS = { + supports: (prop: string, value: string) => prop === 'container-type' && value === 'anchored', + }; + + const addSpy = jest.spyOn(window, 'addEventListener'); + render(); + + const scrollOrResize = addSpy.mock.calls.filter(call => call[0] === 'scroll' || call[0] === 'resize'); + expect(scrollOrResize).toHaveLength(0); + expect(resizeObserverCount).toBe(0); + + addSpy.mockRestore(); + }); + + it('attaches listeners when CSS does not support container-type: anchored', () => { + (window as WindowWithCSS).CSS = { supports: () => false }; + + const addSpy = jest.spyOn(window, 'addEventListener'); + render(); + + const scrollAdds = addSpy.mock.calls.filter(call => call[0] === 'scroll'); + const resizeAdds = addSpy.mock.calls.filter(call => call[0] === 'resize'); + expect(scrollAdds.length).toBeGreaterThanOrEqual(1); + expect(resizeAdds.length).toBeGreaterThanOrEqual(1); + expect(resizeObserverCount).toBeGreaterThanOrEqual(1); + + addSpy.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 793d7af607953..2d16be4b4edc5 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 @@ -5,18 +5,26 @@ import { useId, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import type { PositioningImperativeRef, - PositioningProps, + PositioningShorthandValue, PositioningVirtualElement, } from '@fluentui/react-positioning'; -import type { PositioningReturn } from './types'; +import type { PositioningProps, 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 { + applyOffset, + getCoverSelfAlignment, + resolveElementRef, + resolveOffset, + shorthandToPositionArea, + supportsAnchoredContainerQueries, +} 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[] = []; export function usePositioning(options: PositioningProps): PositioningReturn { const { @@ -24,7 +32,7 @@ export function usePositioning(options: PositioningProps): PositioningReturn { target: customTarget = null, align: alignInput = ALIGNMENTS.center, position = POSITIONS.above, - fallbackPositions = [], + fallbackPositions = EMPTY_FALLBACK_POSITIONS, offset, coverTarget = false, strategy = 'absolute', @@ -35,7 +43,10 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const align = normalizeAlign(alignInput); const { mainAxis, crossAxis } = resolveOffset(offset); - const coverAlignment = coverTarget ? getCoverSelfAlignment(position, align) : null; + const coverAlignment = React.useMemo( + () => (coverTarget ? getCoverSelfAlignment(position, align) : null), + [coverTarget, position, align], + ); const [triggerEl, setTriggerEl] = React.useState(null); const [containerEl, setContainerEl] = React.useState(null); @@ -50,15 +61,17 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const fallbackAreas = React.useMemo(() => fallbackPositions.map(shorthandToPositionArea), [fallbackPositions]); + const requestPlacementUpdate = usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget); + React.useImperativeHandle( positioningRef, () => ({ setTarget: (el: TargetElement | null) => { setImperativeTarget(resolveElementRef(el)); }, - updatePosition: () => undefined, + updatePosition: requestPlacementUpdate, }), - [], + [requestPlacementUpdate], ); useIsomorphicLayoutEffect(() => { @@ -96,8 +109,19 @@ export function usePositioning(options: PositioningProps): PositioningReturn { } node.style.setProperty('position-anchor', anchorName); + + node.setAttribute('data-position', position); + node.setAttribute('data-align', align); node.setAttribute('data-placement', placement); + const win = node.ownerDocument?.defaultView; + + if (win && supportsAnchoredContainerQueries(win)) { + node.style.setProperty('container-type', 'anchored'); + } else { + node.style.removeProperty('container-type'); + } + if (coverAlignment) { node.style.setProperty('position-area', 'center'); node.style.setProperty('align-self', coverAlignment.alignSelf); @@ -149,7 +173,5 @@ export function usePositioning(options: PositioningProps): PositioningReturn { ], ); - usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget); - return { targetRef, containerRef }; } diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computePosition.test.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computePosition.test.ts new file mode 100644 index 0000000000000..c8333edf769d0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computePosition.test.ts @@ -0,0 +1,90 @@ +import { computePosition } from './computePosition'; + +function makeRect(rect: Partial): DOMRect { + const { x = 0, y = 0, width = 0, height = 0 } = rect; + return { + x, + y, + width, + height, + top: rect.top ?? y, + left: rect.left ?? x, + right: rect.right ?? x + width, + bottom: rect.bottom ?? y + height, + toJSON: () => ({}), + } as DOMRect; +} + +function makeElement(rect: Partial): HTMLElement { + const el = document.createElement('div'); + el.getBoundingClientRect = () => makeRect(rect); + return el; +} + +describe('computePosition', () => { + it('returns above-center when floating sits directly above the reference', () => { + const reference = makeElement({ top: 200, left: 100, right: 200, bottom: 240, width: 100, height: 40 }); + const floating = makeElement({ top: 150, left: 125, right: 175, bottom: 190, width: 50, height: 40 }); + + const result = computePosition(reference, floating); + + expect(result).toEqual( + expect.objectContaining({ + position: 'above', + align: 'center', + placement: 'above', + }), + ); + }); + + it('returns below-start when floating is below and left-aligned with reference', () => { + const reference = makeElement({ top: 100, left: 100, right: 200, bottom: 140, width: 100, height: 40 }); + const floating = makeElement({ top: 150, left: 100, right: 160, bottom: 200, width: 60, height: 50 }); + + const result = computePosition(reference, floating); + expect(result?.position).toBe('below'); + expect(result?.align).toBe('start'); + expect(result?.placement).toBe('below-start'); + }); + + it('returns before-end when floating is to the left and bottom-aligned', () => { + const reference = makeElement({ top: 100, left: 200, right: 300, bottom: 200, width: 100, height: 100 }); + const floating = makeElement({ top: 120, left: 100, right: 195, bottom: 200, width: 95, height: 80 }); + + const result = computePosition(reference, floating); + expect(result?.position).toBe('before'); + expect(result?.align).toBe('end'); + expect(result?.placement).toBe('before-bottom'); + }); + + it('returns after when floating is to the right and centered', () => { + const reference = makeElement({ top: 100, left: 100, right: 200, bottom: 200, width: 100, height: 100 }); + const floating = makeElement({ top: 120, left: 210, right: 290, bottom: 180, width: 80, height: 60 }); + + const result = computePosition(reference, floating); + expect(result?.position).toBe('after'); + expect(result?.align).toBe('center'); + }); + + it('returns null when rects overlap and no clear side is determinable', () => { + const reference = makeElement({ top: 100, left: 100, right: 200, bottom: 200, width: 100, height: 100 }); + const floating = makeElement({ top: 150, left: 150, right: 250, bottom: 250, width: 100, height: 100 }); + + expect(computePosition(reference, floating)).toBeNull(); + }); + + it('absorbs subpixel mismatches up to the default tolerance (2px)', () => { + const reference = makeElement({ top: 200, left: 100, right: 200, bottom: 240, width: 100, height: 40 }); + const floating = makeElement({ top: 150, left: 125, right: 175, bottom: 201.5, width: 50, height: 51.5 }); + + expect(computePosition(reference, floating)?.position).toBe('above'); + }); + + it('respects a custom tolerance', () => { + const reference = makeElement({ top: 200, left: 100, right: 200, bottom: 240, width: 100, height: 40 }); + const floating = makeElement({ top: 150, left: 125, right: 175, bottom: 205, width: 50, height: 55 }); + + expect(computePosition(reference, floating)).toBeNull(); + expect(computePosition(reference, floating, { tolerance: 6 })?.position).toBe('above'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computePosition.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computePosition.ts new file mode 100644 index 0000000000000..4d5ae7af803ab --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computePosition.ts @@ -0,0 +1,85 @@ +import type { Position, PositioningShorthandValue } from '@fluentui/react-positioning'; +import type { LogicalAlignment } from '../types'; +import { ALIGNMENTS, POSITIONS } from '../constants'; +import { getPlacementString } from './placement'; + +const DEFAULT_TOLERANCE = 2; + +const closeTo = (a: number, b: number, tolerance: number): boolean => Math.abs(a - b) <= tolerance; + +function detectPosition(floatingRect: DOMRect, referenceRect: DOMRect, tolerance: number): Position | null { + if (floatingRect.bottom <= referenceRect.top + tolerance) { + return POSITIONS.above; + } + + if (floatingRect.top >= referenceRect.bottom - tolerance) { + return POSITIONS.below; + } + + if (floatingRect.right <= referenceRect.left + tolerance) { + return POSITIONS.before; + } + + if (floatingRect.left >= referenceRect.right - tolerance) { + return POSITIONS.after; + } + + return null; +} + +function detectAlign( + position: Position, + floatingRect: DOMRect, + referenceRect: DOMRect, + tolerance: number, +): LogicalAlignment { + const isBlockMain = position === POSITIONS.above || position === POSITIONS.below; + + const startAligned = isBlockMain + ? closeTo(floatingRect.left, referenceRect.left, tolerance) + : closeTo(floatingRect.top, referenceRect.top, tolerance); + + if (startAligned) { + return ALIGNMENTS.start; + } + + const endAligned = isBlockMain + ? closeTo(floatingRect.right, referenceRect.right, tolerance) + : closeTo(floatingRect.bottom, referenceRect.bottom, tolerance); + + if (endAligned) { + return ALIGNMENTS.end; + } + + return ALIGNMENTS.center; +} + +export interface ComputePositionConfig { + tolerance?: number; +} + +export interface ComputePositionReturn { + position: Position; + align: LogicalAlignment; + placement: PositioningShorthandValue; +} + +export function computePosition( + reference: HTMLElement, + floating: HTMLElement, + config?: ComputePositionConfig, +): ComputePositionReturn | null { + const tolerance = config?.tolerance ?? DEFAULT_TOLERANCE; + const referenceRect = reference.getBoundingClientRect(); + const floatingRect = floating.getBoundingClientRect(); + + const position = detectPosition(floatingRect, referenceRect, tolerance); + if (!position) { + return null; + } + + const align = detectAlign(position, floatingRect, referenceRect, tolerance); + const placement = getPlacementString(position, align); + + return { position, align, placement }; +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/debounce.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/debounce.ts new file mode 100644 index 0000000000000..cc80d606118e2 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/debounce.ts @@ -0,0 +1,18 @@ +/** + * Microtask debouncer: coalesces multiple synchronous calls within a task into + * a single invocation, fired before the next paint. Same shape as the helper + * `@fluentui/react-positioning` uses internally (originally from Popper.js v2). + */ +export function debounce(fn: () => void): () => void { + let pending = false; + return () => { + if (pending) { + return; + } + pending = true; + Promise.resolve().then(() => { + pending = false; + fn(); + }); + }; +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts index 78d00ae45eb4c..2feda920ed08f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts @@ -1,8 +1,11 @@ +export { computePosition } from './computePosition'; +export type { ComputePositionConfig, ComputePositionReturn } from './computePosition'; +export { debounce } from './debounce'; export { applyOffset, resolveOffset } from './offset'; export { getCoverSelfAlignment, getPlacementString, - resolvePositioningShorthand, shorthandToPositionArea, + supportsAnchoredContainerQueries, } from './placement'; export { resolveElementRef } from './resolveElementRef'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts index 08d5b0a43eba5..2b28c35d46bb4 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts @@ -1,10 +1,8 @@ import { resolvePositioningShorthand } from '@fluentui/react-positioning'; -import type { Position, PositioningShorthandValue } from '@fluentui/react-positioning'; +import type { Alignment, Position, PositioningShorthandValue } from '@fluentui/react-positioning'; import type { LogicalAlignment } from '../types'; import { ALIGNMENTS, POSITIONS, POSITION_AREA_MAP } from '../constants'; -export { resolvePositioningShorthand }; - const ALIGN_ALIASES: Record = { top: ALIGNMENTS.start, bottom: ALIGNMENTS.end, @@ -19,22 +17,23 @@ export function normalizeAlign(raw: string): LogicalAlignment { } /** - * Maps (position, align) into the human-readable placement string used for the - * `data-placement` attribute. Center alignment renders as the bare position. - * - * For horizontal positions (`before`/`after`) the alignment is rendered using - * physical names (`top`/`bottom`) to match react-positioning's convention. + * Maps (position, align) into the placement value used for the `data-placement` + * attribute. Center alignment renders as the bare position; horizontal positions + * (`before`/`after`) render their alignment as physical (`top`/`bottom`) to + * match react-positioning's convention. */ -export function getPlacementString(position: Position, align: LogicalAlignment): string { - if (align === ALIGNMENTS.center) { +export function getPlacementString(position: Position, align: Alignment): PositioningShorthandValue { + const logical = normalizeAlign(align); + + if (logical === ALIGNMENTS.center) { return position; } if (position === POSITIONS.before || position === POSITIONS.after) { - return `${position}-${align === ALIGNMENTS.start ? 'top' : 'bottom'}`; + return `${position}-${logical === ALIGNMENTS.start ? 'top' : 'bottom'}`; } - return `${position}-${align}`; + return `${position}-${logical}`; } export function shorthandToPositionArea(shorthand: PositioningShorthandValue): string { @@ -60,3 +59,12 @@ export function getCoverSelfAlignment( return { alignSelf: align, justifySelf: ALIGNMENTS.start }; } + +export const supportsAnchoredContainerQueries = (win: Window): boolean => { + return Boolean( + (win as Window & { CSS?: { supports?: (prop: string, value: string) => boolean } }).CSS?.supports?.( + 'container-type', + 'anchored', + ), + ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/popover.module.css b/packages/react-components/react-headless-components-preview/stories/src/Popover/popover.module.css index 4421499d29b36..97bea80f70431 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/popover.module.css +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/popover.module.css @@ -262,3 +262,67 @@ .surfaceWithArrow[data-placement$='-end'] [data-arrow] { inset-inline-end: var(--arrow-padding, 12px); } + +/* + Chrome 143+ exposes `@container anchored(fallback: …)` which fires when a + CSS `position-try-fallback` is active. The base `[data-placement^="…"]` + rules above keep working (the requested placement is always written to + `data-placement`); the overrides below only flip the arrow when the + surface itself was flipped by the browser. +*/ +@container anchored(fallback: flip-block) { + .surfaceWithArrow[data-placement^='above'] [data-arrow] { + top: -6px; + bottom: auto; + } + .surfaceWithArrow[data-placement^='below'] [data-arrow] { + top: auto; + bottom: -6px; + } +} + +@container anchored(fallback: flip-inline) { + .surfaceWithArrow[data-placement^='before'] [data-arrow] { + left: -6px; + right: auto; + } + .surfaceWithArrow[data-placement^='after'] [data-arrow] { + left: auto; + right: -6px; + } + .surfaceWithArrow[data-placement$='-start'] [data-arrow] { + inset-inline-start: auto; + inset-inline-end: var(--arrow-padding, 12px); + } + .surfaceWithArrow[data-placement$='-end'] [data-arrow] { + inset-inline-end: auto; + inset-inline-start: var(--arrow-padding, 12px); + } +} + +@container anchored(fallback: flip-block flip-inline) { + .surfaceWithArrow[data-placement^='above'] [data-arrow] { + top: -6px; + bottom: auto; + } + .surfaceWithArrow[data-placement^='below'] [data-arrow] { + top: auto; + bottom: -6px; + } + .surfaceWithArrow[data-placement^='before'] [data-arrow] { + left: -6px; + right: auto; + } + .surfaceWithArrow[data-placement^='after'] [data-arrow] { + left: auto; + right: -6px; + } + .surfaceWithArrow[data-placement$='-start'] [data-arrow] { + inset-inline-start: auto; + inset-inline-end: var(--arrow-padding, 12px); + } + .surfaceWithArrow[data-placement$='-end'] [data-arrow] { + inset-inline-end: auto; + inset-inline-start: var(--arrow-padding, 12px); + } +}