From 26569607bef22cc3241268e8609af2e2dd6c1cda Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 18 Jul 2026 17:55:21 +0200 Subject: [PATCH] fix(#296): keep combobox popovers anchored on scroll Open shared combobox popovers now reposition on capture-phase scroll events, so scrolling the Workbench variable strip or Dashboard filter strip keeps the fixed listbox aligned with its input. The listener exists only while open and is removed through the common close path.\n\nCloses #296\n\nCo-Authored-By: OpenAI Codex \nClaude-Session: Codex --- CHANGELOG.md | 5 +++++ src/ui/combobox.ts | 11 +++++++++-- tests/unit/combobox.test.ts | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c33709f..17b9fd67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,11 @@ auto-generated per-PR notes; this file is the curated, human-readable history. never executes queries. ### Fixed +- **Open combobox popovers stay anchored while their containing strip scrolls** + (#296). The shared fixed-position listbox now listens for capture-phase + document scroll events only while open, so horizontal scrolling in either the + Workbench variable strip or Dashboard filter strip updates its anchor instead + of leaving the dropdown behind over unrelated fields. - **The Workbench favorite star now drives Dashboard tile membership** (part of #299, post-#287 aggregate). Since `dashboard.tiles[]` became the canonical Dashboard membership (#280/#287), starring a query only flipped `spec.favorite` diff --git a/src/ui/combobox.ts b/src/ui/combobox.ts index 6d3e5092..e34a43c0 100644 --- a/src/ui/combobox.ts +++ b/src/ui/combobox.ts @@ -22,8 +22,9 @@ // under the input via `fixedAnchor` (dom.js) at open time — the same trick // the File/user menus already use to escape an ancestor's `overflow` // clipping (the var-strip / dashboard filter bar both scroll horizontally). -// Repositioning only happens on open/refresh, not continuously on scroll — -// a known, minor v1 limitation (documented, not silently ignored). +// While open, the listbox repositions on capture-phase document scroll events. +// `scroll` does not bubble, so capture is what sees horizontal strip scrolling +// as well as ordinary document scrolling. import { fixedAnchor } from './dom.js'; @@ -154,6 +155,10 @@ export function createCombobox( listEl.style.minWidth = rect.width + 'px'; } + function repositionOnScroll(): void { + if (open) position(); + } + function render(): void { const ids: string[] = []; const children: HTMLElement[] = []; @@ -193,6 +198,7 @@ export function createCombobox( options = getOptions(input.value) || []; position(); render(); + d.addEventListener('scroll', repositionOnScroll, true); } function closeList(): void { @@ -202,6 +208,7 @@ export function createCombobox( input.setAttribute('aria-expanded', 'false'); activeIndex = -1; input.removeAttribute('aria-activedescendant'); + d.removeEventListener('scroll', repositionOnScroll, true); // Review (phase-7 user feedback): this is the ONE place every close path // funnels through — blur, Escape, Enter (with or without an active // option), AND an option's mousedown-commit (which closes the list diff --git a/tests/unit/combobox.test.ts b/tests/unit/combobox.test.ts index 668953b7..aa6c402f 100644 --- a/tests/unit/combobox.test.ts +++ b/tests/unit/combobox.test.ts @@ -55,6 +55,25 @@ describe('createCombobox — open/close + ARIA', () => { combo.onBlur(); expect(combo.isOpen()).toBe(false); }); + it('repositions its fixed listbox on an ancestor scroll while open, then detaches on close', () => { + const { input, listEl, combo } = build(); + const scroller = document.createElement('div'); + document.body.append(scroller); + scroller.append(input); + let left = 20; + input.getBoundingClientRect = () => ({ bottom: 30, left, width: 100 } as DOMRect); + + combo.onFocus(); + expect(listEl.style.left).toBe('20px'); + left = 60; + scroller.dispatchEvent(new Event('scroll')); + expect(listEl.style.left).toBe('60px'); + + combo.close(); + left = 90; + scroller.dispatchEvent(new Event('scroll')); + expect(listEl.style.left).toBe('60px'); + }); it('the live region announces the option count, or "No matches"', () => { const { input, liveEl, combo } = build(); combo.onFocus();