From 610b6e671b5ae8abaec09df3d8fd42e03012b753 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Fri, 10 Jul 2026 14:38:24 +0200 Subject: [PATCH 1/8] docs(combobox): add openspec change for menu jump fix (WC-3406) --- .../fix-combobox-menu-jump/.openspec.yaml | 2 + .../changes/fix-combobox-menu-jump/design.md | 94 +++++++++++++++++++ .../fix-combobox-menu-jump/proposal.md | 77 +++++++++++++++ .../changes/fix-combobox-menu-jump/tasks.md | 64 +++++++++++++ .../combobox-web/openspec/config.yaml | 1 + 5 files changed, 238 insertions(+) create mode 100644 packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/.openspec.yaml create mode 100644 packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/design.md create mode 100644 packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/proposal.md create mode 100644 packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/tasks.md create mode 100644 packages/pluggableWidgets/combobox-web/openspec/config.yaml diff --git a/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/.openspec.yaml b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/.openspec.yaml new file mode 100644 index 0000000000..c0db885c7c --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/.openspec.yaml @@ -0,0 +1,2 @@ +schema: tdd-refactor +created: 2026-07-09 diff --git a/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/design.md b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/design.md new file mode 100644 index 0000000000..03cf100387 --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/design.md @@ -0,0 +1,94 @@ +## Test Cases + + + +### Reproduction Tests + +- Menu wiring uses floating-ui, not the old style hook - `useMenuStyle` is gone and the + wrapper is positioned via floating refs (unit) + - **Given**: `SingleSelection` rendered open with a mocked `useFloatingMenu` + - **When**: component mounts and the menu opens + - **Then**: the menu wrapper (`.widget-combobox-menu`) receives the floating ref and the + `floatingStyles` from the hook; no import of `useMenuStyle` remains in the module graph + +- Menu placement does not oscillate near the viewport bottom (e2e) + - **Given**: a Combobox positioned so there is less space below than the menu's natural + height, with enough options to fill the menu + - **When**: the menu is opened and left open + - **Then**: the menu settles in a single placement within one animation frame and its + `top` (bounding rect) does not change across subsequent frames (no flip-flop) + +- Menu does not jump when the option count changes while open (e2e) + - **Given**: an open Combobox near the viewport bottom with a text filter + - **When**: the user types to reduce, then clear, the number of matching options + - **Then**: the menu re-anchors smoothly and does not flip between above/below the input + +### Edge Cases + +- Menu shrinks and scrolls instead of overflowing when space is tight (e2e) + - **Given**: a Combobox with many options and limited space below it + - **When**: the menu opens + - **Then**: the menu height is capped to the available space (`maxHeight <= availableHeight`), + the option list scrolls internally, and the menu bottom stays within the viewport + (respecting the 8px padding) + +- Menu width matches the input width (unit + e2e) + - **Given**: a Combobox of a known input-container width, menu open + - **When**: floating-ui's `size` middleware applies + - **Then**: the menu wrapper width equals the reference (input container) width + +- Menu header and footer remain visible when the menu shrinks (e2e) + - **Given**: a Combobox configured with `menuHeaderContent` and `menuFooterContent`, in a + tight space so the menu shrinks + - **When**: the menu opens + - **Then**: header and footer are both fully visible; only the option list scrolls; footer + is not clipped below the fold + +- Menu does not flash before it is positioned (e2e) + - **Given**: a Combobox opened + - **When**: the first render occurs before floating-ui reports `isPositioned` + - **Then**: the menu is `visibility: hidden` until positioned (no visible jump-from-origin) + +### Regression Tests + +- alwaysOpen (`keepMenuOpen`) renders inline and does not use floating positioning (unit) + - **Given**: `SingleSelection` and `MultiSelection` rendered with `keepMenuOpen` + - **When**: the menu renders + - **Then**: the wrapper style is `position: relative` (inline block), it does NOT carry the + floating ref/`floatingStyles`, and `useFloatingMenu` is called with `open = false` + +- MultiSelection wires floating identically to SingleSelection (unit) + - **Given**: `MultiSelection` rendered open (not alwaysOpen) + - **When**: it mounts + - **Then**: reference ref is on the input container, floating ref + styles are on the menu + wrapper — same contract as `SingleSelection` + +- Lazy-loading scroll still works (unit) + - **Given**: a Combobox with `lazyLoading` enabled and `hasMore` options, menu open + - **When**: the option list is scrolled + - **Then**: the `onScroll` handler fires and the `widget-combobox-menu-lazy-scroll` class is + applied to the list — unchanged from current behavior + +- Existing Single/Multi/Static selection specs pass with the new mock (unit) + - **Given**: the existing `SingleSelection.spec`, `MultiSelection.spec`, `StaticSelection.spec` + - **When**: run against the floating-ui implementation with `useFloatingMenu` mocked + - **Then**: all previously-passing assertions still pass (menu render, item selection, + open/close, a11y attributes) + +## Notes + + + +- Real flip/shrink math is not unit-testable in jsdom (no layout engine). The no-jump and + shrink outcomes are the reporter's core complaint, so they are covered by real Playwright + e2e in `e2e/Combobox.spec.js` (stable `top` near the bottom edge, capped `maxHeight`, width + == input) plus the manual showcase for Takuma / Ana. +- Transformed/`contain`/`filter` ancestor + `position: fixed` mis-anchoring is a known + limitation (no portal this pass) — not covered by a test, documented in proposal. diff --git a/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/proposal.md b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/proposal.md new file mode 100644 index 0000000000..3cd38ff33c --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/proposal.md @@ -0,0 +1,77 @@ +## Why + +The Combobox dropdown menu jumps around while open (WC-3406). Opening the menu near the +bottom of the viewport, or changing the number of visible options (e.g. by typing to +filter), makes the menu flicker between rendering above and below the input instead of +settling in one place. + +Reported by Roman Vyakhirev with a screen recording. The reporter also built a +proof-of-concept branch (`feat/combobox-better-menu`) migrating the menu to floating-ui, +which this change ports and hardens. + +Expected: once open, the menu picks a stable placement (below the input by default, above +only when there genuinely isn't room) and does not oscillate when its content height +changes. + +## Root Cause + +Menu positioning is hand-rolled in `hooks/useMenuStyle.ts`. `getMenuPosition()` chooses +top-vs-bottom placement from the _measured_ menu height, and that same `menuHeight` is a +dependency of the positioning `useEffect`. So: place menu → height changes → effect +re-runs → placement recomputed from the new height → height changes again. The debounced +`setStyle` (32ms) only smears the oscillation over time; it does not remove the feedback +loop. There is also no re-anchoring on scroll/resize. + +## What Changes + +Replace the hand-rolled positioning with floating-ui (`@floating-ui/react`), following the +reporter's approach but resolving the edge cases his branch left open. + +- **New dependency**: `@floating-ui/react` (`^0.26.27`) added to `combobox-web`. +- **New hook** `hooks/useFloatingMenu.ts`: `useFloating` with `strategy: "fixed"`, + `placement: "bottom-start"`, `whileElementsMounted: autoUpdate`, and middleware + `offset(4)` → `flip({ crossAxis: false, fallbackStrategy: "bestFit", padding: 8 })` → + `size({ padding: 8, apply })`. `apply` sets the floating width to the reference width and + caps `maxHeight` at `min(availableHeight, 320)`. Menu stays hidden until + `isPositioned` to avoid a first-frame flash. +- **Delete** `hooks/useMenuStyle.ts` (and stop importing `usePositionObserver` / `debounce` + for this purpose). +- **Wire floating refs** through `SingleSelection` / `MultiSelection` → + `Single/MultiSelectionMenu` → `ComboboxMenuWrapper`: reference ref on the input + container, floating ref + `floatingStyles` on the menu wrapper. +- **Height model (SCSS)**: `.widget-combobox-menu` becomes `display: flex; flex-direction: +column` and is the element floating-ui height-caps. `.widget-combobox-menu-list` changes + from a hard `max-height: 320px` to `max-height: 100%; flex: 1; min-height: 0` so the list + fills the wrapper and scrolls, while header/footer share the capped height. This is what + makes the "shrink when space is tight" behavior work with header/footer present. +- **Strip stale wrapper CSS**: remove `position: absolute`, `display: inline`, + `margin: 4px 0`, `width: 100%`, `left: unset` from `.widget-combobox-menu` — these fight + the inline styles floating-ui writes (the `margin` in particular caused a residual + offset). The 4px gap is preserved via `offset(4)`. +- **alwaysOpen (`keepMenuOpen`) unchanged in behavior**: this mode renders inline with + `position: relative` and must NOT use floating positioning. Both selections call + `useFloatingMenu(alwaysOpen ? false : isOpen)` and the wrapper attaches no floating ref / + styles in the relative branch. (Fixes an inconsistency in the POC where Single passed + `isOpen || keepMenuOpen` and Multi passed `isOpen`.) + +## Impact + +- **Widget**: `combobox-web` only. No public prop/XML changes — purely internal positioning + and styling. Not breaking for app developers. +- **Behavior change (intended)**: near a viewport edge the menu now shrinks and scrolls + rather than overflowing; default placement is below the input. This shrink behavior is + the part the reporter flagged for design review — this branch is meant to be **shown to + Takuma / Ana before merge**, not merged blind. +- **Must NOT break**: + - `keepMenuOpen` / alwaysOpen inline rendering. + - Menu width still matches the input width. + - Lazy-loading scroll (`widget-combobox-menu-lazy-scroll`) and its scroll handler. + - Menu header/footer content still visible and not clipped when the menu shrinks. + - Existing Single/Multi/Static selection unit tests (updated to mock `useFloatingMenu`). +- **Known limitations (documented, out of scope)**: + - No React portal — with `position: fixed`, a transformed/filtered/`contain` ancestor can + still mis-anchor the menu. Same vulnerability as the old code; not a regression. + - No `shift` middleware (width is pinned to the anchor, so horizontal overflow can't + occur in practice). + - RTL menu _placement_ is handled for free by `bottom-start`; pre-existing RTL item + styling (`margin-right`) is left untouched. diff --git a/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/tasks.md b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/tasks.md new file mode 100644 index 0000000000..2001f5e874 --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/openspec/changes/fix-combobox-menu-jump/tasks.md @@ -0,0 +1,64 @@ +## 1. Test Setup + + + +- [x] 1.1 Add `@floating-ui/react` (`^0.26.27`) to `combobox-web` package.json dependencies +- [x] 1.2 Add `src/hooks/__mocks__/useFloatingMenu.ts` returning stable `refs.setReference` / + `refs.setFloating`, a marker `floatingStyles`, and `isPositioned: true` +- [x] 1.3 Write failing unit test: `SingleSelection` open → menu wrapper gets the floating ref + + `floatingStyles`; `useMenuStyle` no longer imported +- [x] 1.4 Write failing unit test: `MultiSelection` wires floating identically to Single +- [x] 1.5 Test alwaysOpen bypass: wrapper `position: relative`, floating ref NOT attached, floating + styles ignored — via `ComboboxMenuWrapper.spec.tsx` (keepMenuOpen isn't wired to runtime + `Combobox` props, so the presentational wrapper is the correct public interface to test) +- [x] 1.6 Regression: lazy-loading `onScroll` + `widget-combobox-menu-lazy-scroll` covered by the + existing (still-passing) lazy-loading specs + +## 2. Implementation + + + +- [x] 2.1 Create `src/hooks/useFloatingMenu.ts`: `useFloating` with `strategy: "fixed"`, + `placement: "bottom-start"`, `whileElementsMounted: autoUpdate`, middleware + `offset(4)` → `flip({ crossAxis: false, fallbackStrategy: "bestFit", padding: 8 })` → + `size({ padding: 8, apply })`; `apply` sets width = reference width and + `maxHeight = min(availableHeight, 320)`; hide until `isPositioned` +- [x] 2.2 `ComboboxMenuWrapper`: accept `floatingRef` + `floatingStyles`; apply on the + `.widget-combobox-menu` div; alwaysOpen branch keeps `position: relative`, no floating ref +- [x] 2.3 Thread props through `SingleSelectionMenu` / `MultiSelectionMenu` +- [x] 2.4 `SingleSelection` + `MultiSelection`: call `useFloatingMenu(alwaysOpen ? false : isOpen)`, + set `refs.setReference` on `ComboboxWrapper`, pass `refs.setFloating` + `floatingStyles` + to the menu (consistent across both — fixes the POC Single/Multi inconsistency) +- [x] 2.5 `ComboboxWrapper`: already `forwardRef` — confirm reference ref lands on the + input container div + +## 3. Refactoring / Cleanup + + + +- [x] 3.1 Delete `src/hooks/useMenuStyle.ts`; remove now-dead `usePositionObserver` / `debounce` + usage for menu positioning +- [x] 3.2 SCSS `.widget-combobox-menu`: remove `position: absolute`, `display: inline`, + `margin: 4px 0`, `width: 100%`, `left: unset`; add `display: flex; flex-direction: column` +- [x] 3.3 SCSS `.widget-combobox-menu-list`: `max-height: 320px` → `max-height: 100%; flex: 1; +min-height: 0` so the list fills + scrolls within the (shrunk) wrapper +- [ ] 3.4 Verify header/footer share the capped height and are not clipped when shrunk + (needs live app — part of manual verification / 4.5 showcase) + +## 4. Verification + +- [x] 4.1 All new + updated unit tests pass (`SingleSelection`, `MultiSelection`, + `StaticSelection` specs green with the mock) — 28 passed, 5 snapshots regenerated +- [x] 4.2 Full `combobox-web` test suite passes; lint clean (touched files: no issues); + snapshots updated +- [x] 4.3 Add Playwright e2e in `e2e/Combobox.spec.js`: menu `top` stable while open, + height capped + within viewport when space is tight, menu width == input width + (run against the live test project — not runnable in this env) +- [x] 4.4 Add CHANGELOG.md entry under `## [Unreleased] > ### Fixed` + `### Changed` +- [ ] 4.5 Showcase branch to Takuma / Ana for the shrink-behavior sign-off before merge + +## Notes + +- Real flip/shrink math is not unit-testable in jsdom — covered by 4.3 (e2e/manual), not units. +- Known limitation (no portal): `position: fixed` inside a transformed/`contain`/`filter` + ancestor can mis-anchor. Same as old code; documented in proposal, not fixed here. diff --git a/packages/pluggableWidgets/combobox-web/openspec/config.yaml b/packages/pluggableWidgets/combobox-web/openspec/config.yaml new file mode 100644 index 0000000000..bd4abbf66f --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/openspec/config.yaml @@ -0,0 +1 @@ +schema: tdd-refactor From 091b817428f7ec622dbddeb84d589925b0916c81 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Fri, 10 Jul 2026 14:39:01 +0200 Subject: [PATCH 2/8] chore(combobox): add @floating-ui/react dependency --- packages/pluggableWidgets/combobox-web/package.json | 1 + pnpm-lock.yaml | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/pluggableWidgets/combobox-web/package.json b/packages/pluggableWidgets/combobox-web/package.json index ea4e574d44..df8f325029 100644 --- a/packages/pluggableWidgets/combobox-web/package.json +++ b/packages/pluggableWidgets/combobox-web/package.json @@ -47,6 +47,7 @@ "verify": "rui-verify-package-format" }, "dependencies": { + "@floating-ui/react": "^0.26.27", "classnames": "^2.5.1", "downshift": "^7.6.2", "match-sorter": "^8.1.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40105448c2..7977d1365d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -935,6 +935,9 @@ importers: packages/pluggableWidgets/combobox-web: dependencies: + '@floating-ui/react': + specifier: ^0.26.27 + version: 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: specifier: ^2.5.1 version: 2.5.1 @@ -1816,7 +1819,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.11.0 - version: 11.11.0(patch_hash=036a1e3d1a57e7418725babb71e5eef5220ae90fc481ad7e04fa7e8901b25801)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@swc/core@1.15.41)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.3)(eslint@9.39.4(jiti@2.6.1))(jest-util@30.4.1)(picomatch@4.0.4)(prettier@3.8.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.11.0(patch_hash=036a1e3d1a57e7418725babb71e5eef5220ae90fc481ad7e04fa7e8901b25801)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@swc/core@1.15.41)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.3)(eslint@9.39.4(jiti@2.6.1))(jest-util@30.4.1)(prettier@3.8.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2791,7 +2794,7 @@ importers: devDependencies: '@mendix/pluggable-widgets-tools': specifier: 11.11.0 - version: 11.11.0(patch_hash=036a1e3d1a57e7418725babb71e5eef5220ae90fc481ad7e04fa7e8901b25801)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@swc/core@1.15.41)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.3)(eslint@9.39.4(jiti@2.6.1))(jest-util@30.4.1)(picomatch@4.0.4)(prettier@3.8.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.11.0(patch_hash=036a1e3d1a57e7418725babb71e5eef5220ae90fc481ad7e04fa7e8901b25801)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@swc/core@1.15.41)(@types/babel__core@7.20.5)(@types/node@24.12.4)(canvas@3.2.3)(eslint@9.39.4(jiti@2.6.1))(jest-util@30.4.1)(prettier@3.8.4)(react-dom@18.3.1(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) rollup-plugin-copy: specifier: ^3.5.0 version: 3.5.0 From 49cebd6975667facd818ee39509d6746df9ae65d Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Fri, 10 Jul 2026 14:39:26 +0200 Subject: [PATCH 3/8] feat(combobox): add useFloatingMenu hook for menu positioning --- .../src/hooks/__mocks__/useFloatingMenu.ts | 18 +++++++ .../combobox-web/src/hooks/useFloatingMenu.ts | 47 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 packages/pluggableWidgets/combobox-web/src/hooks/__mocks__/useFloatingMenu.ts create mode 100644 packages/pluggableWidgets/combobox-web/src/hooks/useFloatingMenu.ts diff --git a/packages/pluggableWidgets/combobox-web/src/hooks/__mocks__/useFloatingMenu.ts b/packages/pluggableWidgets/combobox-web/src/hooks/__mocks__/useFloatingMenu.ts new file mode 100644 index 0000000000..caab8ec02d --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/src/hooks/__mocks__/useFloatingMenu.ts @@ -0,0 +1,18 @@ +import { useRef } from "react"; + +// Jest mock for useFloatingMenu: returns stable refs and a marker style so specs can +// assert wiring without depending on a real layout engine (jsdom has none). +export function useFloatingMenu(_open: boolean): any { + const reference = useRef(null); + const floating = useRef(null); + return { + refs: { + setReference: reference, + setFloating: floating + }, + floatingStyles: { + "--this-is-mocked-from-unit-tests": "true" + }, + isPositioned: true + }; +} diff --git a/packages/pluggableWidgets/combobox-web/src/hooks/useFloatingMenu.ts b/packages/pluggableWidgets/combobox-web/src/hooks/useFloatingMenu.ts new file mode 100644 index 0000000000..23b6a5aec8 --- /dev/null +++ b/packages/pluggableWidgets/combobox-web/src/hooks/useFloatingMenu.ts @@ -0,0 +1,47 @@ +import { autoUpdate, flip, offset, size, useFloating } from "@floating-ui/react"; +import { useMemo } from "react"; + +// Menu can grow to at most this height; floating-ui shrinks it further when the +// available viewport space is smaller. Mirrors the previous SCSS cap. +const MAX_MENU_HEIGHT = 320; +// Gap between the input and the menu, and breathing room from the viewport edge. +const MENU_OFFSET = 4; +const VIEWPORT_PADDING = 8; + +export function useFloatingMenu(open: boolean): ReturnType { + const middleware = useMemo( + () => [ + offset(MENU_OFFSET), + flip({ + crossAxis: false, + fallbackStrategy: "bestFit", + padding: VIEWPORT_PADDING + }), + size({ + padding: VIEWPORT_PADDING, + apply({ rects, elements, availableHeight }) { + Object.assign(elements.floating.style, { + width: `${rects.reference.width}px`, + maxHeight: `${Math.min(availableHeight, MAX_MENU_HEIGHT)}px` + }); + } + }) + ], + [] + ); + + const result = useFloating({ + open, + placement: "bottom-start", + strategy: "fixed", + middleware, + whileElementsMounted: autoUpdate + }); + + const floatingStyles = + open && result.isPositioned + ? result.floatingStyles + : { ...result.floatingStyles, visibility: "hidden" as const }; + + return { ...result, floatingStyles }; +} From 04161d587fee5c6428b5ab987ab4a3bb712d9cd9 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Fri, 10 Jul 2026 14:45:22 +0200 Subject: [PATCH 4/8] fix(combobox): position menu with floating-ui to stop it jumping --- .../src/__tests__/MultiSelection.spec.tsx | 11 ++++ .../src/__tests__/SingleSelection.spec.tsx | 13 +++++ .../src/components/ComboboxMenuWrapper.tsx | 13 ++--- .../MultiSelection/MultiSelection.tsx | 5 ++ .../MultiSelection/MultiSelectionMenu.tsx | 10 +++- .../SingleSelection/SingleSelection.tsx | 5 ++ .../SingleSelection/SingleSelectionMenu.tsx | 10 +++- .../__tests__/ComboboxMenuWrapper.spec.tsx | 51 +++++++++++++++++++ 8 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 packages/pluggableWidgets/combobox-web/src/components/__tests__/ComboboxMenuWrapper.spec.tsx diff --git a/packages/pluggableWidgets/combobox-web/src/__tests__/MultiSelection.spec.tsx b/packages/pluggableWidgets/combobox-web/src/__tests__/MultiSelection.spec.tsx index a2d67ee5cf..a665983f33 100644 --- a/packages/pluggableWidgets/combobox-web/src/__tests__/MultiSelection.spec.tsx +++ b/packages/pluggableWidgets/combobox-web/src/__tests__/MultiSelection.spec.tsx @@ -1,4 +1,5 @@ import "@testing-library/jest-dom"; +jest.mock("../hooks/useFloatingMenu"); import { fireEvent, render, RenderResult, waitFor } from "@testing-library/react"; import { resetIdCounter } from "downshift"; import { ListValue } from "mendix"; @@ -94,6 +95,16 @@ describe("Combo box (Association)", () => { const { container } = render(); expect(container.getElementsByClassName("widget-combobox-placeholder")).toHaveLength(1); }); + it("positions the open menu via floating-ui (applies floatingStyles to the menu)", async () => { + const component = render(); + const input = await getInput(component); + fireEvent.click(input); + await waitFor(() => { + expect(component.getAllByRole("option")).toHaveLength(4); + }); + const menu = component.container.querySelector(".widget-combobox-menu") as HTMLElement; + expect(menu.style.getPropertyValue("--this-is-mocked-from-unit-tests")).toEqual("true"); + }); it("toggles combobox menu on: input CLICK(focus) / BLUR", async () => { const component = render(); const input = await getInput(component); diff --git a/packages/pluggableWidgets/combobox-web/src/__tests__/SingleSelection.spec.tsx b/packages/pluggableWidgets/combobox-web/src/__tests__/SingleSelection.spec.tsx index f587113377..70436dba9a 100644 --- a/packages/pluggableWidgets/combobox-web/src/__tests__/SingleSelection.spec.tsx +++ b/packages/pluggableWidgets/combobox-web/src/__tests__/SingleSelection.spec.tsx @@ -1,4 +1,5 @@ import "@testing-library/jest-dom"; +jest.mock("../hooks/useFloatingMenu"); import { act, fireEvent, render, RenderResult, waitFor } from "@testing-library/react"; import { resetIdCounter } from "downshift"; import { ListValue } from "mendix"; @@ -114,6 +115,18 @@ describe("Combo box (Association)", () => { }); expect(component.queryAllByRole("option")).toHaveLength(0); }); + it("positions the open menu via floating-ui (applies floatingStyles to the menu)", async () => { + const component = render(); + const toggleButton = await getToggleButton(component); + await act(() => { + fireEvent.click(toggleButton); + }); + await waitFor(() => { + expect(component.getAllByRole("option")).toHaveLength(4); + }); + const menu = component.container.querySelector(".widget-combobox-menu") as HTMLElement; + expect(menu.style.getPropertyValue("--this-is-mocked-from-unit-tests")).toEqual("true"); + }); it("sets option to selected item", async () => { const component = render(); const value = await getVisibleValueNode(component); diff --git a/packages/pluggableWidgets/combobox-web/src/components/ComboboxMenuWrapper.tsx b/packages/pluggableWidgets/combobox-web/src/components/ComboboxMenuWrapper.tsx index 51f414c827..96805b5438 100644 --- a/packages/pluggableWidgets/combobox-web/src/components/ComboboxMenuWrapper.tsx +++ b/packages/pluggableWidgets/combobox-web/src/components/ComboboxMenuWrapper.tsx @@ -1,11 +1,12 @@ import classNames from "classnames"; import { UseComboboxPropGetters } from "downshift/typings"; -import { MouseEvent, PropsWithChildren, ReactElement, ReactNode } from "react"; -import { useMenuStyle } from "../hooks/useMenuStyle"; +import { CSSProperties, MouseEvent, PropsWithChildren, ReactElement, ReactNode, Ref } from "react"; import { NoOptionsPlaceholder } from "./Placeholder"; interface ComboboxMenuWrapperProps extends PropsWithChildren, Partial> { alwaysOpen?: boolean; + floatingRef?: Ref; + floatingStyles?: CSSProperties; highlightedIndex?: number | null; isEmpty: boolean; isLoading: boolean; @@ -32,6 +33,8 @@ export function ComboboxMenuWrapper(props: ComboboxMenuWrapperProps): ReactEleme const { alwaysOpen, children, + floatingRef, + floatingStyles, getMenuProps, highlightedIndex, isEmpty, @@ -46,11 +49,9 @@ export function ComboboxMenuWrapper(props: ComboboxMenuWrapperProps): ReactEleme onScroll } = props; - const [ref, style] = useMenuStyle(isOpen); - return (
diff --git a/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx b/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx index 5708a623b3..172f29c6e3 100644 --- a/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx +++ b/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelection.tsx @@ -4,6 +4,7 @@ import { ClearButton } from "../../assets/icons"; import { MultiSelector, SelectionBaseProps } from "../../helpers/types"; import { getInputLabel, getSelectedCaptionsPlaceholder, getValidationErrorId } from "../../helpers/utils"; import { useDownshiftMultiSelectProps } from "../../hooks/useDownshiftMultiSelectProps"; +import { useFloatingMenu } from "../../hooks/useFloatingMenu"; import { useLazyLoading } from "../../hooks/useLazyLoading"; import { ComboboxWrapper } from "../ComboboxWrapper"; import { InputPlaceholder } from "../Placeholder"; @@ -35,6 +36,7 @@ export function MultiSelection({ setSelectedItems, toggleSelectedItem } = useDownshiftMultiSelectProps(selector, options, inputRef, a11yConfig.a11yStatusMessage); + const { refs, floatingStyles } = useFloatingMenu(isOpen); const isSelectedItemsBoxStyle = selector.selectedItemsStyle === "boxes"; const isOptionsSelected = selector.isOptionsSelected(); const inputLabel = getInputLabel(options.inputId); @@ -90,6 +92,7 @@ export function MultiSelection({ return ( ); diff --git a/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelectionMenu.tsx b/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelectionMenu.tsx index b086db8675..62864eb768 100644 --- a/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelectionMenu.tsx +++ b/packages/pluggableWidgets/combobox-web/src/components/MultiSelection/MultiSelectionMenu.tsx @@ -1,5 +1,5 @@ import { UseComboboxPropGetters } from "downshift/typings"; -import { MouseEvent, ReactElement, ReactNode } from "react"; +import { CSSProperties, MouseEvent, ReactElement, ReactNode, Ref } from "react"; import { Checkbox } from "../../assets/icons"; import { MultiSelector } from "../../helpers/types"; import { ComboboxMenuWrapper } from "../ComboboxMenuWrapper"; @@ -20,6 +20,8 @@ interface MultiSelectionMenuProps extends Partial isLoading: boolean; lazyLoading: boolean; onScroll: (e: any) => void; + floatingRef?: Ref; + floatingStyles?: CSSProperties; } export function MultiSelectionMenu({ @@ -36,10 +38,14 @@ export function MultiSelectionMenu({ onOptionClick, isLoading, lazyLoading, - onScroll + onScroll, + floatingRef, + floatingStyles }: MultiSelectionMenuProps): ReactElement { return ( (null); + const { refs, floatingStyles } = useFloatingMenu(keepMenuOpen === true ? false : isOpen); const lazyLoading = selector.lazyLoading ?? false; const { onScroll } = useLazyLoading({ hasMoreItems: selector.options.hasMore ?? false, @@ -86,6 +88,7 @@ export function SingleSelection({ return ( ); diff --git a/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelectionMenu.tsx b/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelectionMenu.tsx index b1de8d6f61..acc7c98918 100644 --- a/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelectionMenu.tsx +++ b/packages/pluggableWidgets/combobox-web/src/components/SingleSelection/SingleSelectionMenu.tsx @@ -1,5 +1,5 @@ import { UseComboboxPropGetters } from "downshift/typings"; -import { ReactElement, ReactNode } from "react"; +import { CSSProperties, ReactElement, ReactNode, Ref } from "react"; import { SingleSelector } from "../../helpers/types"; import { ComboboxMenuWrapper } from "../ComboboxMenuWrapper"; import { ComboboxOptionWrapper } from "../ComboboxOptionWrapper"; @@ -16,6 +16,8 @@ interface ComboboxMenuProps extends Partial> { isLoading: boolean; lazyLoading: boolean; onScroll: (e: any) => void; + floatingRef?: Ref; + floatingStyles?: CSSProperties; } export function SingleSelectionMenu({ @@ -29,13 +31,17 @@ export function SingleSelectionMenu({ menuFooterContent, isLoading, lazyLoading, - onScroll + onScroll, + floatingRef, + floatingStyles }: ComboboxMenuProps): ReactElement { const items = selector.options.getAll(); return ( ({ ...options }) +}; + +describe("ComboboxMenuWrapper", () => { + it("applies floating styles to the menu when open and not alwaysOpen", () => { + const floatingRef = createRef(); + const { container } = render( + + ); + + const menu = container.querySelector(".widget-combobox-menu") as HTMLElement; + expect(menu.style.position).toBe("fixed"); + expect(menu.style.top).toBe("10px"); + // floating ref is attached so floating-ui can measure/position the element + expect(floatingRef.current).toBe(menu); + }); + + it("renders inline (position: relative) and ignores floating positioning when alwaysOpen", () => { + const floatingRef = createRef(); + const { container } = render( + + ); + + const menu = container.querySelector(".widget-combobox-menu") as HTMLElement; + expect(menu.style.position).toBe("relative"); + expect(menu.style.top).toBe(""); + // floating ref must NOT be attached in alwaysOpen mode + expect(floatingRef.current).toBeNull(); + }); +}); From 6460a18b88319b4344637b2b76c61fafe3f71306 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Fri, 10 Jul 2026 14:45:41 +0200 Subject: [PATCH 5/8] refactor(combobox): remove useMenuStyle, let menu shrink and scroll when space is tight --- .../combobox-web/src/hooks/useMenuStyle.ts | 39 ------------------- .../combobox-web/src/ui/Combobox.scss | 16 +++++--- 2 files changed, 10 insertions(+), 45 deletions(-) delete mode 100644 packages/pluggableWidgets/combobox-web/src/hooks/useMenuStyle.ts diff --git a/packages/pluggableWidgets/combobox-web/src/hooks/useMenuStyle.ts b/packages/pluggableWidgets/combobox-web/src/hooks/useMenuStyle.ts deleted file mode 100644 index 1ed8532918..0000000000 --- a/packages/pluggableWidgets/combobox-web/src/hooks/useMenuStyle.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { CSSProperties, RefObject, useEffect, useMemo, useRef, useState } from "react"; -import { usePositionObserver } from "@mendix/widget-plugin-hooks/usePositionObserver"; -import { debounce } from "@mendix/widget-plugin-platform/utils/debounce"; - -export function useMenuStyle(isOpen: boolean): [RefObject, CSSProperties] { - const ref = useRef(null); - const [style, setStyle] = useState({ visibility: "hidden", position: "fixed" }); - const [setStyleDebounced, abort] = useMemo(() => debounce(setStyle, 32), [setStyle]); - const menuHeight = ref.current?.offsetHeight ?? 0; - const targetBox = usePositionObserver(ref.current?.parentElement ?? null, isOpen); - - useEffect(() => { - if (targetBox === undefined || ref.current === null || !isOpen) { - return; - } - - setStyleDebounced({ - visibility: "visible", - position: "fixed", - width: targetBox.width, - ...getMenuPosition(targetBox, ref.current.getBoundingClientRect()) - }); - - return abort; - }, [menuHeight, isOpen, targetBox, setStyleDebounced, abort]); - - return [ref, style]; -} - -function getMenuPosition(targetBox: DOMRect, menuBox: DOMRect): CSSProperties { - const { height } = menuBox; - const bottomSpace = window.innerHeight - targetBox.bottom; - const topSpace = targetBox.top - height < 0 ? targetBox.top - height : 0; - - if (bottomSpace < height) { - return { bottom: window.innerHeight - targetBox.top + topSpace, left: targetBox.left }; - } - return { top: targetBox.bottom, left: targetBox.left }; -} diff --git a/packages/pluggableWidgets/combobox-web/src/ui/Combobox.scss b/packages/pluggableWidgets/combobox-web/src/ui/Combobox.scss index 0a8a1f6014..6badfca347 100644 --- a/packages/pluggableWidgets/combobox-web/src/ui/Combobox.scss +++ b/packages/pluggableWidgets/combobox-web/src/ui/Combobox.scss @@ -23,12 +23,12 @@ $cb-skeleton-dark: #d2d2d2; position: relative; transition: color 150ms ease 0s; &-menu { - position: absolute; - display: inline; + // Positioning (top/left/width/max-height) is applied inline by floating-ui. + // The wrapper is a flex column so its children (header, scrolling list, footer) + // share the height cap floating-ui sets when space is tight. + display: flex; + flex-direction: column; border-radius: var(--dropdown-border-radius, $cb-menu-border-radius); - margin: var(--spacing-smaller, $cb-spacing) 0 var(--spacing-smaller, $cb-spacing) 0; - width: 100%; - left: unset; padding: var(--dropdown-outer-padding, $cb-menu-outer-padding) 0 0; z-index: 25; box-shadow: 0px 0px var(--dropdown-outer-padding, $cb-menu-outer-padding) 0px @@ -38,7 +38,11 @@ $cb-skeleton-dark: #d2d2d2; &-list { padding: 0; margin-bottom: 0; - max-height: 320px; + // Fill the (possibly shrunk) wrapper and scroll internally, instead of a + // fixed cap that would overflow the wrapper when floating-ui shrinks it. + flex: 1; + min-height: 0; + max-height: 100%; overflow-y: auto; &:last-child { margin-bottom: var(--dropdown-outer-padding, $cb-menu-outer-padding); From 87a5dc208642575bdb1c3a99bbca8b3a1ada5d8a Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Fri, 10 Jul 2026 14:46:08 +0200 Subject: [PATCH 6/8] test(combobox): update snapshots and add e2e for menu positioning --- .../combobox-web/e2e/Combobox.spec.js | 65 +++++++++++++++++++ .../MultiSelection.spec.tsx.snap | 6 +- .../SingleSelection.spec.tsx.snap | 2 +- .../StaticSelection.spec.tsx.snap | 2 +- 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js b/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js index f9e2729342..9a366609f9 100644 --- a/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js +++ b/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js @@ -172,6 +172,71 @@ test.describe("combobox-web", () => { await expect(getOptions(comboBox)).toHaveText(["Antartica", "Australia"]); }); }); + + test.describe("menu positioning (floating-ui)", () => { + // Regression for WC-3406: the menu used to flip between above/below the input + // and jump when its content height changed. It must now settle in one place and, + // when space is tight, shrink + scroll within the viewport instead of overflowing. + + test("menu does not jump (top stays stable) while open", async ({ page }) => { + const comboBox = page.locator(".mx-name-comboBox2"); + await expect(comboBox).toBeVisible({ timeout: 10000 }); + + await comboBox.click(); + const menu = page.locator(".mx-name-comboBox2 .widget-combobox-menu").first(); + await expect(menu).toBeVisible(); + + // Let floating-ui position it, then sample the top across several frames. + const readTop = () => menu.evaluate(el => el.getBoundingClientRect().top); + const first = await readTop(); + const samples = []; + for (let i = 0; i < 5; i++) { + await page.waitForTimeout(60); + samples.push(await readTop()); + } + + // No oscillation: every later sample equals the first (sub-pixel tolerance). + for (const top of samples) { + expect(Math.abs(top - first)).toBeLessThanOrEqual(1); + } + }); + + test("menu shrinks and stays within the viewport when space is tight", async ({ page }) => { + const comboBox = page.locator(".mx-name-comboBox2"); + await expect(comboBox).toBeVisible({ timeout: 10000 }); + + // Shrink the viewport so there is little room below the input, forcing the + // size() middleware to cap the menu height. + await page.setViewportSize({ width: 1024, height: 360 }); + + await comboBox.click(); + const menu = page.locator(".mx-name-comboBox2 .widget-combobox-menu").first(); + await expect(menu).toBeVisible(); + + const box = await menu.boundingBox(); + const viewport = page.viewportSize(); + + // Menu bottom stays on screen (respecting the 8px viewport padding). + expect(box.y + box.height).toBeLessThanOrEqual(viewport.height); + // Menu height is capped below the 320px default because space is limited. + expect(box.height).toBeLessThan(320); + }); + + test("menu width matches the input width", async ({ page }) => { + const comboBox = page.locator(".mx-name-comboBox2"); + await expect(comboBox).toBeVisible({ timeout: 10000 }); + + const inputContainer = comboBox.locator(".widget-combobox-input-container").first(); + await comboBox.click(); + const menu = page.locator(".mx-name-comboBox2 .widget-combobox-menu").first(); + await expect(menu).toBeVisible(); + + const inputBox = await inputContainer.boundingBox(); + const menuBox = await menu.boundingBox(); + + expect(Math.abs(menuBox.width - inputBox.width)).toBeLessThanOrEqual(1); + }); + }); }); function getOptions(combobox) { diff --git a/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/MultiSelection.spec.tsx.snap b/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/MultiSelection.spec.tsx.snap index b77acd645e..041e9a3745 100644 --- a/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/MultiSelection.spec.tsx.snap +++ b/packages/pluggableWidgets/combobox-web/src/__tests__/__snapshots__/MultiSelection.spec.tsx.snap @@ -76,7 +76,7 @@ exports[`Combo box (Association) renders combobox widget 1`] = `