Skip to content

Commit 87ad268

Browse files
feat(Dropdown): add disableAutoFlip & disableReferenceTrackingOnScroll as Props (#1135)
* feat(Dropdown): add disableAutoFlip prop to lock placement and prevent auto-flip * Apply suggestion from @jmundhra-eightfold * feat(Dropdown): disable reference-tracking on scrolling via props * Apply suggestion from @jmundhra-eightfold * feat(Dropdown): add tests * feat(Dropdown): add cautionary comments * feat(Dropdown): nits * Apply suggestion from @jmundhra-eightfold * Apply suggestion from @jmundhra-eightfold * Apply suggestion from @jmundhra-eightfold * Apply suggestion from @jmundhra-eightfold
1 parent 6e191b9 commit 87ad268

3 files changed

Lines changed: 85 additions & 7 deletions

File tree

src/components/Dropdown/Dropdown.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,4 +974,34 @@ describe('Dropdown', () => {
974974

975975
expect(referenceElement.getAttribute('aria-haspopup')).toBe('dialog');
976976
});
977+
978+
test('Should open normally with disableAutoFlip set', async () => {
979+
const { container, getByTestId } = render(
980+
<Dropdown {...dropdownProps} disableAutoFlip>
981+
<Button data-testid="dropdown-reference" text="Dropdown menu test" />
982+
</Dropdown>
983+
);
984+
const referenceElement = getByTestId('dropdown-reference');
985+
act(() => {
986+
userEvent.click(referenceElement);
987+
});
988+
await waitFor(() => screen.getByText('User profile 1'));
989+
expect(referenceElement.getAttribute('aria-expanded')).toBe('true');
990+
expect(container.querySelector('.dropdown-wrapper')).toBeTruthy();
991+
});
992+
993+
test('Should open normally with disableReferenceTrackingOnScroll set', async () => {
994+
const { container, getByTestId } = render(
995+
<Dropdown {...dropdownProps} disableReferenceTrackingOnScroll>
996+
<Button data-testid="dropdown-reference" text="Dropdown menu test" />
997+
</Dropdown>
998+
);
999+
const referenceElement = getByTestId('dropdown-reference');
1000+
act(() => {
1001+
userEvent.click(referenceElement);
1002+
});
1003+
await waitFor(() => screen.getByText('User profile 1'));
1004+
expect(referenceElement.getAttribute('aria-expanded')).toBe('true');
1005+
expect(container.querySelector('.dropdown-wrapper')).toBeTruthy();
1006+
});
9771007
});

src/components/Dropdown/Dropdown.tsx

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ export const Dropdown: FC<DropdownProps> = React.memo(
8282
overlayTabIndex = -1,
8383
overlayProps,
8484
toggleDropdownOnShiftTab = false,
85+
disableAutoFlip = false,
86+
disableReferenceTrackingOnScroll = false,
8587
},
8688
ref: React.ForwardedRef<DropdownRef>
8789
) => {
@@ -105,7 +107,11 @@ export const Dropdown: FC<DropdownProps> = React.memo(
105107
const { x, y, strategy, update, refs, context } = useFloating({
106108
placement,
107109
strategy: positionStrategy,
108-
middleware: [fOffset(offset), flip(), shift()],
110+
middleware: [
111+
fOffset(offset),
112+
...(disableAutoFlip ? [] : [flip()]),
113+
shift(),
114+
],
109115
});
110116

111117
const intervalRef: React.MutableRefObject<NodeJS.Timer> =
@@ -240,17 +246,34 @@ export const Dropdown: FC<DropdownProps> = React.memo(
240246
return autoUpdate(
241247
refs.reference.current,
242248
refs.floating.current,
243-
update
249+
update,
250+
{ ancestorScroll: !disableReferenceTrackingOnScroll }
244251
);
245-
}, [refs.reference, refs.floating, update]);
252+
}, [
253+
disableReferenceTrackingOnScroll,
254+
refs.reference,
255+
refs.floating,
256+
update,
257+
]);
246258

247259
// Add a new useEffect to update position when overlay content changes
248260
useEffect(() => {
249-
if (mergedVisible && refs.floating.current) {
250-
// Update the position when the overlay content changes
251-
update();
261+
if (
262+
disableReferenceTrackingOnScroll ||
263+
!mergedVisible ||
264+
!refs.floating.current
265+
) {
266+
return;
252267
}
253-
}, [overlay, mergedVisible, update, refs.floating]);
268+
// Update the position when the overlay content changes
269+
update();
270+
}, [
271+
disableReferenceTrackingOnScroll,
272+
overlay,
273+
mergedVisible,
274+
update,
275+
refs.floating,
276+
]);
254277

255278
const dropdownClasses: string = mergeClasses([
256279
dropdownClassNames,

src/components/Dropdown/Dropdown.types.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,31 @@ export interface DropdownProps {
192192
* The props of the overlay
193193
*/
194194
overlayProps?: React.HTMLAttributes<HTMLDivElement>;
195+
196+
/**
197+
* When true, the dropdown will not auto-flip to the opposite side when the
198+
* preferred placement overflows. Use to keep a fixed placement (e.g. anchored
199+
* below a trigger toward a scrollable region).
200+
*
201+
* CAUTION - this suppresses dropdown's auto-flip if the space on
202+
* the configured side is less than the dropdown-overlay height.
203+
* If the side fixed doesn't have a scrollable region, then the dropdown overlay is stuck.
204+
* @default false
205+
*/
206+
disableAutoFlip?: boolean;
207+
208+
/**
209+
* When true, the dropdown does not reposition when an ancestor of the reference
210+
* or floating element scrolls, or when the overlay content changes size while
211+
* open — it stays where it opened. Element resize and layout-shift tracking via
212+
* autoUpdate are unaffected. Use alongside disableAutoFlip when the reference is
213+
* inside a container that scrolls out from under a fixed-placement dropdown.
214+
*
215+
* CAUTION: this also suppresses the reposition that would otherwise run when the
216+
* overlay's own rendered content changes size while open.
217+
* @default false
218+
*/
219+
disableReferenceTrackingOnScroll?: boolean;
195220
}
196221

197222
export type DropdownRef = {

0 commit comments

Comments
 (0)