Skip to content
Merged
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": "minor",
"comment": "feat: Update position when target or container dimensions change",
"packageName": "@fluentui/react-positioning",
"email": "lingfangao@hotmail.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export const MatchTargetSize = () => {
</Button>
</PopoverTrigger>

<PopoverSurface>This popover has the same width as its target anchor</PopoverSurface>
<PopoverSurface style={{ boxSizing: 'border-box' }}>
This popover has the same width as its target anchor
</PopoverSurface>
</Popover>
);
};
Expand All @@ -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'),
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PositioningImperativeRef>(null);
const timeoutRef = React.useRef(0);

const onOpenChange = React.useCallback<NonNullable<PopoverProps['onOpenChange']>>((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 (
<Popover positioning={{ position: 'below', positioningRef }} onOpenChange={onOpenChange}>
<PopoverTrigger disableButtonEnhancement>
<Button appearance="primary">Click me</Button>
</PopoverTrigger>
<div className={styles.container}>
<Field label="Move the button with the slider">
<Slider className={styles.slider} value={value} onChange={onChange} max={80} />
</Field>
<Popover positioning={{ position: 'below', positioningRef }} open>
<PopoverTrigger disableButtonEnhancement>
<Button style={{ left: `${value}%` }} className={styles.button} appearance="primary">
Popover
</Button>
</PopoverTrigger>

<PopoverSurface style={{ minWidth: 100 }}>{loading ? 'Loading 1 second...' : <Placeholder />}</PopoverSurface>
</Popover>
<PopoverSurface style={{ minWidth: 100 }}>Target</PopoverSurface>
</Popover>
</div>
);
};

Expand All @@ -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 = () => (
<div>
<h4>Dynamic content</h4>

<img src="https://fabricweb.azureedge.net/fabric-website/placeholders/400x400.png" />
</div>
);
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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<HTMLElement> = new Set<HTMLElement>();
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
Expand All @@ -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;
}

Expand Down Expand Up @@ -129,6 +138,8 @@ export function createPositionManager(options: PositionManagerOptions): Position
scrollParent.removeEventListener('scroll', updatePosition);
});
scrollParents.clear();

resizeObserver.disconnect();
};

if (targetWindow) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}