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 @@
{

@github-actions github-actions Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Menu Converged - submenuIndicator slotted content 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default - RTL.submenus open.chromium.png 404 Changed
vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default.submenus open.chromium.png 413 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.chromium.png 16 Changed
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 44 Changed
vr-tests-react-components/ProgressBar converged 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 59 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png 158 Changed
vr-tests-react-components/TagPicker 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - High Contrast.chromium.png 1319 Changed
vr-tests-react-components/TagPicker.disabled - RTL.disabled input hover.chromium.png 635 Changed
vr-tests-react-components/TagPicker.disabled.chromium.png 677 Changed

There were 3 duplicate changes discarded. Check the build logs for more information.

"type": "patch",
"comment": "Fixes #35968",
"packageName": "@fluentui/react-positioning",
"email": "paulmardling@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Fixed popup positioning drift when the anchor moves during parent entry animations (for example, TagPicker inside Dialog), by refreshing placement during animation frames.",
"packageName": "@fluentui/react-tag-picker",
"email": "paulmardling@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export type PositioningImperativeRef = {
};

// @public
export interface PositioningProps extends Pick<PositioningOptions, 'align' | 'arrowPadding' | 'autoSize' | 'coverTarget' | 'fallbackPositions' | 'flipBoundary' | 'offset' | 'overflowBoundary' | 'overflowBoundaryPadding' | 'pinned' | 'position' | 'strategy' | 'useTransform' | 'matchTargetSize' | 'onPositioningEnd' | 'disableUpdateOnResize' | 'shiftToCoverTarget'> {
export interface PositioningProps extends Pick<PositioningOptions, 'align' | 'arrowPadding' | 'autoSize' | 'coverTarget' | 'fallbackPositions' | 'flipBoundary' | 'offset' | 'overflowBoundary' | 'overflowBoundaryPadding' | 'pinned' | 'position' | 'strategy' | 'useTransform' | 'matchTargetSize' | 'onPositioningEnd' | 'disableUpdateOnResize' | 'updatePositionOnAnimationFrame' | 'shiftToCoverTarget'> {
positioningRef?: React_2.Ref<PositioningImperativeRef>;
target?: TargetElement | null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,52 @@ describe('createPositionManager', () => {

expect(listener).not.toHaveBeenCalled();
});

it('schedules updatePosition on animation frames when enabled', async () => {
computePositionMock.mockResolvedValue({
x: 10,
y: 20,
placement: 'bottom',
strategy: 'absolute',
middlewareData: mockMiddlewareData,
});

const { container, target } = createTestElements();
const win = container.ownerDocument.defaultView!;
const rafCallbacks: FrameRequestCallback[] = [];

const requestAnimationFrameSpy = jest.spyOn(win, 'requestAnimationFrame').mockImplementation(callback => {
rafCallbacks.push(callback);
return rafCallbacks.length;
});
const cancelAnimationFrameSpy = jest.spyOn(win, 'cancelAnimationFrame').mockImplementation(() => undefined);

const manager = createPositionManager({
container,
target,
arrow: null,
strategy: 'absolute',
middleware: [],
placement: 'bottom',
disableUpdateOnResize: true,
updatePositionOnAnimationFrame: true,
});

await flushMicrotasks();
expect(computePositionMock).toHaveBeenCalledTimes(1);
expect(requestAnimationFrameSpy).toHaveBeenCalled();

const callback = rafCallbacks.shift();
expect(callback).toBeDefined();

callback?.(16);
await flushMicrotasks();
expect(computePositionMock).toHaveBeenCalledTimes(2);

manager.dispose();
expect(cancelAnimationFrameSpy).toHaveBeenCalled();

requestAnimationFrameSpy.mockRestore();
cancelAnimationFrameSpy.mockRestore();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ interface PositionManagerOptions {
* Disables the resize observer that updates position on target or dimension change
*/
disableUpdateOnResize?: boolean;
/**
* Continuously updates position on animation frames while mounted.
*/
updatePositionOnAnimationFrame?: boolean;
}

/**
Expand All @@ -59,6 +63,7 @@ export function createPositionManager(options: PositionManagerOptions): Position
placement,
useTransform = true,
disableUpdateOnResize = false,
updatePositionOnAnimationFrame = false,
} = options;
const targetWindow = container.ownerDocument.defaultView;
if (!target || !container || !targetWindow) {
Expand All @@ -84,6 +89,7 @@ export function createPositionManager(options: PositionManagerOptions): Position
});

let isFirstUpdate = true;
let animationFrameId: number | undefined;
const scrollParents: Set<HTMLElement> = new Set<HTMLElement>();

// When the container is first resolved, set position `fixed` to avoid scroll jumps.
Expand Down Expand Up @@ -163,6 +169,17 @@ export function createPositionManager(options: PositionManagerOptions): Position

const updatePosition = debounce(() => forceUpdate());

const scheduleAnimationFrameUpdate = () => {
if (!targetWindow || !updatePositionOnAnimationFrame || isDestroyed) {
return;
}

animationFrameId = targetWindow.requestAnimationFrame(() => {
updatePosition();
scheduleAnimationFrameUpdate();
});
};

const dispose = () => {
isDestroyed = true;

Expand All @@ -176,6 +193,11 @@ export function createPositionManager(options: PositionManagerOptions): Position
});
scrollParents.clear();

if (targetWindow && animationFrameId !== undefined) {
targetWindow.cancelAnimationFrame(animationFrameId);
animationFrameId = undefined;
}

resizeObserver?.disconnect();
};

Expand All @@ -186,6 +208,7 @@ export function createPositionManager(options: PositionManagerOptions): Position

// Update the position on initialization
updatePosition();
scheduleAnimationFrameUpdate();

return {
updatePosition,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,14 @@ export interface PositioningOptions {
*/
disableUpdateOnResize?: boolean;

/**
* Continuously updates position on animation frames while the positioned element is mounted.
* Useful when the target can move due to ancestor animations that do not trigger scroll or resize observers.
*
* @default false
*/
updatePositionOnAnimationFrame?: boolean;

/**
* When true, the positioned element will shift to cover the target element when there's not enough space.
* @default false
Expand Down Expand Up @@ -275,6 +283,7 @@ export interface PositioningProps
| 'matchTargetSize'
| 'onPositioningEnd'
| 'disableUpdateOnResize'
| 'updatePositionOnAnimationFrame'
| 'shiftToCoverTarget'
> {
/** An imperative handle to Popper methods. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function usePositioning(options: PositioningProps & PositioningOptions):
const containerRef = React.useRef<HTMLElement | null>(null);
const arrowRef = React.useRef<HTMLElement | null>(null);

const { enabled = true } = options;
const { enabled = true, updatePositionOnAnimationFrame = false } = options;
const resolvePositioningOptions = usePositioningOptions(options);
const updatePositionManager = React.useCallback(() => {
if (managerRef.current) {
Expand All @@ -41,10 +41,11 @@ export function usePositioning(options: PositioningProps & PositioningOptions):
container: containerRef.current,
target,
arrow: arrowRef.current,
updatePositionOnAnimationFrame,
...resolvePositioningOptions(containerRef.current, arrowRef.current),
});
}
}, [enabled, resolvePositioningOptions]);
}, [enabled, resolvePositioningOptions, updatePositionOnAnimationFrame]);

const setOverrideTarget = useEventCallback((target: TargetElement | null) => {
overrideTargetRef.current = target;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { TagPickerList } from '../TagPickerList/TagPickerList';
import { TagPickerOption } from '../TagPickerOption/TagPickerOption';
import { Avatar } from '@fluentui/react-avatar';
import { Button } from '@fluentui/react-button';
import { Dialog, DialogActions, DialogBody, DialogSurface, type DialogProps } from '@fluentui/react-dialog';

import 'cypress-real-events';
import { tagPickerControlClassNames } from '../TagPickerControl/useTagPickerControlStyles.styles';
Expand Down Expand Up @@ -110,6 +111,127 @@ const TagPickerControlled = ({
);
};

const TagPickerInAnimatedDialog = () => {
const [dialogOpen, setDialogOpen] = React.useState(false);
const [selectedOptions, setSelectedOptions] = React.useState<string[]>([]);
const [loadedOptions, setLoadedOptions] = React.useState<string[]>([]);
const hasLoadedOnceRef = React.useRef(false);
const animatedHostRef = React.useRef<HTMLDivElement>(null);

React.useEffect(() => {
if (!dialogOpen) {
return;
}

if (animatedHostRef.current) {
animatedHostRef.current.dataset.animating = 'true';
}

const animation = animatedHostRef.current?.animate(
[{ transform: 'translate3d(40px, 21px, 0)' }, { transform: 'translate3d(0, 0, 0)' }],
{ duration: 50, easing: 'ease-out', fill: 'forwards' },
);

if (animation) {
animation.onfinish = () => {
if (animatedHostRef.current) {
animatedHostRef.current.dataset.animating = 'false';
}
};
}

return () => animation?.cancel();
}, [dialogOpen]);

React.useEffect(() => {
if (!dialogOpen) {
return;
}

if (hasLoadedOnceRef.current) {
setLoadedOptions(options);
return;
}

setLoadedOptions([]);
const timeoutId = setTimeout(() => {
hasLoadedOnceRef.current = true;
setLoadedOptions(options);
}, 50);

return () => clearTimeout(timeoutId);
}, [dialogOpen]);

const onOptionSelect: TagPickerProps['onOptionSelect'] = (_, data) => {
if (data.value === 'no-options') {
return;
}

setSelectedOptions(data.selectedOptions);
};

const onOpenChange: DialogProps['onOpenChange'] = (_, data) => setDialogOpen(data.open);
const tagPickerOptions = loadedOptions.filter(option => !selectedOptions.includes(option));

return (
<>
<Button data-testid="open-dialog" onClick={() => setDialogOpen(true)}>
Open dialog
</Button>
<Button data-testid="close-dialog" onClick={() => setDialogOpen(false)}>
Close dialog
</Button>

<Dialog open={dialogOpen} onOpenChange={onOpenChange}>
<DialogSurface>
<DialogBody data-testid="dialog-body">
<div data-testid="dialog-tag-picker-host" ref={animatedHostRef}>
<TagPicker
onOptionSelect={onOptionSelect}
selectedOptions={selectedOptions}
open={tagPickerOptions.length > 0}
>
<TagPickerControl data-testid="dialog-tag-picker-control">
<TagPickerGroup>
{selectedOptions.map(option => (
<Tag
key={option}
shape="rounded"
media={<Avatar name={option} color="colorful" />}
value={option}
>
{option}
</Tag>
))}
</TagPickerGroup>
<TagPickerInput aria-label="Select Employees" />
</TagPickerControl>
<TagPickerList data-testid="dialog-tag-picker-list">
{tagPickerOptions.length > 0 ? (
tagPickerOptions.map(option => (
<TagPickerOption media={<Avatar name={option} color="colorful" />} value={option} key={option}>
{option}
</TagPickerOption>
))
) : (
<TagPickerOption value="no-options">No options available</TagPickerOption>
)}
</TagPickerList>
</TagPicker>
</div>

<DialogActions>
<Button appearance="secondary" data-testid="dialog-internal-close" onClick={() => setDialogOpen(false)}>
Close
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
</>
);
};

describe('TagPicker', () => {
it('should render a closed listbox', () => {
mount(<TagPickerControlled />);
Expand Down Expand Up @@ -443,4 +565,35 @@ describe('TagPicker', () => {
cy.get('[data-testid="tag-picker-input"]').realClick();
cy.get('[data-testid="tag-picker-list"]').should('not.exist');
});

describe('Dialog integration', () => {
it('keeps the dropdown aligned with the control when reopened during the dialog entry animation', () => {
cy.viewport(1024, 900);
mount(<TagPickerInAnimatedDialog />);

const assertDropdownAlignedToControl = () => {
cy.get('[data-testid="dialog-tag-picker-control"]').should('be.visible');
cy.get('[data-testid="dialog-tag-picker-host"]').should('have.attr', 'data-animating', 'false');
cy.get('[data-testid="dialog-tag-picker-list"]', { timeout: 200 })
.should('be.visible')
.should($list => {
const controlRect = Cypress.$('[data-testid="dialog-tag-picker-control"]')[0].getBoundingClientRect();
const listRect = $list[0].getBoundingClientRect();

expect(listRect.width, 'dropdown width matches control width').to.be.closeTo(controlRect.width, 4);
expect(listRect.left, 'dropdown left aligns with control left').to.be.closeTo(controlRect.left, 4);
expect(listRect.top, 'dropdown sits directly below control').to.be.closeTo(controlRect.bottom, 6);
});
};

cy.get('[data-testid="open-dialog"]').realClick();
assertDropdownAlignedToControl();

cy.get('[data-testid="close-dialog"]').realClick();
cy.get('[data-testid="dialog-tag-picker-control"]').should('not.exist');

cy.get('[data-testid="open-dialog"]').realClick();
assertDropdownAlignedToControl();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const useTagPicker_unstable = (props: TagPickerProps): TagPickerState =>
offset: { crossAxis: 0, mainAxis: 2 },
fallbackPositions,
matchTargetSize: 'width' as const,
updatePositionOnAnimationFrame: true,
...resolvePositioningShorthand(positioning),
});

Expand Down
Loading
Loading