diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c33709..17b9fd6 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 6d3e509..e34a43c 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 668953b..aa6c402 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();