Skip to content
Draft
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
6 changes: 6 additions & 0 deletions packages/pluggableWidgets/combobox-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Fixed

- We fixed an issue where the combobox menu jumped around and flickered between opening above and below the input.

- We fixed an issue where the combobox collapsed to 1px width when placed inside a flex row container with `align-items: center`.

### Changed

- The combobox menu now keeps a stable position and, when there is not enough space, shrinks and scrolls instead of overflowing the viewport.

## [2.8.1] - 2026-04-30

### Fixed
Expand Down
65 changes: 65 additions & 0 deletions packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: tdd-refactor
created: 2026-07-09
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
## Test Cases

<!--
Unit tests use Jest + RTL and mock `hooks/useFloatingMenu` via
`src/hooks/__mocks__/useFloatingMenu.ts` (the mock returns stable refs + a marker
style so specs don't depend on real layout). The real middleware behavior (flip,
size/shrink) is not exercisable in jsdom (no layout), so it is verified by e2e /
manual showcase rather than unit tests. Unit tests assert the WIRING and the
alwaysOpen bypass; e2e asserts the observable no-jump / shrink behavior.
-->

### 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

<!-- Track unexpected behaviors, additional edge cases found, test failures and resolutions. -->

- 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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
## 1. Test Setup

<!-- RED: Write failing tests / add the mock first -->

- [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

<!-- GREEN: Make tests pass with minimal code -->

- [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

<!-- REFACTOR: Clean up while keeping tests green -->

- [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.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
schema: tdd-refactor
1 change: 1 addition & 0 deletions packages/pluggableWidgets/combobox-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -94,6 +95,16 @@ describe("Combo box (Association)", () => {
const { container } = render(<Combobox {...defaultProps} />);
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(<Combobox {...defaultProps} />);
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(<Combobox {...defaultProps} />);
const input = await getInput(component);
Expand Down
Loading
Loading