@@ -172,6 +172,71 @@ test.describe("combobox-web", () => {
172172 await expect ( getOptions ( comboBox ) ) . toHaveText ( [ "Antartica" , "Australia" ] ) ;
173173 } ) ;
174174 } ) ;
175+
176+ test . describe ( "menu positioning (floating-ui)" , ( ) => {
177+ // Regression for WC-3406: the menu used to flip between above/below the input
178+ // and jump when its content height changed. It must now settle in one place and,
179+ // when space is tight, shrink + scroll within the viewport instead of overflowing.
180+
181+ test ( "menu does not jump (top stays stable) while open" , async ( { page } ) => {
182+ const comboBox = page . locator ( ".mx-name-comboBox2" ) ;
183+ await expect ( comboBox ) . toBeVisible ( { timeout : 10000 } ) ;
184+
185+ await comboBox . click ( ) ;
186+ const menu = page . locator ( ".mx-name-comboBox2 .widget-combobox-menu" ) . first ( ) ;
187+ await expect ( menu ) . toBeVisible ( ) ;
188+
189+ // Let floating-ui position it, then sample the top across several frames.
190+ const readTop = ( ) => menu . evaluate ( el => el . getBoundingClientRect ( ) . top ) ;
191+ const first = await readTop ( ) ;
192+ const samples = [ ] ;
193+ for ( let i = 0 ; i < 5 ; i ++ ) {
194+ await page . waitForTimeout ( 60 ) ;
195+ samples . push ( await readTop ( ) ) ;
196+ }
197+
198+ // No oscillation: every later sample equals the first (sub-pixel tolerance).
199+ for ( const top of samples ) {
200+ expect ( Math . abs ( top - first ) ) . toBeLessThanOrEqual ( 1 ) ;
201+ }
202+ } ) ;
203+
204+ test ( "menu shrinks and stays within the viewport when space is tight" , async ( { page } ) => {
205+ const comboBox = page . locator ( ".mx-name-comboBox2" ) ;
206+ await expect ( comboBox ) . toBeVisible ( { timeout : 10000 } ) ;
207+
208+ // Shrink the viewport so there is little room below the input, forcing the
209+ // size() middleware to cap the menu height.
210+ await page . setViewportSize ( { width : 1024 , height : 360 } ) ;
211+
212+ await comboBox . click ( ) ;
213+ const menu = page . locator ( ".mx-name-comboBox2 .widget-combobox-menu" ) . first ( ) ;
214+ await expect ( menu ) . toBeVisible ( ) ;
215+
216+ const box = await menu . boundingBox ( ) ;
217+ const viewport = page . viewportSize ( ) ;
218+
219+ // Menu bottom stays on screen (respecting the 8px viewport padding).
220+ expect ( box . y + box . height ) . toBeLessThanOrEqual ( viewport . height ) ;
221+ // Menu height is capped below the 320px default because space is limited.
222+ expect ( box . height ) . toBeLessThan ( 320 ) ;
223+ } ) ;
224+
225+ test ( "menu width matches the input width" , async ( { page } ) => {
226+ const comboBox = page . locator ( ".mx-name-comboBox2" ) ;
227+ await expect ( comboBox ) . toBeVisible ( { timeout : 10000 } ) ;
228+
229+ const inputContainer = comboBox . locator ( ".widget-combobox-input-container" ) . first ( ) ;
230+ await comboBox . click ( ) ;
231+ const menu = page . locator ( ".mx-name-comboBox2 .widget-combobox-menu" ) . first ( ) ;
232+ await expect ( menu ) . toBeVisible ( ) ;
233+
234+ const inputBox = await inputContainer . boundingBox ( ) ;
235+ const menuBox = await menu . boundingBox ( ) ;
236+
237+ expect ( Math . abs ( menuBox . width - inputBox . width ) ) . toBeLessThanOrEqual ( 1 ) ;
238+ } ) ;
239+ } ) ;
175240} ) ;
176241
177242function getOptions ( combobox ) {
0 commit comments