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(react-positioning): add placement to onPositioningEnd event",
"packageName": "@fluentui/react-positioning",
"email": "robertpenner@microsoft.com",
"dependentChangeType": "patch"
}
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();
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 type { OnPositioningEndEventDetail, PositionManager, PositioningPlacement, TargetElement } from './types';
import { debounce, writeArrowUpdates, writeContainerUpdates } from './utils';
import { listScrollParents } from './utils/listScrollParents';
import { POSITIONING_END_EVENT } from './constants';
Expand Down Expand Up @@ -135,7 +135,16 @@ export function createPositionManager(options: PositionManagerOptions): Position
useTransform,
});

container.dispatchEvent(new CustomEvent(POSITIONING_END_EVENT));
container.dispatchEvent(
new CustomEvent<OnPositioningEndEventDetail>(POSITIONING_END_EVENT, {
detail: {
// Cast from Floating UI's Placement to the Fluent-owned PositioningPlacement.
// These are equivalent string unions; the cast avoids leaking @floating-ui/dom
// types into the public API surface.
placement: computedPlacement satisfies PositioningPlacement,
},
}),
);
})
.catch(err => {
// https://github.com/floating-ui/floating-ui/issues/1845
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import type { OnPositioningEndEvent } from './types';
import { PositioningProps } from './types';

describe('PositioningProps', () => {
Expand All @@ -21,4 +22,32 @@ describe('PositioningProps', () => {
// assertion is useless, we just want typescript to check the positioning props
expect(props).toBeTruthy;
});

it('accepts () => void for onPositioningEnd (backwards compatibility)', () => {
// The signature changed from () => void to (e: OnPositioningEndEvent) => void.
// TypeScript's function parameter compatibility ensures () => void still works.
const callback: () => void = () => {
/* noop */
};

const props: PositioningProps = {
onPositioningEnd: callback,
};

expect(props.onPositioningEnd).toBeDefined();
});

it('accepts (e: OnPositioningEndEvent) => void for onPositioningEnd', () => {
const callback = (e: OnPositioningEndEvent) => {
// Access detail.placement to verify the type shape
const _placement: string = e.detail.placement;
_placement;
};

const props: PositioningProps = {
onPositioningEnd: callback,
};

expect(props.onPositioningEnd).toBeDefined();
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,42 @@
import * as React from 'react';

/**
* Physical placement of a positioned element relative to its target, as computed by Floating UI.
* This is a Fluent-owned equivalent of Floating UI's `Placement` type, avoiding a transitive
* dependency on `@floating-ui/dom` in the public API surface.
*/
export type PositioningPlacement =
| 'top'
| 'top-start'
| 'top-end'
| 'right'
| 'right-start'
| 'right-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'left'
| 'left-start'
| 'left-end';

/**
* Detail payload of the positioning end event, providing the final computed placement
* after all middleware (flip, shift, etc.) have run.
*/
export type OnPositioningEndEventDetail = {
/**
* The computed placement of the positioned element. May differ from the requested
* placement if flip or other middleware adjusted it.
*/
placement: PositioningPlacement;
};

/**
* Custom DOM event dispatched on the positioned container element when a
* positioning update completes. Carries placement information in `event.detail`.
*/
export type OnPositioningEndEvent = CustomEvent<OnPositioningEndEventDetail>;

export type PositioningRect = {
width: number;
height: number;
Expand Down Expand Up @@ -198,10 +235,11 @@ export interface PositioningOptions {
/**
* Called when a position update has finished. Multiple position updates can happen in a single render,
* since positioning happens outside of the React lifecycle.
* The event's `detail.placement` indicates the final computed placement after middleware adjustments.
*
* It's also possible to listen to the custom DOM event `fui-positioningend`
*/
onPositioningEnd?: () => void;
onPositioningEnd?: (e: OnPositioningEndEvent) => void;

/**
* Disables the resize observer that updates position on target or dimension change
Expand Down
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);
});
});
});
});
Loading