-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat(react-positioning): add placement to onPositioningEnd event #35773
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
robertpenner
merged 7 commits into
microsoft:master
from
robertpenner:feat/positioning-placement
Feb 23, 2026
+341
−6
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
29daea6
feat(react-positioning): add placement to onPositioningEnd event
robertpenner e3db1a1
chore: yarn change
robertpenner 4c4249a
docs(react-positioning): code comment on cast
robertpenner 49dcca1
docs(react-positioning): clarify placement detail in onPositioningEnd…
robertpenner 5bc8fe1
test(react-positioning): add unit tests for onPositioningEnd placemen…
robertpenner d1682ff
chore: address code review
robertpenner 91db5f0
chore: lint
robertpenner File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
7 changes: 7 additions & 0 deletions
7
change/@fluentui-react-positioning-9523f0c2-c54d-4461-81db-52c268453586.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "type": "minor", | ||
| "comment": "feat(react-positioning): add placement to onPositioningEnd event", | ||
| "packageName": "@fluentui/react-positioning", | ||
| "email": "robertpenner@microsoft.com", | ||
| "dependentChangeType": "patch" | ||
| } | ||
174 changes: 174 additions & 0 deletions
174
packages/react-components/react-positioning/library/src/createPositionManager.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| import { computePosition } from '@floating-ui/dom'; | ||
| import type { Placement } from '@floating-ui/dom'; | ||
| import { createPositionManager } from './createPositionManager'; | ||
| import { POSITIONING_END_EVENT } from './constants'; | ||
| import type { OnPositioningEndEvent } from './types'; | ||
|
|
||
| jest.mock('@floating-ui/dom', () => ({ | ||
| computePosition: jest.fn(), | ||
| })); | ||
|
|
||
| const computePositionMock = computePosition as jest.MockedFunction<typeof computePosition>; | ||
|
|
||
| /** | ||
| * Flush the microtask queue. | ||
| * createPositionManager uses debounce (Promise.resolve().then → forceUpdate) | ||
| * followed by computePosition(...).then → dispatch event, requiring multiple | ||
| * microtask cycles to fully resolve. | ||
| */ | ||
| const flushMicrotasks = async () => { | ||
| for (let i = 0; i < 5; i++) { | ||
| await new Promise(process.nextTick); | ||
| } | ||
| }; | ||
|
|
||
| function createTestElements() { | ||
| const container = document.createElement('div'); | ||
| const target = document.createElement('button'); | ||
|
|
||
| document.body.appendChild(container); | ||
| document.body.appendChild(target); | ||
|
|
||
| return { container, target }; | ||
| } | ||
|
|
||
| const mockMiddlewareData = { | ||
| intersectionObserver: { intersecting: false }, | ||
| hide: { escaped: false, referenceHidden: false }, | ||
| }; | ||
|
|
||
| describe('createPositionManager', () => { | ||
| beforeEach(() => { | ||
| computePositionMock.mockReset(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| document.body.innerHTML = ''; | ||
| }); | ||
|
|
||
| it.each([ | ||
| 'top', | ||
| 'top-start', | ||
| 'top-end', | ||
| 'right', | ||
| 'right-start', | ||
| 'right-end', | ||
| 'bottom', | ||
| 'bottom-start', | ||
| 'bottom-end', | ||
| 'left', | ||
| 'left-start', | ||
| 'left-end', | ||
| ] as Placement[])('dispatches POSITIONING_END_EVENT with placement "%s"', async (placement: Placement) => { | ||
| computePositionMock.mockResolvedValue({ | ||
| x: 10, | ||
| y: 20, | ||
| placement, | ||
| strategy: 'absolute', | ||
| middlewareData: mockMiddlewareData, | ||
| }); | ||
|
|
||
| const { container, target } = createTestElements(); | ||
| const listener = jest.fn(); | ||
| container.addEventListener(POSITIONING_END_EVENT, listener); | ||
|
|
||
| createPositionManager({ | ||
| container, | ||
| target, | ||
| arrow: null, | ||
| strategy: 'absolute', | ||
| middleware: [], | ||
| placement, | ||
| disableUpdateOnResize: true, | ||
| }); | ||
|
|
||
| await flushMicrotasks(); | ||
|
|
||
| expect(listener).toHaveBeenCalledTimes(1); | ||
|
|
||
| const event: OnPositioningEndEvent = listener.mock.calls[0][0]; | ||
|
|
||
| expect(event).toBeInstanceOf(CustomEvent); | ||
| expect(event.type).toBe(POSITIONING_END_EVENT); | ||
| expect(event.detail).toEqual({ placement }); | ||
| }); | ||
|
|
||
| it('dispatches event with computed placement when middleware changes it', async () => { | ||
| // Request 'top' but middleware flips to 'bottom' | ||
| computePositionMock.mockResolvedValue({ | ||
| x: 10, | ||
| y: 20, | ||
| placement: 'bottom', | ||
| strategy: 'absolute', | ||
| middlewareData: mockMiddlewareData, | ||
| }); | ||
|
|
||
| const { container, target } = createTestElements(); | ||
| const listener = jest.fn(); | ||
| container.addEventListener(POSITIONING_END_EVENT, listener); | ||
|
|
||
| createPositionManager({ | ||
| container, | ||
| target, | ||
| arrow: null, | ||
| strategy: 'absolute', | ||
| middleware: [], | ||
| placement: 'top', | ||
| disableUpdateOnResize: true, | ||
| }); | ||
|
|
||
| await flushMicrotasks(); | ||
|
|
||
| expect(listener).toHaveBeenCalledTimes(1); | ||
|
|
||
| const event: OnPositioningEndEvent = listener.mock.calls[0][0]; | ||
|
|
||
| expect(event.detail.placement).toBe('bottom'); | ||
| }); | ||
|
|
||
| it('does not dispatch event after dispose', async () => { | ||
| // Use a deferred promise so we can control when computePosition resolves | ||
| let resolveCompute!: (value: Awaited<ReturnType<typeof computePosition>>) => void; | ||
|
|
||
| computePositionMock.mockImplementation( | ||
| () => | ||
| new Promise(resolve => { | ||
| resolveCompute = resolve; | ||
| }), | ||
| ); | ||
|
|
||
| const { container, target } = createTestElements(); | ||
| const listener = jest.fn(); | ||
| container.addEventListener(POSITIONING_END_EVENT, listener); | ||
|
|
||
| const manager = createPositionManager({ | ||
| container, | ||
| target, | ||
| arrow: null, | ||
| strategy: 'absolute', | ||
| middleware: [], | ||
| placement: 'bottom', | ||
| disableUpdateOnResize: true, | ||
| }); | ||
|
|
||
| // Let debounce microtask fire so computePosition is called | ||
| await flushMicrotasks(); | ||
|
|
||
| // Dispose before the promise resolves | ||
| manager.dispose(); | ||
|
|
||
| // Now resolve the pending computePosition | ||
| resolveCompute({ | ||
| x: 10, | ||
| y: 20, | ||
| placement: 'bottom', | ||
| strategy: 'absolute', | ||
| middlewareData: mockMiddlewareData, | ||
| }); | ||
|
|
||
| // Allow the .then() to run | ||
| await flushMicrotasks(); | ||
|
|
||
| expect(listener).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
76 changes: 76 additions & 0 deletions
76
packages/react-components/react-positioning/library/src/usePositioning.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import { act, render } from '@testing-library/react'; | ||
| import * as React from 'react'; | ||
| import { usePositioning } from './usePositioning'; | ||
| import { POSITIONING_END_EVENT } from './constants'; | ||
| import type { OnPositioningEndEvent, OnPositioningEndEventDetail, PositioningProps } from './types'; | ||
|
|
||
| // Mock createPositionManager to avoid @floating-ui/dom dependency in this test. | ||
| // The mock dispatches the positioning end event asynchronously (via microtask), | ||
| // matching the real implementation's debounce + computePosition promise chain. | ||
| jest.mock('./createPositionManager', () => ({ | ||
| createPositionManager: jest.fn(({ container }: { container: HTMLElement }) => { | ||
| const dispatchEnd = () => { | ||
| Promise.resolve().then(() => { | ||
| container.dispatchEvent( | ||
| new CustomEvent<OnPositioningEndEventDetail>(POSITIONING_END_EVENT, { | ||
| detail: { placement: 'bottom' }, | ||
| }), | ||
| ); | ||
| }); | ||
| }; | ||
|
|
||
| dispatchEnd(); | ||
|
|
||
| return { | ||
| updatePosition: dispatchEnd, | ||
| dispose: jest.fn(), | ||
| }; | ||
| }), | ||
| })); | ||
|
|
||
| const TestComponent: React.FC<{ onPositioningEnd?: PositioningProps['onPositioningEnd'] }> = ({ onPositioningEnd }) => { | ||
| const { targetRef, containerRef } = usePositioning({ onPositioningEnd }); | ||
|
|
||
| return ( | ||
| <> | ||
| <button ref={targetRef} data-testid="target"> | ||
| Target | ||
| </button> | ||
| <div ref={containerRef} data-testid="container"> | ||
| Container | ||
| </div> | ||
| </> | ||
| ); | ||
| }; | ||
|
|
||
| describe('usePositioning', () => { | ||
| describe('onPositioningEnd', () => { | ||
| it('calls onPositioningEnd with the positioning event', async () => { | ||
| const onPositioningEnd = jest.fn(); | ||
|
|
||
| render(<TestComponent onPositioningEnd={onPositioningEnd} />); | ||
|
|
||
| // Flush microtasks so the async dispatch fires | ||
| await act(async () => { | ||
| await new Promise(process.nextTick); | ||
| }); | ||
|
|
||
| expect(onPositioningEnd).toHaveBeenCalled(); | ||
|
|
||
| const event: OnPositioningEndEvent = onPositioningEnd.mock.calls[0][0]; | ||
|
|
||
| expect(event).toBeInstanceOf(CustomEvent); | ||
| expect(event.type).toBe(POSITIONING_END_EVENT); | ||
| expect(event.detail.placement).toBe('bottom'); | ||
| }); | ||
|
|
||
| it('works when onPositioningEnd is not provided', async () => { | ||
| // Should not throw | ||
| render(<TestComponent />); | ||
|
|
||
| await act(async () => { | ||
| await new Promise(process.nextTick); | ||
| }); | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.