Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<PositioningProps_2, 'align' | 'coverTarget' | 'fallbackPositions' | 'matchTargetSize' | 'offset' | 'pinned' | 'position' | 'positioningRef' | 'strategy' | 'target'>;

// @public (undocumented)
export type PositioningReturn = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -379,3 +380,37 @@ 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<PositioningImperativeRef>();

const Fixture = () => (
<div style={{ padding: 16, paddingTop: 240 }}>
<Popover defaultOpen positioning={{ position: 'above', positioningRef }}>
<PopoverTrigger disableButtonEnhancement>
<button data-testid="trigger">Trigger</button>
</PopoverTrigger>
<PopoverSurface style={{ width: 200, height: 80, padding: 8 }}>Surface</PopoverSurface>
</Popover>
</div>
);

cy.viewport(1000, 600);
mount(<Fixture />);
cy.get(surfaceSelector).should('be.visible');
cy.then(() => {
expect(() => positioningRef.current?.updatePosition()).not.to.throw();
});
// Only assert the resolved placement when the browser supports CSS anchor positioning
// (Chromium 125+). Without it the surface stays at its static DOM position and the
// observer correctly reports whatever side it ends up on — not necessarily 'above'.
cy.window().then(win => {
if (win.CSS?.supports?.('anchor-name: --x')) {
cy.get(surfaceSelector).should('have.attr', 'data-placement', 'above');
}
});
});
});
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import type * as React from 'react';
import type { PositioningProps as CanonicalPositioningProps } from '@fluentui/react-positioning';

export type LogicalAlignment = 'start' | 'center' | 'end';

export type PositioningReturn = {
targetRef: React.RefCallback<HTMLElement>;
containerRef: React.RefCallback<HTMLElement>;
};

export type PositioningProps = Pick<
CanonicalPositioningProps,
| 'align'
| 'coverTarget'
| 'fallbackPositions'
| 'matchTargetSize'
| 'offset'
| 'pinned'
| 'position'
| 'positioningRef'
| 'strategy'
| 'target'
>;
Original file line number Diff line number Diff line change
@@ -1,111 +1,73 @@
'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(
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;
}

const win = targetDocument?.defaultView;

if (!win) {
return;
}

const update = () => {
const surfaceRect = containerEl.getBoundingClientRect();
const targetRect = targetEl.getBoundingClientRect();
const position = detectPosition(surfaceRect, targetRect);
const debouncedUpdate = debounce(update);

if (!position) {
return;
}
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;

const align = detectAlign(position, surfaceRect, targetRect);
const next = getPlacementString(position, align);
resizeObserver?.observe(containerEl);
resizeObserver?.observe(targetEl);

if (containerEl.getAttribute('data-placement') !== next) {
containerEl.setAttribute('data-placement', next);
}
};
win.addEventListener('scroll', debouncedUpdate, { capture: true, passive: true });
win.addEventListener('resize', debouncedUpdate);

update();

const ResizeObserverCtor = win.ResizeObserver;
const observer = ResizeObserverCtor ? new ResizeObserverCtor(update) : null;
observer?.observe(containerEl);
observer?.observe(targetEl);
win.addEventListener('scroll', update, true);
win.addEventListener('resize', update);
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>();
Expand Down Expand Up @@ -160,15 +159,35 @@ 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');

act(() => {
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();
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,14 +17,15 @@ 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 {
pinned,
target: customTarget = null,
align: alignInput = ALIGNMENTS.center,
position = POSITIONS.above,
fallbackPositions = [],
fallbackPositions = EMPTY_FALLBACK_POSITIONS,
offset,
coverTarget = false,
strategy = 'absolute',
Expand All @@ -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<HTMLElement | null>(null);
const [containerEl, setContainerEl] = React.useState<HTMLElement | null>(null);
Expand All @@ -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<PositioningImperativeRef, PositioningImperativeRef>(
positioningRef,
() => ({
setTarget: (el: TargetElement | null) => {
setImperativeTarget(resolveElementRef(el));
},
updatePosition: () => undefined,
updatePosition: requestPlacementUpdate,
}),
[],
[requestPlacementUpdate],
);

useIsomorphicLayoutEffect(() => {
Expand Down Expand Up @@ -149,7 +155,5 @@ export function usePositioning(options: PositioningProps): PositioningReturn {
],
);

usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget);

return { targetRef, containerRef };
}
Loading
Loading