Skip to content
Open
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": "feat(Popover): adopt anchored container queries for arrow placement",
"packageName": "@fluentui/react-headless-components-preview",
"email": "vgenaev@gmail.com",
"dependentChangeType": "patch"
}
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,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<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();
});
cy.get(surfaceSelector).should('have.attr', 'data-position', '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,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;
Expand All @@ -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;
}
Loading
Loading