diff --git a/packages/pluggableWidgets/combobox-web/CHANGELOG.md b/packages/pluggableWidgets/combobox-web/CHANGELOG.md index cb191d46f1..2364b2cb27 100644 --- a/packages/pluggableWidgets/combobox-web/CHANGELOG.md +++ b/packages/pluggableWidgets/combobox-web/CHANGELOG.md @@ -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 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/e2e/Combobox.spec.js-snapshots/comboBoxAssociationOpen-chromium-linux.png b/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxAssociationOpen-chromium-linux.png index 79e6efe270..e1d33e63da 100644 Binary files a/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxAssociationOpen-chromium-linux.png and b/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxAssociationOpen-chromium-linux.png differ diff --git a/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxEnumFooter-chromium-linux.png b/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxEnumFooter-chromium-linux.png index 07b619a52a..3b64441886 100644 Binary files a/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxEnumFooter-chromium-linux.png and b/packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js-snapshots/comboBoxEnumFooter-chromium-linux.png differ 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 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/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/__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`] = `