diff --git a/change/@fluentui-react-positioning-9ba5afb4-921b-4a5f-bc1d-4ba812a14719.json b/change/@fluentui-react-positioning-9ba5afb4-921b-4a5f-bc1d-4ba812a14719.json new file mode 100644 index 00000000000000..70bcf63ff2e98c --- /dev/null +++ b/change/@fluentui-react-positioning-9ba5afb4-921b-4a5f-bc1d-4ba812a14719.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Fixes #35968", + "packageName": "@fluentui/react-positioning", + "email": "paulmardling@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-tag-picker-910df714-82d7-4dda-8bf8-2dfd9a9815d9.json b/change/@fluentui-react-tag-picker-910df714-82d7-4dda-8bf8-2dfd9a9815d9.json new file mode 100644 index 00000000000000..83834889909651 --- /dev/null +++ b/change/@fluentui-react-tag-picker-910df714-82d7-4dda-8bf8-2dfd9a9815d9.json @@ -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" +} diff --git a/packages/react-components/react-positioning/library/etc/react-positioning.api.md b/packages/react-components/react-positioning/library/etc/react-positioning.api.md index 9a2e65951c1e13..06d74f3c996cd6 100644 --- a/packages/react-components/react-positioning/library/etc/react-positioning.api.md +++ b/packages/react-components/react-positioning/library/etc/react-positioning.api.md @@ -96,7 +96,7 @@ export type PositioningImperativeRef = { }; // @public -export interface PositioningProps extends Pick { +export interface PositioningProps extends Pick { positioningRef?: React_2.Ref; target?: TargetElement | null; } diff --git a/packages/react-components/react-positioning/library/src/createPositionManager.test.ts b/packages/react-components/react-positioning/library/src/createPositionManager.test.ts index 943c592c0d8a1f..70ce690ed2d69c 100644 --- a/packages/react-components/react-positioning/library/src/createPositionManager.test.ts +++ b/packages/react-components/react-positioning/library/src/createPositionManager.test.ts @@ -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(); + }); }); diff --git a/packages/react-components/react-positioning/library/src/createPositionManager.ts b/packages/react-components/react-positioning/library/src/createPositionManager.ts index 3e5136cecf8312..d5d0fa2d7769b8 100644 --- a/packages/react-components/react-positioning/library/src/createPositionManager.ts +++ b/packages/react-components/react-positioning/library/src/createPositionManager.ts @@ -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; } /** @@ -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) { @@ -84,6 +89,7 @@ export function createPositionManager(options: PositionManagerOptions): Position }); let isFirstUpdate = true; + let animationFrameId: number | undefined; const scrollParents: Set = new Set(); // When the container is first resolved, set position `fixed` to avoid scroll jumps. @@ -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; @@ -176,6 +193,11 @@ export function createPositionManager(options: PositionManagerOptions): Position }); scrollParents.clear(); + if (targetWindow && animationFrameId !== undefined) { + targetWindow.cancelAnimationFrame(animationFrameId); + animationFrameId = undefined; + } + resizeObserver?.disconnect(); }; @@ -186,6 +208,7 @@ export function createPositionManager(options: PositionManagerOptions): Position // Update the position on initialization updatePosition(); + scheduleAnimationFrameUpdate(); return { updatePosition, diff --git a/packages/react-components/react-positioning/library/src/types.ts b/packages/react-components/react-positioning/library/src/types.ts index a0e81a2370e622..573c5106ae8fdb 100644 --- a/packages/react-components/react-positioning/library/src/types.ts +++ b/packages/react-components/react-positioning/library/src/types.ts @@ -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 @@ -275,6 +283,7 @@ export interface PositioningProps | 'matchTargetSize' | 'onPositioningEnd' | 'disableUpdateOnResize' + | 'updatePositionOnAnimationFrame' | 'shiftToCoverTarget' > { /** An imperative handle to Popper methods. */ diff --git a/packages/react-components/react-positioning/library/src/usePositioning.ts b/packages/react-components/react-positioning/library/src/usePositioning.ts index 0eeeda8f41a34f..bde3dce56773ad 100644 --- a/packages/react-components/react-positioning/library/src/usePositioning.ts +++ b/packages/react-components/react-positioning/library/src/usePositioning.ts @@ -26,7 +26,7 @@ export function usePositioning(options: PositioningProps & PositioningOptions): const containerRef = React.useRef(null); const arrowRef = React.useRef(null); - const { enabled = true } = options; + const { enabled = true, updatePositionOnAnimationFrame = false } = options; const resolvePositioningOptions = usePositioningOptions(options); const updatePositionManager = React.useCallback(() => { if (managerRef.current) { @@ -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; diff --git a/packages/react-components/react-tag-picker/library/src/components/TagPicker/TagPicker.cy.tsx b/packages/react-components/react-tag-picker/library/src/components/TagPicker/TagPicker.cy.tsx index 1306696f3c2ac3..ee9fa17a31c3f2 100644 --- a/packages/react-components/react-tag-picker/library/src/components/TagPicker/TagPicker.cy.tsx +++ b/packages/react-components/react-tag-picker/library/src/components/TagPicker/TagPicker.cy.tsx @@ -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'; @@ -110,6 +111,127 @@ const TagPickerControlled = ({ ); }; +const TagPickerInAnimatedDialog = () => { + const [dialogOpen, setDialogOpen] = React.useState(false); + const [selectedOptions, setSelectedOptions] = React.useState([]); + const [loadedOptions, setLoadedOptions] = React.useState([]); + const hasLoadedOnceRef = React.useRef(false); + const animatedHostRef = React.useRef(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 ( + <> + + + + + + +
+ 0} + > + + + {selectedOptions.map(option => ( + } + value={option} + > + {option} + + ))} + + + + + {tagPickerOptions.length > 0 ? ( + tagPickerOptions.map(option => ( + } value={option} key={option}> + {option} + + )) + ) : ( + No options available + )} + + +
+ + + + +
+
+
+ + ); +}; + describe('TagPicker', () => { it('should render a closed listbox', () => { mount(); @@ -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(); + + 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(); + }); + }); }); diff --git a/packages/react-components/react-tag-picker/library/src/components/TagPicker/useTagPicker.ts b/packages/react-components/react-tag-picker/library/src/components/TagPicker/useTagPicker.ts index 6c47cbb52f7cb4..c34b4898a46e64 100644 --- a/packages/react-components/react-tag-picker/library/src/components/TagPicker/useTagPicker.ts +++ b/packages/react-components/react-tag-picker/library/src/components/TagPicker/useTagPicker.ts @@ -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), }); diff --git a/packages/react-components/react-tag-picker/stories/src/TagPicker/TagPickerDialogRepositioning.stories.tsx b/packages/react-components/react-tag-picker/stories/src/TagPicker/TagPickerDialogRepositioning.stories.tsx new file mode 100644 index 00000000000000..5b6cec96bf250c --- /dev/null +++ b/packages/react-components/react-tag-picker/stories/src/TagPicker/TagPickerDialogRepositioning.stories.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; +import type { JSXElement, TagPickerProps } from '@fluentui/react-components'; +import { + Dialog, + DialogTrigger, + DialogSurface, + DialogTitle, + DialogBody, + DialogContent, + Button, + TagPicker, + TagPickerList, + TagPickerInput, + TagPickerControl, + TagPickerOption, + TagPickerGroup, + Tag, + Avatar, + Field, + DialogActions, +} from '@fluentui/react-components'; + +const options = [ + 'John Doe', + 'Jane Doe', + 'Max Mustermann', + 'Erika Mustermann', + 'Pierre Dupont', + 'Amelie Dupont', + 'Mario Rossi', + 'Maria Rossi', +]; + +export const DialogWithTagPicker = (): JSXElement => { + const [selectedOptions, setSelectedOptions] = React.useState([]); + + const onOptionSelect: TagPickerProps['onOptionSelect'] = (e, data) => { + if (data.value === 'no-options') { + return; + } + + setSelectedOptions(data.selectedOptions); + }; + + const tagPickerOptions = options.filter(option => !selectedOptions.includes(option)); + + return ( + + + + + + + Select People + + + 0} + > + + + {selectedOptions.map(option => ( + } + value={option} + > + {option} + + ))} + + + + + + {tagPickerOptions.length > 0 ? ( + tagPickerOptions.map(option => ( + } + value={option} + key={option} + > + {option} + + )) + ) : ( + No options available + )} + + + + + + + + + + + + + + ); +}; diff --git a/packages/react-components/react-tag-picker/stories/src/TagPicker/index.stories.tsx b/packages/react-components/react-tag-picker/stories/src/TagPicker/index.stories.tsx index 32a0f841262503..6263eb3dfe07f9 100644 --- a/packages/react-components/react-tag-picker/stories/src/TagPicker/index.stories.tsx +++ b/packages/react-components/react-tag-picker/stories/src/TagPicker/index.stories.tsx @@ -25,6 +25,7 @@ export { TruncatedText } from './TagPickerTruncatedText.stories'; export { SingleSelect } from './TagPickerSingleSelect.stories'; export { NoPopover } from './TagPickerNoPopover.stories'; export { SingleLine } from './TagPickerSingleLine.stories'; +export { DialogWithTagPicker } from './TagPickerDialogRepositioning.stories'; export default { title: 'Components/TagPicker',