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
Expand Up @@ -1159,6 +1159,29 @@ const MatchTargetSize = () => {
);
};

const PositioningEndEvent = () => {
const positioningRef = React.useRef<PositioningImperativeRef>(null);
const [count, setCount] = React.useState(0);
const { targetRef, containerRef } = usePositioning({
onPositioningEnd: () => setCount(s => s + 1),
positioningRef,
});

return (
<>
<button id="target" ref={targetRef} onClick={() => positioningRef.current?.updatePosition()}>
Update position
</button>
<div
ref={containerRef}
style={{ border: '2px solid blue', padding: 20, backgroundColor: 'white', boxSizing: 'border-box' }}
>
positioning count: {count}
</div>
</>
);
};

storiesOf('Positioning', module)
.addDecorator(story => (
<div
Expand Down Expand Up @@ -1242,7 +1265,12 @@ storiesOf('Positioning', module)
<MultiScrollParent />
</StoryWright>
))
.addStory('Match target size', () => <MatchTargetSize />);
.addStory('Match target size', () => <MatchTargetSize />)
.addStory('Positioning end', () => (
<StoryWright steps={new Steps().click('#target').snapshot('updated 2 times').end()}>
<PositioningEndEvent />
</StoryWright>
));

storiesOf('Positioning (no decorator)', module)
.addStory('scroll jumps', () => (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: Implement onPositioningEnd callback",
"packageName": "@fluentui/react-positioning",
"email": "lingfangao@hotmail.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import * as React from 'react';
import {
useId,
Text,
makeStyles,
shorthands,
tokens,
Popover,
Button,
PopoverTrigger,
PopoverSurface,
PositioningImperativeRef,
PopoverProps,
} from '@fluentui/react-components';

const useStyles = makeStyles({
root: {
display: 'flex',
...shorthands.gap('20px'),
},

button: {
display: 'block',
minWidth: '120px',
},

logContainer: {
display: 'flex',
flexDirection: 'column',
},

logLabel: {
color: tokens.colorNeutralForegroundOnBrand,
backgroundColor: tokens.colorBrandBackground,
width: 'fit-content',
fontWeight: tokens.fontWeightBold,
...shorthands.padding('2px', '12px'),
},

log: {
overflowY: 'auto',
boxShadow: tokens.shadow16,
position: 'relative',
minWidth: '200px',
height: '200px',
...shorthands.border('2px', 'solid', tokens.colorBrandBackground),
...shorthands.padding('12px', '12px'),
},
});

export const ListenToUpdates = () => {
const styles = useStyles();
const labelId = useId();
const [statusLog, setStatusLog] = React.useState<number[]>([]);
const positioningRef = React.useRef<PositioningImperativeRef>(null);
const [open, setOpen] = React.useState(false);

const onOpenChange: PopoverProps['onOpenChange'] = React.useCallback((e, data) => {
setOpen(data.open);
if (!data.open) {
setStatusLog([]);
}
}, []);

const updatePosition = React.useCallback(() => {
positioningRef.current?.updatePosition();
}, []);

const onPositioningEnd = React.useCallback(() => {
setStatusLog(s => [Date.now(), ...s]);
}, []);

return (
<div className={styles.root}>
<div>
<Popover
open={open}
onOpenChange={onOpenChange}
positioning={{ positioningRef, onPositioningEnd, position: 'below' }}
>
<PopoverTrigger>
<Button className={styles.button}>Open popover</Button>
</PopoverTrigger>
<PopoverSurface>
<Button className={styles.button} onClick={updatePosition}>
Update position
</Button>
</PopoverSurface>
</Popover>
</div>
<div className={styles.logContainer}>
<div className={styles.logLabel} id={labelId}>
Status log
</div>
<div role="log" aria-labelledby={labelId} className={styles.log}>
{statusLog.map((time, i) => {
const date = new Date(time);
return (
<div key={i}>
{date.toLocaleTimeString()} <Text weight="bold">Position updated [{i}]</Text>
</div>
);
})}
</div>
</div>
</div>
);
};

ListenToUpdates.parameters = {
docs: {
description: {
story: [
'Positioning happens outside of the React render lifecycle for performance purposes so that a position update',
'does not need to:',
'- trigger by a re-render',
'- be dependent on a re-render',
'',
'This constraint makes it difficult to know exactly when an element has been positioned. In order to listen',
'to position updates you can use the `onPositioningEnd` callback.',
'',
'> ⚠️ _Very few use cases would actually require listening to position updates. Please remember that_',
'_there is a difference between this and the **open/close state** which is normally handled in React_',
].join('\n'),
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export { OverflowBoundaryPadding } from './OverflowBoundaryPadding.stories';
export { FlipBoundary } from './PositioningFlipBoundary.stories';
export { MatchTargetSize } from './MatchTargetSize.stories';
export { DisableTransform } from './PositioningDisableTransform.stories';
export { ListenToUpdates } from './PositioningListenToUpdates.stories';

export default {
title: 'Concepts/Developer/Positioning Components',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export type PositioningImperativeRef = {
};

// @public
export interface PositioningProps extends Pick<PositioningOptions, 'align' | 'arrowPadding' | 'autoSize' | 'coverTarget' | 'flipBoundary' | 'offset' | 'overflowBoundary' | 'overflowBoundaryPadding' | 'pinned' | 'position' | 'strategy' | 'useTransform' | 'matchTargetSize'> {
export interface PositioningProps extends Pick<PositioningOptions, 'align' | 'arrowPadding' | 'autoSize' | 'coverTarget' | 'flipBoundary' | 'offset' | 'overflowBoundary' | 'overflowBoundaryPadding' | 'pinned' | 'position' | 'strategy' | 'useTransform' | 'matchTargetSize' | 'onPositioningEnd'> {
positioningRef?: React_2.Ref<PositioningImperativeRef>;
target?: TargetElement | null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export const DATA_POSITIONING_INTERSECTING = 'data-popper-is-intersecting';
export const DATA_POSITIONING_ESCAPED = 'data-popper-escaped';
export const DATA_POSITIONING_HIDDEN = 'data-popper-reference-hidden';
export const DATA_POSITIONING_PLACEMENT = 'data-popper-placement';
export const POSITIONING_END_EVENT = 'fui-positioningend';
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { computePosition } from '@floating-ui/dom';
import type { Middleware, Placement, Strategy } from '@floating-ui/dom';
import { isHTMLElement } from '@fluentui/react-utilities';
import type { PositionManager, TargetElement } from './types';
import { debounce, writeArrowUpdates, writeContainerUpdates } from './utils';
import { isHTMLElement } from '@fluentui/react-utilities';
import { listScrollParents } from './utils/listScrollParents';
import { POSITIONING_END_EVENT } from './constants';
import { createResizeObserver } from './utils/createResizeObserver';

interface PositionManagerOptions {
Expand Down Expand Up @@ -108,6 +109,8 @@ export function createPositionManager(options: PositionManagerOptions): Position
strategy,
useTransform,
});

container.dispatchEvent(new CustomEvent(POSITIONING_END_EVENT));
})
.catch(err => {
// https://github.com/floating-ui/floating-ui/issues/1845
Expand Down
9 changes: 9 additions & 0 deletions packages/react-components/react-positioning/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ export interface PositioningOptions {
* When set, the positioned element matches the chosen dimension(s) of the target element
*/
matchTargetSize?: 'width';

/**
* Called when a position update has finished. Multiple position updates can happen in a single render,
* since positioning happens outside of the React lifecycle.
*
* It's also possible to listen to the custom DOM event `fui-positioningend`
*/
onPositioningEnd?: () => void;
}

/**
Expand All @@ -205,6 +213,7 @@ export interface PositioningProps
| 'strategy'
| 'useTransform'
| 'matchTargetSize'
| 'onPositioningEnd'
> {
/** An imperative handle to Popper methods. */
positioningRef?: React.Ref<PositioningImperativeRef>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { createPositionManager } from './createPositionManager';
import { devtools } from '@floating-ui/devtools';
import { devtoolsCallback } from './utils/devtools';
import { POSITIONING_END_EVENT } from './constants';

/**
* @internal
Expand Down Expand Up @@ -135,8 +136,11 @@ export function usePositioning(options: PositioningProps & PositioningOptions):
}
});

const onPositioningEnd = useEventCallback(() => options.onPositioningEnd?.());
const setContainer = useCallbackRef<HTMLElement | null>(null, container => {
if (containerRef.current !== container) {
containerRef.current?.removeEventListener(POSITIONING_END_EVENT, onPositioningEnd);
container?.addEventListener(POSITIONING_END_EVENT, onPositioningEnd);
containerRef.current = container;
updatePositionManager();
}
Expand Down