From 26ed1a4e1ba14e3fec5d652b4c4e5c010eb76575 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Fri, 29 Dec 2023 10:21:41 +0100 Subject: [PATCH 1/7] feat: Update position when target or container dimensions change Uses a ResizeObserver in the position manager so that a position update is triggered when the dimension of the target or container changes. This should handle most async update scenarios without needing to call `updatePosition` in userland. Fixes # --- .../stories/Menu/MenuDefault.stories.tsx | 40 ++++++++++++------- .../src/createPositionManager.ts | 12 +++++- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/packages/react-components/react-menu/stories/Menu/MenuDefault.stories.tsx b/packages/react-components/react-menu/stories/Menu/MenuDefault.stories.tsx index bb6837eade01a1..178d006d652171 100644 --- a/packages/react-components/react-menu/stories/Menu/MenuDefault.stories.tsx +++ b/packages/react-components/react-menu/stories/Menu/MenuDefault.stories.tsx @@ -2,19 +2,29 @@ import * as React from 'react'; import { Button, Menu, MenuTrigger, MenuList, MenuItem, MenuPopover } from '@fluentui/react-components'; -export const Default = () => ( - - - - +export const Default = () => { + const [wide, setWide] = React.useState(false); + React.useEffect(() => { + setTimeout(() => setWide(true), 3000); + }, []); - - - New - New Window - Open File - Open Folder - - - -); + return ( + <> +
+ + + + + + + + New + New Window + Open File + Open Folder + + + + + ); +}; diff --git a/packages/react-components/react-positioning/src/createPositionManager.ts b/packages/react-components/react-positioning/src/createPositionManager.ts index 0b7a354162874f..52c9982e807c2c 100644 --- a/packages/react-components/react-positioning/src/createPositionManager.ts +++ b/packages/react-components/react-positioning/src/createPositionManager.ts @@ -43,8 +43,8 @@ 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; + const { container, target, arrow, strategy, middleware, placement, useTransform = true } = options; if (!target || !container) { return { updatePosition: () => undefined, @@ -52,6 +52,9 @@ export function createPositionManager(options: PositionManagerOptions): Position }; } + // When the dimensions of the target or the container change - trigger a position update + const resizeObserver = new ResizeObserver(() => updatePosition()); + let isFirstUpdate = true; const scrollParents: Set = new Set(); const targetWindow = container.ownerDocument.defaultView; @@ -77,6 +80,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 +137,8 @@ export function createPositionManager(options: PositionManagerOptions): Position scrollParent.removeEventListener('scroll', updatePosition); }); scrollParents.clear(); + + resizeObserver.disconnect(); }; if (targetWindow) { From e9b5b29ef07b9f50bd590b8b7e933c9ee690042d Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Fri, 29 Dec 2023 10:24:10 +0100 Subject: [PATCH 2/7] changefile --- ...t-positioning-29f6eb38-4af2-40f1-a095-6abef76d64f4.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-react-positioning-29f6eb38-4af2-40f1-a095-6abef76d64f4.json 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" +} From 8ed3df324d12337cde81fdd3af029197cfa7235a Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Fri, 29 Dec 2023 10:28:30 +0100 Subject: [PATCH 3/7] revert story --- .../stories/Menu/MenuDefault.stories.tsx | 40 +++++++------------ 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/packages/react-components/react-menu/stories/Menu/MenuDefault.stories.tsx b/packages/react-components/react-menu/stories/Menu/MenuDefault.stories.tsx index 178d006d652171..bb6837eade01a1 100644 --- a/packages/react-components/react-menu/stories/Menu/MenuDefault.stories.tsx +++ b/packages/react-components/react-menu/stories/Menu/MenuDefault.stories.tsx @@ -2,29 +2,19 @@ import * as React from 'react'; import { Button, Menu, MenuTrigger, MenuList, MenuItem, MenuPopover } from '@fluentui/react-components'; -export const Default = () => { - const [wide, setWide] = React.useState(false); - React.useEffect(() => { - setTimeout(() => setWide(true), 3000); - }, []); +export const Default = () => ( + + + + - return ( - <> -
- - - - - - - - New - New Window - Open File - Open Folder - - - - - ); -}; + + + New + New Window + Open File + Open Folder + + +
+); From 4ad847cf26d101c0cbee0bd10a2374d8fb782c2e Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Fri, 29 Dec 2023 13:42:47 +0100 Subject: [PATCH 4/7] update docs --- ...ioningImperativePositionUpdate.stories.tsx | 78 ++++++++++--------- 1 file changed, 42 insertions(+), 36 deletions(-) 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

- - -
-); From 33251d3ff57528bf790babc8d48da72798d24a9f Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Fri, 29 Dec 2023 13:44:30 +0100 Subject: [PATCH 5/7] match target size --- .../stories/Concepts/Positioning/MatchTargetSize.stories.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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..bd9d78f5cac009 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 +
); }; From 8b48a0250f68881feb62a94c357b6a4eef0abbe7 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Fri, 29 Dec 2023 13:45:24 +0100 Subject: [PATCH 6/7] match target size --- .../stories/Concepts/Positioning/MatchTargetSize.stories.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 bd9d78f5cac009..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 @@ -31,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'), }, }, From 489bdc766f13b02340552931f13310335a519100 Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Fri, 5 Jan 2024 16:29:40 +0000 Subject: [PATCH 7/7] fix missing ResizeObserver in unit tests --- .../src/createPositionManager.ts | 7 ++++--- .../src/utils/createResizeObserver.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 packages/react-components/react-positioning/src/utils/createResizeObserver.ts diff --git a/packages/react-components/react-positioning/src/createPositionManager.ts b/packages/react-components/react-positioning/src/createPositionManager.ts index 52c9982e807c2c..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 { /** @@ -45,7 +46,8 @@ interface PositionManagerOptions { export function createPositionManager(options: PositionManagerOptions): PositionManager { let isDestroyed = false; const { container, target, arrow, strategy, middleware, placement, useTransform = true } = options; - if (!target || !container) { + const targetWindow = container.ownerDocument.defaultView; + if (!target || !container || !targetWindow) { return { updatePosition: () => undefined, dispose: () => undefined, @@ -53,11 +55,10 @@ export function createPositionManager(options: PositionManagerOptions): Position } // When the dimensions of the target or the container change - trigger a position update - const resizeObserver = new ResizeObserver(() => updatePosition()); + 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 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); +}