From 883392cd11291ef900f34a2dded03edfc1bd4617 Mon Sep 17 00:00:00 2001 From: mainframev Date: Tue, 5 May 2026 22:55:04 +0200 Subject: [PATCH 1/6] fix(positioning): fix positioning types and performance issues refactor(headless-popover): refactor implementation --- .../library/etc/positioning.api.md | 7 +- .../library/src/hooks/usePositioning/index.ts | 6 +- .../library/src/hooks/usePositioning/types.ts | 15 ++ .../usePositioning/usePlacementObserver.ts | 124 ++++------- .../usePositioning/usePositioning.test.tsx | 204 +++++++++++++++++- .../hooks/usePositioning/usePositioning.ts | 20 +- .../utils/computePosition.test.ts | 90 ++++++++ .../usePositioning/utils/computePosition.ts | 85 ++++++++ .../hooks/usePositioning/utils/debounce.ts | 18 ++ .../src/hooks/usePositioning/utils/index.ts | 10 +- .../src/hooks/usePositioning/utils/offset.ts | 38 ++-- .../hooks/usePositioning/utils/placement.ts | 23 +- 12 files changed, 500 insertions(+), 140 deletions(-) create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computePosition.test.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/computePosition.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/debounce.ts 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/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..6e620efbefe04 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,62 +1,14 @@ '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 } 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. * */ export function usePlacementObserver( @@ -64,48 +16,56 @@ export function usePlacementObserver( targetEl: HTMLElement | null, targetDocument: Document | undefined, disabled = false, -): void { +): () => void { + const update = useEventCallback(() => { + if (!containerEl || !targetEl) { + return; + } + + const result = computePosition(targetEl, containerEl); + const next = result?.placement ?? 'indeterminate'; + + if (containerEl.getAttribute('data-placement') !== next) { + containerEl.setAttribute('data-placement', next); + } + }); + 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 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..f8ef862b50eb4 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 }>(); @@ -160,7 +159,7 @@ describe('usePositioning', () => { expect(node).toHaveStyle({ width: 'anchor-size(width)' }); }); - it('containerRef applies offset as logical margins', () => { + it('containerRef applies offset as symmetric logical margins so flips keep their gap', () => { const result = mountHook({ position: 'below', offset: { mainAxis: 8, crossAxis: 4 } }); const node = document.createElement('div'); @@ -168,7 +167,204 @@ describe('usePositioning', () => { result.current.containerRef(node); }); - expect(node).toHaveStyle({ marginBlockStart: '8px', marginInlineStart: '4px' }); + expect(node).toHaveStyle({ + marginBlockStart: '8px', + marginBlockEnd: '8px', + marginInlineStart: '4px', + marginInlineEnd: '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(); + }); + }); +}); + +describe('placement observer', () => { + type ResizeObserverInstance = { + observe: jest.Mock; + disconnect: jest.Mock; + callback: ResizeObserverCallback; + observed: Element[]; + }; + + let originalResizeObserver: typeof ResizeObserver | undefined; + let resizeObservers: ResizeObserverInstance[]; + let windowAddSpy: jest.SpyInstance; + let windowRemoveSpy: jest.SpyInstance; + + beforeEach(() => { + originalResizeObserver = window.ResizeObserver; + resizeObservers = []; + + window.ResizeObserver = class MockResizeObserver { + public observe: jest.Mock; + public disconnect: jest.Mock; + public callback: ResizeObserverCallback; + public observed: Element[]; + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + this.observed = []; + const observed = this.observed; + this.observe = jest.fn((el: Element) => { + observed.push(el); + }); + this.disconnect = jest.fn(); + resizeObservers.push(this as unknown as ResizeObserverInstance); + } + } as unknown as typeof ResizeObserver; + + windowAddSpy = jest.spyOn(window, 'addEventListener'); + windowRemoveSpy = jest.spyOn(window, 'removeEventListener'); + }); + + afterEach(() => { + if (originalResizeObserver) { + window.ResizeObserver = originalResizeObserver; + } else { + delete (window as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; + } + windowAddSpy.mockRestore(); + windowRemoveSpy.mockRestore(); + }); + + const callsOfType = (spy: jest.SpyInstance, type: string) => spy.mock.calls.filter(call => call[0] === type); + + const optionFlag = (call: unknown[], key: 'capture' | 'passive'): boolean => { + const opts = call[2]; + if (typeof opts === 'boolean') { + return key === 'capture' ? opts : false; + } + return typeof opts === 'object' && opts !== null && (opts as AddEventListenerOptions)[key] === true; + }; + + const ObserverHarness = ({ options = {} }: { options?: PositioningProps }) => { + const { targetRef, containerRef } = usePositioning(options); + return ( + <> +
+
+ + ); + }; + + const flushMicrotasks = async () => { + await Promise.resolve(); + }; + + it('observes container and target with ResizeObserver', () => { + render(); + expect(resizeObservers).toHaveLength(1); + expect(resizeObservers[0].observed).toHaveLength(2); + expect(resizeObservers[0].observed[0].getAttribute('data-testid')).toBe('container'); + expect(resizeObservers[0].observed[1].getAttribute('data-testid')).toBe('target'); + }); + + it('attaches a capture-phase passive scroll listener and a resize listener on the window', () => { + render(); + + const windowScroll = callsOfType(windowAddSpy, 'scroll'); + const windowResize = callsOfType(windowAddSpy, 'resize'); + + expect(windowScroll).toHaveLength(1); + expect(optionFlag(windowScroll[0], 'capture')).toBe(true); + expect(optionFlag(windowScroll[0], 'passive')).toBe(true); + expect(windowResize).toHaveLength(1); + }); + + it('coalesces multiple resize callbacks within a microtask cycle into one update', async () => { + const setAttrSpy = jest.spyOn(Element.prototype, 'setAttribute'); + + render(); + await act(async () => { + await flushMicrotasks(); + }); + setAttrSpy.mockClear(); + + const ro = resizeObservers[0]; + const target = ro.observed.find(el => el.getAttribute('data-testid') === 'target')!; + const container = ro.observed.find(el => el.getAttribute('data-testid') === 'container')!; + const entry = (el: Element) => + ({ + target: el, + contentRect: { width: 100, height: 50, top: 0, left: 0, right: 100, bottom: 50, x: 0, y: 0 }, + } as ResizeObserverEntry); + + await act(async () => { + (ro.callback as ResizeObserverCallback)([entry(target), entry(container)], ro as unknown as ResizeObserver); + (ro.callback as ResizeObserverCallback)([entry(target), entry(container)], ro as unknown as ResizeObserver); + (ro.callback as ResizeObserverCallback)([entry(target), entry(container)], ro as unknown as ResizeObserver); + await flushMicrotasks(); + }); + + // Three RO ticks within the same microtask cycle → at most one data-placement write. + const placementWrites = setAttrSpy.mock.calls.filter(call => call[0] === 'data-placement'); + expect(placementWrites.length).toBeLessThanOrEqual(1); + + setAttrSpy.mockRestore(); + }); + + it('skips updates when ResizeObserver fires with a 0×0 contentRect (likely display:none)', async () => { + const setAttrSpy = jest.spyOn(Element.prototype, 'setAttribute'); + + render(); + await act(async () => { + await flushMicrotasks(); + }); + setAttrSpy.mockClear(); + + const ro = resizeObservers[0]; + const target = ro.observed.find(el => el.getAttribute('data-testid') === 'target')!; + const zeroEntry = { + target, + contentRect: { width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 }, + } as ResizeObserverEntry; + + await act(async () => { + (ro.callback as ResizeObserverCallback)([zeroEntry], ro as unknown as ResizeObserver); + await flushMicrotasks(); + }); + + expect(setAttrSpy.mock.calls.find(call => call[0] === 'data-placement')).toBeUndefined(); + + setAttrSpy.mockRestore(); + }); + + it('disconnects ResizeObserver and removes scroll/resize listeners on unmount', () => { + const { unmount } = render(); + const ro = resizeObservers[0]; + + const scrollAdds = callsOfType(windowAddSpy, 'scroll').length; + const resizeAdds = callsOfType(windowAddSpy, 'resize').length; + + unmount(); + + expect(ro.disconnect).toHaveBeenCalled(); + expect(callsOfType(windowRemoveSpy, 'scroll')).toHaveLength(scrollAdds); + expect(callsOfType(windowRemoveSpy, 'resize')).toHaveLength(resizeAdds); + + // The scroll listener was added with capture: true; cleanup must match. + const scrollRemove = callsOfType(windowRemoveSpy, 'scroll')[0]; + expect(optionFlag(scrollRemove, 'capture')).toBe(true); + }); + + it('does not attach observers or listeners when coverTarget is true (no flip can happen)', () => { + render(); + expect(resizeObservers).toHaveLength(0); + expect(callsOfType(windowAddSpy, 'scroll')).toHaveLength(0); + expect(callsOfType(windowAddSpy, 'resize')).toHaveLength(0); }); }); 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..68bcd95c54373 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,10 +5,10 @@ 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'; @@ -17,6 +17,7 @@ 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 +25,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 +36,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 +54,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(() => { @@ -149,7 +155,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..8506fa430c612 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts @@ -1,8 +1,6 @@ +export { computePosition } from './computePosition'; +export type { ComputePositionConfig, ComputePositionReturn } from './computePosition'; +export { debounce } from './debounce'; export { applyOffset, resolveOffset } from './offset'; -export { - getCoverSelfAlignment, - getPlacementString, - resolvePositioningShorthand, - shorthandToPositionArea, -} from './placement'; +export { getCoverSelfAlignment, getPlacementString, shorthandToPositionArea } from './placement'; export { resolveElementRef } from './resolveElementRef'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/offset.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/offset.ts index 2a8537873de62..940956f736d98 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/offset.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/offset.ts @@ -2,33 +2,27 @@ import type { Position, PositioningProps } from '@fluentui/react-positioning'; import { POSITIONS } from '../constants'; export function applyOffset(node: HTMLElement, position: Position, mainAxis: number, crossAxis: number): void { + const isBlockMain = position === POSITIONS.above || position === POSITIONS.below; + if (mainAxis) { - switch (position) { - case POSITIONS.above: - node.style.marginBlockEnd = `${mainAxis}px`; - break; - case POSITIONS.below: - node.style.marginBlockStart = `${mainAxis}px`; - break; - case POSITIONS.before: - node.style.marginInlineEnd = `${mainAxis}px`; - break; - case POSITIONS.after: - node.style.marginInlineStart = `${mainAxis}px`; - break; + const main = `${mainAxis}px`; + if (isBlockMain) { + node.style.marginBlockStart = main; + node.style.marginBlockEnd = main; + } else { + node.style.marginInlineStart = main; + node.style.marginInlineEnd = main; } } if (crossAxis) { - switch (position) { - case POSITIONS.above: - case POSITIONS.below: - node.style.marginInlineStart = `${crossAxis}px`; - break; - case POSITIONS.before: - case POSITIONS.after: - node.style.marginBlockStart = `${crossAxis}px`; - break; + const cross = `${crossAxis}px`; + if (isBlockMain) { + node.style.marginInlineStart = cross; + node.style.marginInlineEnd = cross; + } else { + node.style.marginBlockStart = cross; + node.style.marginBlockEnd = cross; } } } 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..812e5919fd123 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 { From abfc113013a194facac83f727dc1dee3b3bb63b0 Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 6 May 2026 19:00:51 +0200 Subject: [PATCH 2/6] test(react-headless-components-preview): add more tests --- .../src/components/Popover/Popover.cy.tsx | 28 +++ .../usePositioning/usePositioning.test.tsx | 177 ------------------ 2 files changed, 28 insertions(+), 177 deletions(-) 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..9d149b781c720 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-placement', 'above'); + }); +}); 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 f8ef862b50eb4..7f849b3294e97 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 @@ -191,183 +191,6 @@ describe('usePositioning', () => { }); }); -describe('placement observer', () => { - type ResizeObserverInstance = { - observe: jest.Mock; - disconnect: jest.Mock; - callback: ResizeObserverCallback; - observed: Element[]; - }; - - let originalResizeObserver: typeof ResizeObserver | undefined; - let resizeObservers: ResizeObserverInstance[]; - let windowAddSpy: jest.SpyInstance; - let windowRemoveSpy: jest.SpyInstance; - - beforeEach(() => { - originalResizeObserver = window.ResizeObserver; - resizeObservers = []; - - window.ResizeObserver = class MockResizeObserver { - public observe: jest.Mock; - public disconnect: jest.Mock; - public callback: ResizeObserverCallback; - public observed: Element[]; - - constructor(callback: ResizeObserverCallback) { - this.callback = callback; - this.observed = []; - const observed = this.observed; - this.observe = jest.fn((el: Element) => { - observed.push(el); - }); - this.disconnect = jest.fn(); - resizeObservers.push(this as unknown as ResizeObserverInstance); - } - } as unknown as typeof ResizeObserver; - - windowAddSpy = jest.spyOn(window, 'addEventListener'); - windowRemoveSpy = jest.spyOn(window, 'removeEventListener'); - }); - - afterEach(() => { - if (originalResizeObserver) { - window.ResizeObserver = originalResizeObserver; - } else { - delete (window as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; - } - windowAddSpy.mockRestore(); - windowRemoveSpy.mockRestore(); - }); - - const callsOfType = (spy: jest.SpyInstance, type: string) => spy.mock.calls.filter(call => call[0] === type); - - const optionFlag = (call: unknown[], key: 'capture' | 'passive'): boolean => { - const opts = call[2]; - if (typeof opts === 'boolean') { - return key === 'capture' ? opts : false; - } - return typeof opts === 'object' && opts !== null && (opts as AddEventListenerOptions)[key] === true; - }; - - const ObserverHarness = ({ options = {} }: { options?: PositioningProps }) => { - const { targetRef, containerRef } = usePositioning(options); - return ( - <> -
-
- - ); - }; - - const flushMicrotasks = async () => { - await Promise.resolve(); - }; - - it('observes container and target with ResizeObserver', () => { - render(); - expect(resizeObservers).toHaveLength(1); - expect(resizeObservers[0].observed).toHaveLength(2); - expect(resizeObservers[0].observed[0].getAttribute('data-testid')).toBe('container'); - expect(resizeObservers[0].observed[1].getAttribute('data-testid')).toBe('target'); - }); - - it('attaches a capture-phase passive scroll listener and a resize listener on the window', () => { - render(); - - const windowScroll = callsOfType(windowAddSpy, 'scroll'); - const windowResize = callsOfType(windowAddSpy, 'resize'); - - expect(windowScroll).toHaveLength(1); - expect(optionFlag(windowScroll[0], 'capture')).toBe(true); - expect(optionFlag(windowScroll[0], 'passive')).toBe(true); - expect(windowResize).toHaveLength(1); - }); - - it('coalesces multiple resize callbacks within a microtask cycle into one update', async () => { - const setAttrSpy = jest.spyOn(Element.prototype, 'setAttribute'); - - render(); - await act(async () => { - await flushMicrotasks(); - }); - setAttrSpy.mockClear(); - - const ro = resizeObservers[0]; - const target = ro.observed.find(el => el.getAttribute('data-testid') === 'target')!; - const container = ro.observed.find(el => el.getAttribute('data-testid') === 'container')!; - const entry = (el: Element) => - ({ - target: el, - contentRect: { width: 100, height: 50, top: 0, left: 0, right: 100, bottom: 50, x: 0, y: 0 }, - } as ResizeObserverEntry); - - await act(async () => { - (ro.callback as ResizeObserverCallback)([entry(target), entry(container)], ro as unknown as ResizeObserver); - (ro.callback as ResizeObserverCallback)([entry(target), entry(container)], ro as unknown as ResizeObserver); - (ro.callback as ResizeObserverCallback)([entry(target), entry(container)], ro as unknown as ResizeObserver); - await flushMicrotasks(); - }); - - // Three RO ticks within the same microtask cycle → at most one data-placement write. - const placementWrites = setAttrSpy.mock.calls.filter(call => call[0] === 'data-placement'); - expect(placementWrites.length).toBeLessThanOrEqual(1); - - setAttrSpy.mockRestore(); - }); - - it('skips updates when ResizeObserver fires with a 0×0 contentRect (likely display:none)', async () => { - const setAttrSpy = jest.spyOn(Element.prototype, 'setAttribute'); - - render(); - await act(async () => { - await flushMicrotasks(); - }); - setAttrSpy.mockClear(); - - const ro = resizeObservers[0]; - const target = ro.observed.find(el => el.getAttribute('data-testid') === 'target')!; - const zeroEntry = { - target, - contentRect: { width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 }, - } as ResizeObserverEntry; - - await act(async () => { - (ro.callback as ResizeObserverCallback)([zeroEntry], ro as unknown as ResizeObserver); - await flushMicrotasks(); - }); - - expect(setAttrSpy.mock.calls.find(call => call[0] === 'data-placement')).toBeUndefined(); - - setAttrSpy.mockRestore(); - }); - - it('disconnects ResizeObserver and removes scroll/resize listeners on unmount', () => { - const { unmount } = render(); - const ro = resizeObservers[0]; - - const scrollAdds = callsOfType(windowAddSpy, 'scroll').length; - const resizeAdds = callsOfType(windowAddSpy, 'resize').length; - - unmount(); - - expect(ro.disconnect).toHaveBeenCalled(); - expect(callsOfType(windowRemoveSpy, 'scroll')).toHaveLength(scrollAdds); - expect(callsOfType(windowRemoveSpy, 'resize')).toHaveLength(resizeAdds); - - // The scroll listener was added with capture: true; cleanup must match. - const scrollRemove = callsOfType(windowRemoveSpy, 'scroll')[0]; - expect(optionFlag(scrollRemove, 'capture')).toBe(true); - }); - - it('does not attach observers or listeners when coverTarget is true (no flip can happen)', () => { - render(); - expect(resizeObservers).toHaveLength(0); - expect(callsOfType(windowAddSpy, 'scroll')).toHaveLength(0); - expect(callsOfType(windowAddSpy, 'resize')).toHaveLength(0); - }); -}); - describe('getPlacementString', () => { it('returns the bare position for center alignment', () => { expect(getPlacementString('above', 'center')).toBe('above'); From aeb1d2970d4db1bdd477fbc57af022460b3c1ab2 Mon Sep 17 00:00:00 2001 From: mainframev Date: Tue, 12 May 2026 11:50:24 +0200 Subject: [PATCH 3/6] chore: add change file --- ...nents-preview-fa7a6438-e4cf-4a58-9c0b-5d4d44798e1e.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-react-headless-components-preview-fa7a6438-e4cf-4a58-9c0b-5d4d44798e1e.json 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" +} From be863dd416517ad98733c4835d92927423510b13 Mon Sep 17 00:00:00 2001 From: mainframev Date: Thu, 7 May 2026 00:01:05 +0200 Subject: [PATCH 4/6] feat(react-headless-components-preview): use container-type for arrow placement --- .../src/components/Popover/Popover.cy.tsx | 2 +- .../usePositioning/usePlacementObserver.ts | 19 ++- .../usePositioning/usePositioning.test.tsx | 123 +++++++++++++++++- .../hooks/usePositioning/usePositioning.ts | 24 +++- .../src/hooks/usePositioning/utils/index.ts | 7 +- .../src/hooks/usePositioning/utils/offset.ts | 38 +++--- .../hooks/usePositioning/utils/placement.ts | 9 ++ .../stories/src/Popover/popover.module.css | 103 +++++++++++++++ 8 files changed, 297 insertions(+), 28 deletions(-) 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 9d149b781c720..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 @@ -404,6 +404,6 @@ describe('positioning observer', () => { cy.then(() => { expect(() => positioningRef.current?.updatePosition()).not.to.throw(); }); - cy.get(surfaceSelector).should('have.attr', 'data-placement', 'above'); + cy.get(surfaceSelector).should('have.attr', 'data-position', 'above'); }); }); 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 6e620efbefe04..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,7 +1,7 @@ 'use client'; import { useIsomorphicLayoutEffect, useEventCallback } from '@fluentui/react-utilities'; -import { computePosition, debounce } from './utils'; +import { computePosition, debounce, supportsAnchoredContainerQueries } from './utils'; /** * Mirrors the placement that the browser actually resolves for an @@ -10,6 +10,9 @@ import { computePosition, debounce } from './utils'; * 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, @@ -23,10 +26,13 @@ export function usePlacementObserver( } const result = computePosition(targetEl, containerEl); - const next = result?.placement ?? 'indeterminate'; - if (containerEl.getAttribute('data-placement') !== next) { - containerEl.setAttribute('data-placement', next); + if (!result) { + return; + } + + if (containerEl.getAttribute('data-placement') !== result.placement) { + containerEl.setAttribute('data-placement', result.placement); } }); @@ -36,10 +42,15 @@ export function usePlacementObserver( } const win = targetDocument?.defaultView; + if (!win) { return; } + if (supportsAnchoredContainerQueries(win)) { + return; + } + const debouncedUpdate = debounce(update); const ResizeObserverCtor = win.ResizeObserver; 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 7f849b3294e97..fca8c09059d37 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 @@ -68,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'); @@ -76,9 +76,47 @@ 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 omits data-placement and sets 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); + }); + + expect(node).not.toHaveAttribute('data-placement'); + 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'); @@ -159,7 +197,7 @@ describe('usePositioning', () => { expect(node).toHaveStyle({ width: 'anchor-size(width)' }); }); - it('containerRef applies offset as symmetric logical margins so flips keep their gap', () => { + 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'); @@ -169,9 +207,7 @@ describe('usePositioning', () => { expect(node).toHaveStyle({ marginBlockStart: '8px', - marginBlockEnd: '8px', marginInlineStart: '4px', - marginInlineEnd: '4px', }); }); @@ -204,3 +240,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 68bcd95c54373..a01b140a93e0a 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 @@ -11,7 +11,14 @@ import type { 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; @@ -102,7 +109,20 @@ export function usePositioning(options: PositioningProps): PositioningReturn { } node.style.setProperty('position-anchor', anchorName); - node.setAttribute('data-placement', placement); + + node.setAttribute('data-position', position); + node.setAttribute('data-align', align); + + const win = node.ownerDocument?.defaultView; + + if (win && supportsAnchoredContainerQueries(win)) { + // Chrome 143+: opt into `@container anchored(fallback: …)` queries. + node.style.setProperty('container-type', 'anchored'); + node.removeAttribute('data-placement'); + } else { + node.style.removeProperty('container-type'); + node.setAttribute('data-placement', placement); + } if (coverAlignment) { node.style.setProperty('position-area', 'center'); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts index 8506fa430c612..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 @@ -2,5 +2,10 @@ export { computePosition } from './computePosition'; export type { ComputePositionConfig, ComputePositionReturn } from './computePosition'; export { debounce } from './debounce'; export { applyOffset, resolveOffset } from './offset'; -export { getCoverSelfAlignment, getPlacementString, shorthandToPositionArea } from './placement'; +export { + getCoverSelfAlignment, + getPlacementString, + shorthandToPositionArea, + supportsAnchoredContainerQueries, +} from './placement'; export { resolveElementRef } from './resolveElementRef'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/offset.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/offset.ts index 940956f736d98..2a8537873de62 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/offset.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/offset.ts @@ -2,27 +2,33 @@ import type { Position, PositioningProps } from '@fluentui/react-positioning'; import { POSITIONS } from '../constants'; export function applyOffset(node: HTMLElement, position: Position, mainAxis: number, crossAxis: number): void { - const isBlockMain = position === POSITIONS.above || position === POSITIONS.below; - if (mainAxis) { - const main = `${mainAxis}px`; - if (isBlockMain) { - node.style.marginBlockStart = main; - node.style.marginBlockEnd = main; - } else { - node.style.marginInlineStart = main; - node.style.marginInlineEnd = main; + switch (position) { + case POSITIONS.above: + node.style.marginBlockEnd = `${mainAxis}px`; + break; + case POSITIONS.below: + node.style.marginBlockStart = `${mainAxis}px`; + break; + case POSITIONS.before: + node.style.marginInlineEnd = `${mainAxis}px`; + break; + case POSITIONS.after: + node.style.marginInlineStart = `${mainAxis}px`; + break; } } if (crossAxis) { - const cross = `${crossAxis}px`; - if (isBlockMain) { - node.style.marginInlineStart = cross; - node.style.marginInlineEnd = cross; - } else { - node.style.marginBlockStart = cross; - node.style.marginBlockEnd = cross; + switch (position) { + case POSITIONS.above: + case POSITIONS.below: + node.style.marginInlineStart = `${crossAxis}px`; + break; + case POSITIONS.before: + case POSITIONS.after: + node.style.marginBlockStart = `${crossAxis}px`; + break; } } } diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts index 812e5919fd123..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 @@ -59,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..7c4c57d2833ef 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,106 @@ .surfaceWithArrow[data-placement$='-end'] [data-arrow] { inset-inline-end: var(--arrow-padding, 12px); } + +/* + On Chrome 143+ the JS placement observer is skipped, so `data-placement` + is not set on the surface. The rules below mirror the base ones above but + key on `data-position` / `data-align` (the requested side/alignment, always + set), and `@container anchored(fallback: …)` overrides handle CSS flips. + + Mapping (per requested placement): + above/below — `flip-block` flips main side (top ↔ bottom of surface) + `flip-inline` flips alignment padding (-start ↔ -end) + before/after — `flip-inline` flips main side (left ↔ right of surface) + `flip-block` flips block-axis alignment (no rule needed) +*/ +@supports (container-type: anchored) { + .surfaceWithArrow[data-position='above'] [data-arrow] { + bottom: -6px; + } + .surfaceWithArrow[data-position='below'] [data-arrow] { + top: -6px; + } + .surfaceWithArrow[data-position='before'] [data-arrow] { + right: -6px; + } + .surfaceWithArrow[data-position='after'] [data-arrow] { + left: -6px; + } + + .surfaceWithArrow[data-position='above'][data-align='center'] [data-arrow], + .surfaceWithArrow[data-position='below'][data-align='center'] [data-arrow] { + inset-inline: 0; + margin-inline: auto; + } + + .surfaceWithArrow[data-position='before'][data-align='center'] [data-arrow], + .surfaceWithArrow[data-position='after'][data-align='center'] [data-arrow] { + inset-block: 0; + margin-block: auto; + } + + .surfaceWithArrow[data-align='start'] [data-arrow] { + inset-inline-start: var(--arrow-padding, 12px); + } + .surfaceWithArrow[data-align='end'] [data-arrow] { + inset-inline-end: var(--arrow-padding, 12px); + } + + @container anchored(fallback: flip-block) { + .surfaceWithArrow[data-position='above'] [data-arrow] { + top: -6px; + bottom: auto; + } + .surfaceWithArrow[data-position='below'] [data-arrow] { + top: auto; + bottom: -6px; + } + } + + @container anchored(fallback: flip-inline) { + .surfaceWithArrow[data-position='before'] [data-arrow] { + left: -6px; + right: auto; + } + .surfaceWithArrow[data-position='after'] [data-arrow] { + left: auto; + right: -6px; + } + .surfaceWithArrow[data-align='start'] [data-arrow] { + inset-inline-start: auto; + inset-inline-end: var(--arrow-padding, 12px); + } + .surfaceWithArrow[data-align='end'] [data-arrow] { + inset-inline-end: auto; + inset-inline-start: var(--arrow-padding, 12px); + } + } + + @container anchored(fallback: flip-block flip-inline) { + .surfaceWithArrow[data-position='above'] [data-arrow] { + top: -6px; + bottom: auto; + } + .surfaceWithArrow[data-position='below'] [data-arrow] { + top: auto; + bottom: -6px; + } + .surfaceWithArrow[data-position='before'] [data-arrow] { + left: -6px; + right: auto; + } + .surfaceWithArrow[data-position='after'] [data-arrow] { + left: auto; + right: -6px; + } + .surfaceWithArrow[data-align='start'] [data-arrow] { + inset-inline-start: auto; + inset-inline-end: var(--arrow-padding, 12px); + } + .surfaceWithArrow[data-align='end'] [data-arrow] { + inset-inline-end: auto; + inset-inline-start: var(--arrow-padding, 12px); + } + } +} From 0e5bd95f12e3a4ac7de0a47c3014064ac58ecbfe Mon Sep 17 00:00:00 2001 From: mainframev Date: Thu, 7 May 2026 00:02:03 +0200 Subject: [PATCH 5/6] chore: change files --- ...nents-preview-8553b315-6293-4a03-b4b5-392c4b1f09b2.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-react-headless-components-preview-8553b315-6293-4a03-b4b5-392c4b1f09b2.json 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..43bcd12bc477f --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-8553b315-6293-4a03-b4b5-392c4b1f09b2.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat(headless): use container-type for arrow positioning in Popover' ", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} From d6768ac0de8fc52a2d67c54f4656351f1f263031 Mon Sep 17 00:00:00 2001 From: mainframev Date: Tue, 12 May 2026 11:58:44 +0200 Subject: [PATCH 6/6] fix: always set data-placement so consumers don't need two styles --- ...-8553b315-6293-4a03-b4b5-392c4b1f09b2.json | 2 +- .../usePositioning/usePositioning.test.tsx | 7 +- .../hooks/usePositioning/usePositioning.ts | 4 +- .../stories/src/Popover/popover.module.css | 131 ++++++------------ 4 files changed, 53 insertions(+), 91 deletions(-) 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 index 43bcd12bc477f..c1976cb8b77d2 100644 --- 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 @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "feat(headless): use container-type for arrow positioning in Popover' ", + "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/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 fca8c09059d37..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 @@ -93,7 +93,7 @@ describe('usePositioning', () => { expect(node).toHaveAttribute('data-placement', 'below-start'); }); - it('containerRef omits data-placement and sets container-type when anchored CQ is supported', () => { + 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 = { @@ -108,7 +108,10 @@ describe('usePositioning', () => { result.current.containerRef(node); }); - expect(node).not.toHaveAttribute('data-placement'); + // `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'); 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 a01b140a93e0a..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 @@ -112,16 +112,14 @@ export function usePositioning(options: PositioningProps): PositioningReturn { node.setAttribute('data-position', position); node.setAttribute('data-align', align); + node.setAttribute('data-placement', placement); const win = node.ownerDocument?.defaultView; if (win && supportsAnchoredContainerQueries(win)) { - // Chrome 143+: opt into `@container anchored(fallback: …)` queries. node.style.setProperty('container-type', 'anchored'); - node.removeAttribute('data-placement'); } else { node.style.removeProperty('container-type'); - node.setAttribute('data-placement', placement); } if (coverAlignment) { 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 7c4c57d2833ef..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 @@ -264,104 +264,65 @@ } /* - On Chrome 143+ the JS placement observer is skipped, so `data-placement` - is not set on the surface. The rules below mirror the base ones above but - key on `data-position` / `data-align` (the requested side/alignment, always - set), and `@container anchored(fallback: …)` overrides handle CSS flips. - - Mapping (per requested placement): - above/below — `flip-block` flips main side (top ↔ bottom of surface) - `flip-inline` flips alignment padding (-start ↔ -end) - before/after — `flip-inline` flips main side (left ↔ right of surface) - `flip-block` flips block-axis alignment (no rule needed) + 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. */ -@supports (container-type: anchored) { - .surfaceWithArrow[data-position='above'] [data-arrow] { +@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; } - .surfaceWithArrow[data-position='below'] [data-arrow] { - top: -6px; +} + +@container anchored(fallback: flip-inline) { + .surfaceWithArrow[data-placement^='before'] [data-arrow] { + left: -6px; + right: auto; } - .surfaceWithArrow[data-position='before'] [data-arrow] { + .surfaceWithArrow[data-placement^='after'] [data-arrow] { + left: auto; right: -6px; } - .surfaceWithArrow[data-position='after'] [data-arrow] { - left: -6px; + .surfaceWithArrow[data-placement$='-start'] [data-arrow] { + inset-inline-start: auto; + inset-inline-end: var(--arrow-padding, 12px); } - - .surfaceWithArrow[data-position='above'][data-align='center'] [data-arrow], - .surfaceWithArrow[data-position='below'][data-align='center'] [data-arrow] { - inset-inline: 0; - margin-inline: auto; + .surfaceWithArrow[data-placement$='-end'] [data-arrow] { + inset-inline-end: auto; + inset-inline-start: var(--arrow-padding, 12px); } +} - .surfaceWithArrow[data-position='before'][data-align='center'] [data-arrow], - .surfaceWithArrow[data-position='after'][data-align='center'] [data-arrow] { - inset-block: 0; - margin-block: auto; +@container anchored(fallback: flip-block flip-inline) { + .surfaceWithArrow[data-placement^='above'] [data-arrow] { + top: -6px; + bottom: auto; } - - .surfaceWithArrow[data-align='start'] [data-arrow] { - inset-inline-start: var(--arrow-padding, 12px); + .surfaceWithArrow[data-placement^='below'] [data-arrow] { + top: auto; + bottom: -6px; } - .surfaceWithArrow[data-align='end'] [data-arrow] { - inset-inline-end: var(--arrow-padding, 12px); + .surfaceWithArrow[data-placement^='before'] [data-arrow] { + left: -6px; + right: auto; } - - @container anchored(fallback: flip-block) { - .surfaceWithArrow[data-position='above'] [data-arrow] { - top: -6px; - bottom: auto; - } - .surfaceWithArrow[data-position='below'] [data-arrow] { - top: auto; - bottom: -6px; - } + .surfaceWithArrow[data-placement^='after'] [data-arrow] { + left: auto; + right: -6px; } - - @container anchored(fallback: flip-inline) { - .surfaceWithArrow[data-position='before'] [data-arrow] { - left: -6px; - right: auto; - } - .surfaceWithArrow[data-position='after'] [data-arrow] { - left: auto; - right: -6px; - } - .surfaceWithArrow[data-align='start'] [data-arrow] { - inset-inline-start: auto; - inset-inline-end: var(--arrow-padding, 12px); - } - .surfaceWithArrow[data-align='end'] [data-arrow] { - inset-inline-end: auto; - inset-inline-start: var(--arrow-padding, 12px); - } + .surfaceWithArrow[data-placement$='-start'] [data-arrow] { + inset-inline-start: auto; + inset-inline-end: var(--arrow-padding, 12px); } - - @container anchored(fallback: flip-block flip-inline) { - .surfaceWithArrow[data-position='above'] [data-arrow] { - top: -6px; - bottom: auto; - } - .surfaceWithArrow[data-position='below'] [data-arrow] { - top: auto; - bottom: -6px; - } - .surfaceWithArrow[data-position='before'] [data-arrow] { - left: -6px; - right: auto; - } - .surfaceWithArrow[data-position='after'] [data-arrow] { - left: auto; - right: -6px; - } - .surfaceWithArrow[data-align='start'] [data-arrow] { - inset-inline-start: auto; - inset-inline-end: var(--arrow-padding, 12px); - } - .surfaceWithArrow[data-align='end'] [data-arrow] { - inset-inline-end: auto; - inset-inline-start: var(--arrow-padding, 12px); - } + .surfaceWithArrow[data-placement$='-end'] [data-arrow] { + inset-inline-end: auto; + inset-inline-start: var(--arrow-padding, 12px); } }