diff --git a/apps/vr-tests-react-components/src/stories/Positioning.stories.tsx b/apps/vr-tests-react-components/src/stories/Positioning.stories.tsx index a6f64da5c376d..a4de2da837829 100644 --- a/apps/vr-tests-react-components/src/stories/Positioning.stories.tsx +++ b/apps/vr-tests-react-components/src/stories/Positioning.stories.tsx @@ -1159,6 +1159,29 @@ const MatchTargetSize = () => { ); }; +const PositioningEndEvent = () => { + const positioningRef = React.useRef(null); + const [count, setCount] = React.useState(0); + const { targetRef, containerRef } = usePositioning({ + onPositioningEnd: () => setCount(s => s + 1), + positioningRef, + }); + + return ( + <> + +
+ positioning count: {count} +
+ + ); +}; + storiesOf('Positioning', module) .addDecorator(story => (
)) - .addStory('Match target size', () => ); + .addStory('Match target size', () => ) + .addStory('Positioning end', () => ( + + + + )); storiesOf('Positioning (no decorator)', module) .addStory('scroll jumps', () => ( diff --git a/change/@fluentui-react-positioning-d30b18b7-e743-42d7-b3bc-0b1ff22655fd.json b/change/@fluentui-react-positioning-d30b18b7-e743-42d7-b3bc-0b1ff22655fd.json new file mode 100644 index 0000000000000..a0ac2ea6ae9e4 --- /dev/null +++ b/change/@fluentui-react-positioning-d30b18b7-e743-42d7-b3bc-0b1ff22655fd.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: Implement onPositioningEnd callback", + "packageName": "@fluentui/react-positioning", + "email": "lingfangao@hotmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-components/stories/Concepts/Positioning/PositioningListenToUpdates.stories.tsx b/packages/react-components/react-components/stories/Concepts/Positioning/PositioningListenToUpdates.stories.tsx new file mode 100644 index 0000000000000..53522edcf3fae --- /dev/null +++ b/packages/react-components/react-components/stories/Concepts/Positioning/PositioningListenToUpdates.stories.tsx @@ -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([]); + const positioningRef = React.useRef(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 ( +
+
+ + + + + + + + +
+
+
+ Status log +
+
+ {statusLog.map((time, i) => { + const date = new Date(time); + return ( +
+ {date.toLocaleTimeString()} Position updated [{i}] +
+ ); + })} +
+
+
+ ); +}; + +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'), + }, + }, +}; diff --git a/packages/react-components/react-components/stories/Concepts/Positioning/index.stories.tsx b/packages/react-components/react-components/stories/Concepts/Positioning/index.stories.tsx index 2f79d0c5896d7..14f39e6c0d8b2 100644 --- a/packages/react-components/react-components/stories/Concepts/Positioning/index.stories.tsx +++ b/packages/react-components/react-components/stories/Concepts/Positioning/index.stories.tsx @@ -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', diff --git a/packages/react-components/react-positioning/etc/react-positioning.api.md b/packages/react-components/react-positioning/etc/react-positioning.api.md index 28dbce76f0926..5a1568d4f7d83 100644 --- a/packages/react-components/react-positioning/etc/react-positioning.api.md +++ b/packages/react-components/react-positioning/etc/react-positioning.api.md @@ -75,7 +75,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/src/constants.ts b/packages/react-components/react-positioning/src/constants.ts index 9766a880b866f..e6fd421d130c8 100644 --- a/packages/react-components/react-positioning/src/constants.ts +++ b/packages/react-components/react-positioning/src/constants.ts @@ -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'; diff --git a/packages/react-components/react-positioning/src/createPositionManager.ts b/packages/react-components/react-positioning/src/createPositionManager.ts index 24978fcd2cafd..4726ec1a778ce 100644 --- a/packages/react-components/react-positioning/src/createPositionManager.ts +++ b/packages/react-components/react-positioning/src/createPositionManager.ts @@ -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 { @@ -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 diff --git a/packages/react-components/react-positioning/src/types.ts b/packages/react-components/react-positioning/src/types.ts index 3d29b8f7ddd9a..5db505dda6770 100644 --- a/packages/react-components/react-positioning/src/types.ts +++ b/packages/react-components/react-positioning/src/types.ts @@ -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; } /** @@ -205,6 +213,7 @@ export interface PositioningProps | 'strategy' | 'useTransform' | 'matchTargetSize' + | 'onPositioningEnd' > { /** An imperative handle to Popper methods. */ positioningRef?: React.Ref; diff --git a/packages/react-components/react-positioning/src/usePositioning.ts b/packages/react-components/react-positioning/src/usePositioning.ts index 40d8aa1a1849f..299fb2cd44e36 100644 --- a/packages/react-components/react-positioning/src/usePositioning.ts +++ b/packages/react-components/react-positioning/src/usePositioning.ts @@ -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 @@ -135,8 +136,11 @@ export function usePositioning(options: PositioningProps & PositioningOptions): } }); + const onPositioningEnd = useEventCallback(() => options.onPositioningEnd?.()); const setContainer = useCallbackRef(null, container => { if (containerRef.current !== container) { + containerRef.current?.removeEventListener(POSITIONING_END_EVENT, onPositioningEnd); + container?.addEventListener(POSITIONING_END_EVENT, onPositioningEnd); containerRef.current = container; updatePositionManager(); }