diff --git a/change/@fluentui-react-positioning-29f6eb38-4af2-40f1-a095-6abef76d64f4.json b/change/@fluentui-react-positioning-29f6eb38-4af2-40f1-a095-6abef76d64f4.json new file mode 100644 index 00000000000000..e55120c9ff66f8 --- /dev/null +++ b/change/@fluentui-react-positioning-29f6eb38-4af2-40f1-a095-6abef76d64f4.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: Update position when target or container dimensions change", + "packageName": "@fluentui/react-positioning", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-components/stories/Concepts/Positioning/MatchTargetSize.stories.tsx b/packages/react-components/react-components/stories/Concepts/Positioning/MatchTargetSize.stories.tsx index 6bb70962ce4666..52eb42e58c95d0 100644 --- a/packages/react-components/react-components/stories/Concepts/Positioning/MatchTargetSize.stories.tsx +++ b/packages/react-components/react-components/stories/Concepts/Positioning/MatchTargetSize.stories.tsx @@ -17,7 +17,9 @@ export const MatchTargetSize = () => { - This popover has the same width as its target anchor + + This popover has the same width as its target anchor + ); }; @@ -29,6 +31,8 @@ MatchTargetSize.parameters = { 'The `matchTargetSize` option will automatically style the positioned element so that the chosen dimension', 'matches that of the target element. This can be useful for autocomplete or combobox input fields where the', 'popover should match the width of the text input field.', + '', + '> ⚠️ Make sure that the positioned element use `box-sizing: border-box`', ].join('\n'), }, }, diff --git a/packages/react-components/react-components/stories/Concepts/Positioning/PositioningImperativePositionUpdate.stories.tsx b/packages/react-components/react-components/stories/Concepts/Positioning/PositioningImperativePositionUpdate.stories.tsx index 7e2c255967bc64..8054d61c8e6c50 100644 --- a/packages/react-components/react-components/stories/Concepts/Positioning/PositioningImperativePositionUpdate.stories.tsx +++ b/packages/react-components/react-components/stories/Concepts/Positioning/PositioningImperativePositionUpdate.stories.tsx @@ -1,39 +1,49 @@ import * as React from 'react'; -import { Button, Popover, PopoverSurface, PopoverTrigger } from '@fluentui/react-components'; -import type { PopoverProps, PositioningImperativeRef } from '@fluentui/react-components'; +import { Button, Popover, PopoverSurface, PopoverTrigger, Slider, Field, makeStyles } from '@fluentui/react-components'; +import type { PositioningImperativeRef, SliderProps } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + container: { + position: 'relative', + }, + + button: { + position: 'absolute', + }, + + slider: { + marginBottom: '10px', + }, +}); export const ImperativePositionUpdate = () => { - const [loading, setLoading] = React.useState(true); + const styles = useStyles(); const positioningRef = React.useRef(null); - const timeoutRef = React.useRef(0); - - const onOpenChange = React.useCallback>((e, data) => { - if (!data.open) { - setLoading(true); - } else { - clearTimeout(timeoutRef.current); - timeoutRef.current = window.setTimeout(() => setLoading(false), 1000); - } - }, []); + const [value, setValue] = React.useState(0); - React.useEffect(() => { - if (!loading) { - positioningRef.current?.updatePosition(); - } - }, [loading]); + const onChange: SliderProps['onChange'] = React.useCallback((e, data) => { + setValue(data.value); + }, []); React.useEffect(() => { - return () => clearTimeout(timeoutRef.current); - }); + positioningRef.current?.updatePosition(); + }, [value]); return ( - - - - +
+ + + + + + + - {loading ? 'Loading 1 second...' : } - + Target + +
); }; @@ -43,18 +53,14 @@ ImperativePositionUpdate.parameters = { description: { story: [ 'The `positioningRef` positioning prop provides an [imperative handle](https://reactjs.org/docs/hooks-reference.html#useimperativehandle)', - 'to reposition the positioned element. This can be useful for scenarios where content is dynamically loaded.', + 'to reposition the positioned element.', + 'In this example the `updatePosition` command is used to reposition the popover when its target button is', + 'dynamically moved.', '', - 'In this example, you can move your mouse in the red boundary and the tooltip will follow the mouse cursor', + '> ⚠️ In later versions of Fluent UI, position updates are triggered once the target or container dimensions', + 'change. This was previously the main use case for imperative position updates. Please think carefully', + 'if your scenario needs this pattern in the future.', ].join('\n'), }, }, }; - -const Placeholder = () => ( -
-

Dynamic content

- - -
-); diff --git a/packages/react-components/react-positioning/src/createPositionManager.ts b/packages/react-components/react-positioning/src/createPositionManager.ts index 0b7a354162874f..24978fcd2cafdf 100644 --- a/packages/react-components/react-positioning/src/createPositionManager.ts +++ b/packages/react-components/react-positioning/src/createPositionManager.ts @@ -4,6 +4,7 @@ import type { PositionManager, TargetElement } from './types'; import { debounce, writeArrowUpdates, writeContainerUpdates } from './utils'; import { isHTMLElement } from '@fluentui/react-utilities'; import { listScrollParents } from './utils/listScrollParents'; +import { createResizeObserver } from './utils/createResizeObserver'; interface PositionManagerOptions { /** @@ -43,18 +44,21 @@ interface PositionManagerOptions { * @returns manager that handles positioning out of the react lifecycle */ export function createPositionManager(options: PositionManagerOptions): PositionManager { - const { container, target, arrow, strategy, middleware, placement, useTransform = true } = options; let isDestroyed = false; - if (!target || !container) { + const { container, target, arrow, strategy, middleware, placement, useTransform = true } = options; + const targetWindow = container.ownerDocument.defaultView; + if (!target || !container || !targetWindow) { return { updatePosition: () => undefined, dispose: () => undefined, }; } + // When the dimensions of the target or the container change - trigger a position update + const resizeObserver = createResizeObserver(targetWindow, () => updatePosition()); + let isFirstUpdate = true; const scrollParents: Set = new Set(); - const targetWindow = container.ownerDocument.defaultView; // When the container is first resolved, set position `fixed` to avoid scroll jumps. // Without this scroll jumps can occur when the element is rendered initially and receives focus @@ -77,6 +81,11 @@ export function createPositionManager(options: PositionManagerOptions): Position scrollParent.addEventListener('scroll', updatePosition, { passive: true }); }); + resizeObserver.observe(container); + if (isHTMLElement(target)) { + resizeObserver.observe(target); + } + isFirstUpdate = false; } @@ -129,6 +138,8 @@ export function createPositionManager(options: PositionManagerOptions): Position scrollParent.removeEventListener('scroll', updatePosition); }); scrollParents.clear(); + + resizeObserver.disconnect(); }; if (targetWindow) { diff --git a/packages/react-components/react-positioning/src/utils/createResizeObserver.ts b/packages/react-components/react-positioning/src/utils/createResizeObserver.ts new file mode 100644 index 00000000000000..7c2ac5e90ecbff --- /dev/null +++ b/packages/react-components/react-positioning/src/utils/createResizeObserver.ts @@ -0,0 +1,19 @@ +export function createResizeObserver(targetWindow: Window & typeof globalThis, callback: ResizeObserverCallback) { + // https://github.com/jsdom/jsdom/issues/3368 + // Add the polyfill here so it is not needed for all unit tests that leverage positioning + if (process.env.NODE_ENV === 'test') { + targetWindow.ResizeObserver = class ResizeObserver { + public observe() { + // do nothing + } + public unobserve() { + // do nothing + } + public disconnect() { + // do nothing + } + }; + } + + return new targetWindow.ResizeObserver(callback); +}