Skip to content

Commit 04161d5

Browse files
committed
fix(combobox): position menu with floating-ui to stop it jumping
1 parent 49cebd6 commit 04161d5

8 files changed

Lines changed: 108 additions & 10 deletions

File tree

packages/pluggableWidgets/combobox-web/src/__tests__/MultiSelection.spec.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import "@testing-library/jest-dom";
2+
jest.mock("../hooks/useFloatingMenu");
23
import { fireEvent, render, RenderResult, waitFor } from "@testing-library/react";
34
import { resetIdCounter } from "downshift";
45
import { ListValue } from "mendix";
@@ -94,6 +95,16 @@ describe("Combo box (Association)", () => {
9495
const { container } = render(<Combobox {...defaultProps} />);
9596
expect(container.getElementsByClassName("widget-combobox-placeholder")).toHaveLength(1);
9697
});
98+
it("positions the open menu via floating-ui (applies floatingStyles to the menu)", async () => {
99+
const component = render(<Combobox {...defaultProps} />);
100+
const input = await getInput(component);
101+
fireEvent.click(input);
102+
await waitFor(() => {
103+
expect(component.getAllByRole("option")).toHaveLength(4);
104+
});
105+
const menu = component.container.querySelector(".widget-combobox-menu") as HTMLElement;
106+
expect(menu.style.getPropertyValue("--this-is-mocked-from-unit-tests")).toEqual("true");
107+
});
97108
it("toggles combobox menu on: input CLICK(focus) / BLUR", async () => {
98109
const component = render(<Combobox {...defaultProps} />);
99110
const input = await getInput(component);

packages/pluggableWidgets/combobox-web/src/__tests__/SingleSelection.spec.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import "@testing-library/jest-dom";
2+
jest.mock("../hooks/useFloatingMenu");
23
import { act, fireEvent, render, RenderResult, waitFor } from "@testing-library/react";
34
import { resetIdCounter } from "downshift";
45
import { ListValue } from "mendix";
@@ -114,6 +115,18 @@ describe("Combo box (Association)", () => {
114115
});
115116
expect(component.queryAllByRole("option")).toHaveLength(0);
116117
});
118+
it("positions the open menu via floating-ui (applies floatingStyles to the menu)", async () => {
119+
const component = render(<Combobox {...defaultProps} />);
120+
const toggleButton = await getToggleButton(component);
121+
await act(() => {
122+
fireEvent.click(toggleButton);
123+
});
124+
await waitFor(() => {
125+
expect(component.getAllByRole("option")).toHaveLength(4);
126+
});
127+
const menu = component.container.querySelector(".widget-combobox-menu") as HTMLElement;
128+
expect(menu.style.getPropertyValue("--this-is-mocked-from-unit-tests")).toEqual("true");
129+
});
117130
it("sets option to selected item", async () => {
118131
const component = render(<Combobox {...defaultProps} />);
119132
const value = await getVisibleValueNode(component);

packages/pluggableWidgets/combobox-web/src/components/ComboboxMenuWrapper.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import classNames from "classnames";
22
import { UseComboboxPropGetters } from "downshift/typings";
3-
import { MouseEvent, PropsWithChildren, ReactElement, ReactNode } from "react";
4-
import { useMenuStyle } from "../hooks/useMenuStyle";
3+
import { CSSProperties, MouseEvent, PropsWithChildren, ReactElement, ReactNode, Ref } from "react";
54
import { NoOptionsPlaceholder } from "./Placeholder";
65

76
interface ComboboxMenuWrapperProps extends PropsWithChildren, Partial<UseComboboxPropGetters<string>> {
87
alwaysOpen?: boolean;
8+
floatingRef?: Ref<HTMLDivElement>;
9+
floatingStyles?: CSSProperties;
910
highlightedIndex?: number | null;
1011
isEmpty: boolean;
1112
isLoading: boolean;
@@ -32,6 +33,8 @@ export function ComboboxMenuWrapper(props: ComboboxMenuWrapperProps): ReactEleme
3233
const {
3334
alwaysOpen,
3435
children,
36+
floatingRef,
37+
floatingStyles,
3538
getMenuProps,
3639
highlightedIndex,
3740
isEmpty,
@@ -46,11 +49,9 @@ export function ComboboxMenuWrapper(props: ComboboxMenuWrapperProps): ReactEleme
4649
onScroll
4750
} = props;
4851

49-
const [ref, style] = useMenuStyle<HTMLDivElement>(isOpen);
50-
5152
return (
5253
<div
53-
ref={ref}
54+
ref={alwaysOpen ? undefined : floatingRef}
5455
className={classNames("widget-combobox-menu", { "widget-combobox-menu-hidden": !isOpen })}
5556
style={
5657
alwaysOpen
@@ -59,7 +60,7 @@ export function ComboboxMenuWrapper(props: ComboboxMenuWrapperProps): ReactEleme
5960
visibility: "visible",
6061
position: "relative"
6162
}
62-
: style
63+
: floatingStyles
6364
}
6465
data-overlay-content={isOpen || undefined}
6566
>

packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ClearButton } from "../../assets/icons";
44
import { MultiSelector, SelectionBaseProps } from "../../helpers/types";
55
import { getInputLabel, getSelectedCaptionsPlaceholder, getValidationErrorId } from "../../helpers/utils";
66
import { useDownshiftMultiSelectProps } from "../../hooks/useDownshiftMultiSelectProps";
7+
import { useFloatingMenu } from "../../hooks/useFloatingMenu";
78
import { useLazyLoading } from "../../hooks/useLazyLoading";
89
import { ComboboxWrapper } from "../ComboboxWrapper";
910
import { InputPlaceholder } from "../Placeholder";
@@ -35,6 +36,7 @@ export function MultiSelection({
3536
setSelectedItems,
3637
toggleSelectedItem
3738
} = useDownshiftMultiSelectProps(selector, options, inputRef, a11yConfig.a11yStatusMessage);
39+
const { refs, floatingStyles } = useFloatingMenu(isOpen);
3840
const isSelectedItemsBoxStyle = selector.selectedItemsStyle === "boxes";
3941
const isOptionsSelected = selector.isOptionsSelected();
4042
const inputLabel = getInputLabel(options.inputId);
@@ -90,6 +92,7 @@ export function MultiSelection({
9092
return (
9193
<Fragment>
9294
<ComboboxWrapper
95+
ref={refs.setReference}
9396
isOpen={isOpen}
9497
readOnly={selector.readOnly}
9598
readOnlyStyle={options.readOnlyStyle}
@@ -201,6 +204,8 @@ export function MultiSelection({
201204
isLoading={selector.options.isLoading}
202205
lazyLoading={lazyLoading}
203206
onScroll={onScroll}
207+
floatingRef={refs.setFloating}
208+
floatingStyles={floatingStyles}
204209
/>
205210
</Fragment>
206211
);

packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelectionMenu.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { UseComboboxPropGetters } from "downshift/typings";
2-
import { MouseEvent, ReactElement, ReactNode } from "react";
2+
import { CSSProperties, MouseEvent, ReactElement, ReactNode, Ref } from "react";
33
import { Checkbox } from "../../assets/icons";
44
import { MultiSelector } from "../../helpers/types";
55
import { ComboboxMenuWrapper } from "../ComboboxMenuWrapper";
@@ -20,6 +20,8 @@ interface MultiSelectionMenuProps extends Partial<UseComboboxPropGetters<string>
2020
isLoading: boolean;
2121
lazyLoading: boolean;
2222
onScroll: (e: any) => void;
23+
floatingRef?: Ref<HTMLDivElement>;
24+
floatingStyles?: CSSProperties;
2325
}
2426

2527
export function MultiSelectionMenu({
@@ -36,10 +38,14 @@ export function MultiSelectionMenu({
3638
onOptionClick,
3739
isLoading,
3840
lazyLoading,
39-
onScroll
41+
onScroll,
42+
floatingRef,
43+
floatingStyles
4044
}: MultiSelectionMenuProps): ReactElement {
4145
return (
4246
<ComboboxMenuWrapper
47+
floatingRef={floatingRef}
48+
floatingStyles={floatingStyles}
4349
getMenuProps={getMenuProps}
4450
highlightedIndex={highlightedIndex}
4551
isEmpty={selectableItems.length <= 0}

packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelection.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ClearButton } from "../../assets/icons";
44
import { SelectionBaseProps, SingleSelector } from "../../helpers/types";
55
import { getInputLabel, getValidationErrorId } from "../../helpers/utils";
66
import { useDownshiftSingleSelectProps } from "../../hooks/useDownshiftSingleSelectProps";
7+
import { useFloatingMenu } from "../../hooks/useFloatingMenu";
78
import { useLazyLoading } from "../../hooks/useLazyLoading";
89
import { ComboboxWrapper } from "../ComboboxWrapper";
910
import { InputPlaceholder } from "../Placeholder";
@@ -30,6 +31,7 @@ export function SingleSelection({
3031
selectItem
3132
} = useDownshiftSingleSelectProps(selector, options, a11yConfig.a11yStatusMessage);
3233
const inputRef = useRef<HTMLInputElement>(null);
34+
const { refs, floatingStyles } = useFloatingMenu(keepMenuOpen === true ? false : isOpen);
3335
const lazyLoading = selector.lazyLoading ?? false;
3436
const { onScroll } = useLazyLoading({
3537
hasMoreItems: selector.options.hasMore ?? false,
@@ -86,6 +88,7 @@ export function SingleSelection({
8688
return (
8789
<Fragment>
8890
<ComboboxWrapper
91+
ref={refs.setReference}
8992
isOpen={isOpen || keepMenuOpen === true}
9093
readOnly={selector.readOnly}
9194
readOnlyStyle={options.readOnlyStyle}
@@ -154,6 +157,8 @@ export function SingleSelection({
154157
isLoading={selector.options.isLoading}
155158
lazyLoading={lazyLoading}
156159
onScroll={onScroll}
160+
floatingRef={refs.setFloating}
161+
floatingStyles={floatingStyles}
157162
/>
158163
</Fragment>
159164
);

packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelectionMenu.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { UseComboboxPropGetters } from "downshift/typings";
2-
import { ReactElement, ReactNode } from "react";
2+
import { CSSProperties, ReactElement, ReactNode, Ref } from "react";
33
import { SingleSelector } from "../../helpers/types";
44
import { ComboboxMenuWrapper } from "../ComboboxMenuWrapper";
55
import { ComboboxOptionWrapper } from "../ComboboxOptionWrapper";
@@ -16,6 +16,8 @@ interface ComboboxMenuProps extends Partial<UseComboboxPropGetters<string>> {
1616
isLoading: boolean;
1717
lazyLoading: boolean;
1818
onScroll: (e: any) => void;
19+
floatingRef?: Ref<HTMLDivElement>;
20+
floatingStyles?: CSSProperties;
1921
}
2022

2123
export function SingleSelectionMenu({
@@ -29,13 +31,17 @@ export function SingleSelectionMenu({
2931
menuFooterContent,
3032
isLoading,
3133
lazyLoading,
32-
onScroll
34+
onScroll,
35+
floatingRef,
36+
floatingStyles
3337
}: ComboboxMenuProps): ReactElement {
3438
const items = selector.options.getAll();
3539

3640
return (
3741
<ComboboxMenuWrapper
3842
alwaysOpen={alwaysOpen}
43+
floatingRef={floatingRef}
44+
floatingStyles={floatingStyles}
3945
getMenuProps={getMenuProps}
4046
isEmpty={items?.length <= 0}
4147
isLoading={isLoading}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import "@testing-library/jest-dom";
2+
import { render } from "@testing-library/react";
3+
import { createRef } from "react";
4+
import { ComboboxMenuWrapper } from "../ComboboxMenuWrapper";
5+
6+
const baseProps = {
7+
isEmpty: false,
8+
isLoading: false,
9+
lazyLoading: false,
10+
loader: null,
11+
getMenuProps: (options?: any) => ({ ...options })
12+
};
13+
14+
describe("ComboboxMenuWrapper", () => {
15+
it("applies floating styles to the menu when open and not alwaysOpen", () => {
16+
const floatingRef = createRef<HTMLDivElement>();
17+
const { container } = render(
18+
<ComboboxMenuWrapper
19+
{...baseProps}
20+
isOpen
21+
floatingRef={floatingRef}
22+
floatingStyles={{ position: "fixed", top: 10, left: 20 }}
23+
/>
24+
);
25+
26+
const menu = container.querySelector(".widget-combobox-menu") as HTMLElement;
27+
expect(menu.style.position).toBe("fixed");
28+
expect(menu.style.top).toBe("10px");
29+
// floating ref is attached so floating-ui can measure/position the element
30+
expect(floatingRef.current).toBe(menu);
31+
});
32+
33+
it("renders inline (position: relative) and ignores floating positioning when alwaysOpen", () => {
34+
const floatingRef = createRef<HTMLDivElement>();
35+
const { container } = render(
36+
<ComboboxMenuWrapper
37+
{...baseProps}
38+
isOpen
39+
alwaysOpen
40+
floatingRef={floatingRef}
41+
floatingStyles={{ position: "fixed", top: 10, left: 20 }}
42+
/>
43+
);
44+
45+
const menu = container.querySelector(".widget-combobox-menu") as HTMLElement;
46+
expect(menu.style.position).toBe("relative");
47+
expect(menu.style.top).toBe("");
48+
// floating ref must NOT be attached in alwaysOpen mode
49+
expect(floatingRef.current).toBeNull();
50+
});
51+
});

0 commit comments

Comments
 (0)